2025-11-18 12:07:02 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
2026-02-19 07:33:59 +01:00
|
|
|
from typing import Sequence, Iterable
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
import os, sys, subprocess, json, time, asyncio
|
|
|
|
|
|
2025-11-18 12:07:02 +01:00
|
|
|
from argparse import Namespace
|
|
|
|
|
from urllib.parse import urlparse
|
2025-11-20 11:29:50 +01:00
|
|
|
from enum import Enum, auto
|
|
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
from .log import *
|
|
|
|
|
|
2025-11-20 11:29:50 +01:00
|
|
|
class AskpassKey(Enum):
|
|
|
|
|
Username = auto()
|
|
|
|
|
Password = auto()
|
2025-11-18 12:07:02 +01:00
|
|
|
|
2025-11-20 10:44:14 +01:00
|
|
|
def pretty_cmd(cmd: list[str], wd=None):
|
2025-11-18 12:07:02 +01:00
|
|
|
tokens = [cmd[0]]
|
|
|
|
|
for token in cmd[1:]:
|
|
|
|
|
if token.find(' ') != -1:
|
|
|
|
|
token = '"' + token + '"'
|
|
|
|
|
tokens.append(token)
|
2026-02-23 10:26:59 +01:00
|
|
|
ret = ' '.join(tokens)
|
2025-11-18 12:07:02 +01:00
|
|
|
if wd is not None:
|
2025-11-20 10:44:14 +01:00
|
|
|
ret += f' in {wd}'
|
|
|
|
|
return ret
|
|
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
async def run_cmd(
|
2026-02-26 15:55:52 +01:00
|
|
|
args: list[str],
|
2026-02-18 13:38:08 +01:00
|
|
|
wd: str|None = None,
|
2026-01-27 16:20:57 +01:00
|
|
|
throw: bool = True,
|
|
|
|
|
verbose: bool = False,
|
|
|
|
|
cmd_input: str|None = None,
|
2026-02-18 13:38:08 +01:00
|
|
|
env: dict[str, str]|None = None,
|
|
|
|
|
title: str=None,
|
|
|
|
|
output_encoding: str|None = None, # None => unchanged; "bytes" => return raw bytes
|
|
|
|
|
) -> tuple[str|bytes|None, str|bytes|None]:
|
2026-01-27 16:20:57 +01:00
|
|
|
"""
|
|
|
|
|
Run a command asynchronously and return its output
|
|
|
|
|
|
|
|
|
|
Args:
|
2026-02-26 15:55:52 +01:00
|
|
|
args: Command and arguments
|
2026-01-27 16:20:57 +01:00
|
|
|
wd: Optional working directory
|
|
|
|
|
throw: Raise an exception on non-zero exit status if True
|
|
|
|
|
verbose: Emit log output while the command runs
|
|
|
|
|
cmd_input:
|
|
|
|
|
- None -> stdin from /dev/null
|
|
|
|
|
- "mode:interactive" -> Inherit terminal stdin
|
|
|
|
|
- "mode:auto" -> Inherit terminal stdin if it is a TTY
|
|
|
|
|
- otherwise -> String fed to stdin
|
2026-02-18 13:38:08 +01:00
|
|
|
output_encoding:
|
|
|
|
|
- None -> unchanged behavior (decode stdout via sys.stdout.encoding, stderr via sys.stderr.encoding)
|
|
|
|
|
- "bytes" -> return raw bytes instead of decoded strings
|
|
|
|
|
- otherwise -> decode stdout/stderr using this encoding
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
Returns:
|
2026-03-04 08:58:46 +01:00
|
|
|
(stdout, stderr, exit_status):
|
|
|
|
|
stdout: stderr each as a string/bytes or None
|
|
|
|
|
In PTY mode stderr is always None because PTY merges stdout/stderr.
|
2026-01-27 16:20:57 +01:00
|
|
|
"""
|
|
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
want_bytes = (output_encoding == "bytes")
|
|
|
|
|
|
2026-02-23 10:26:59 +01:00
|
|
|
def __log(prio, *args, verbose=verbose):
|
2026-01-27 16:20:57 +01:00
|
|
|
if verbose:
|
|
|
|
|
log(prio, "|", *args)
|
|
|
|
|
|
2026-02-23 10:26:59 +01:00
|
|
|
def __check_exit_code(code: int, stdout=None, stderr=None):
|
|
|
|
|
if code == 0:
|
|
|
|
|
return
|
|
|
|
|
if (throw or verbose):
|
lib.Distro, ExecContext: Add classes, refactor lib.distro
The code below lib.distro, as left behind by the previous commit, is
geared towards being directly used as a command-line API. This commit
introduces the abstract base class Distro, a proxy for
distribution-specific interactions. The proxy abstracts distro
specifics into an API with proper method prototypes, not
argparse.Namespace contents, and can thus be more easily driven by
arbitrary code.
The Distro class is initialized with a member variable of type
ExecContext, another new class introduced by this commit. It is
designed to abstract the communication channel to the distribution
instance. Currently only one specialization exists, Local, which
interacts with the distribution and root file system it is running
in, but is planned to be subclassed to support interaction via SSH,
serial, chroot, or chains thereof.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-05 17:33:52 +01:00
|
|
|
msg = f'Command returned error {code}: {pretty_cmd(args, wd)}'
|
2026-02-23 10:26:59 +01:00
|
|
|
if stderr:
|
lib.Distro, ExecContext: Add classes, refactor lib.distro
The code below lib.distro, as left behind by the previous commit, is
geared towards being directly used as a command-line API. This commit
introduces the abstract base class Distro, a proxy for
distribution-specific interactions. The proxy abstracts distro
specifics into an API with proper method prototypes, not
argparse.Namespace contents, and can thus be more easily driven by
arbitrary code.
The Distro class is initialized with a member variable of type
ExecContext, another new class introduced by this commit. It is
designed to abstract the communication channel to the distribution
instance. Currently only one specialization exists, Local, which
interacts with the distribution and root file system it is running
in, but is planned to be subclassed to support interaction via SSH,
serial, chroot, or chains thereof.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-05 17:33:52 +01:00
|
|
|
msg += ': ' + stderr.strip()
|
2026-01-27 16:20:57 +01:00
|
|
|
if throw:
|
|
|
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
def __make_pty_reader(collector: list[bytes], enc_for_verbose: str):
|
2026-01-27 16:20:57 +01:00
|
|
|
def _read(fd):
|
2026-03-03 08:34:27 +01:00
|
|
|
ret = os.read(fd, 1024)
|
|
|
|
|
if not ret:
|
|
|
|
|
return ret
|
|
|
|
|
collector.append(ret)
|
|
|
|
|
return ret
|
2026-01-27 16:20:57 +01:00
|
|
|
return _read
|
2025-11-20 10:44:14 +01:00
|
|
|
|
2026-03-04 15:26:25 +00:00
|
|
|
interactive = (
|
|
|
|
|
cmd_input == "mode:interactive"
|
|
|
|
|
or (cmd_input == "mode:auto" and sys.stdin.isatty())
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-18 12:07:02 +01:00
|
|
|
if verbose:
|
|
|
|
|
delim_len = 120
|
2026-02-18 10:59:22 +01:00
|
|
|
delim = title if title is not None else f'---- Running {pretty_cmd(args, wd)} -'
|
2026-03-04 15:26:25 +00:00
|
|
|
if interactive:
|
|
|
|
|
log(NOTICE, delim)
|
|
|
|
|
else:
|
|
|
|
|
delim += '-' * max(0, delim_len - len(delim))
|
|
|
|
|
log(NOTICE, ',' + delim + ' >')
|
2025-11-18 12:07:02 +01:00
|
|
|
|
|
|
|
|
cwd: str|None = None
|
|
|
|
|
if wd is not None:
|
|
|
|
|
cwd = os.getcwd()
|
|
|
|
|
os.chdir(wd)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
# -- interactive mode
|
|
|
|
|
|
2026-03-04 15:26:25 +00:00
|
|
|
if interactive:
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
import pty
|
|
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
stdout_chunks_b: list[bytes] = []
|
2026-01-27 16:20:57 +01:00
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
enc_for_verbose = (
|
|
|
|
|
(sys.stdout.encoding or "utf-8")
|
|
|
|
|
if output_encoding in (None, "bytes")
|
|
|
|
|
else output_encoding
|
2026-01-27 16:20:57 +01:00
|
|
|
)
|
|
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
reader = __make_pty_reader(stdout_chunks_b, enc_for_verbose)
|
|
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
def _spawn():
|
2026-02-18 13:29:43 +01:00
|
|
|
# Apply env in PTY mode by temporarily updating os.environ around spawn.
|
|
|
|
|
if env:
|
|
|
|
|
old_env = os.environ.copy()
|
|
|
|
|
try:
|
|
|
|
|
os.environ.update(env)
|
|
|
|
|
return pty.spawn(args, master_read=reader)
|
|
|
|
|
finally:
|
|
|
|
|
os.environ.clear()
|
|
|
|
|
os.environ.update(old_env)
|
2026-01-27 16:20:57 +01:00
|
|
|
return pty.spawn(args, master_read=reader)
|
|
|
|
|
|
2026-03-03 08:34:27 +01:00
|
|
|
exit_code = await asyncio.to_thread(_spawn)
|
|
|
|
|
__check_exit_code(exit_code)
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
# PTY merges stdout/stderr
|
2026-02-18 13:38:08 +01:00
|
|
|
stdout_b = b"".join(stdout_chunks_b) if stdout_chunks_b else None
|
|
|
|
|
if want_bytes:
|
2026-03-03 08:34:27 +01:00
|
|
|
return stdout_b, None, exit_code
|
2026-02-18 13:38:08 +01:00
|
|
|
|
|
|
|
|
stdout_dec_enc = (sys.stdout.encoding or "utf-8") if output_encoding is None else output_encoding
|
|
|
|
|
stdout_s = stdout_b.decode(stdout_dec_enc, errors="replace") if stdout_b is not None else None
|
2026-03-03 08:34:27 +01:00
|
|
|
return stdout_s, None, exit_code
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
# -- non-interactive mode
|
|
|
|
|
stdin = (
|
|
|
|
|
asyncio.subprocess.DEVNULL
|
|
|
|
|
if cmd_input is None
|
|
|
|
|
else asyncio.subprocess.PIPE
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
|
|
|
*args,
|
|
|
|
|
stdin=stdin,
|
|
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
|
|
stderr=asyncio.subprocess.PIPE,
|
|
|
|
|
env=env,
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
stdout_parts_b: list[bytes] = []
|
|
|
|
|
stderr_parts_b: list[bytes] = []
|
2026-01-27 16:20:57 +01:00
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
# -- decoding for verbose output in pipe mode
|
|
|
|
|
if output_encoding is None or want_bytes:
|
|
|
|
|
stdout_log_enc = sys.stdout.encoding or "utf-8"
|
|
|
|
|
stderr_log_enc = sys.stderr.encoding or "utf-8"
|
|
|
|
|
else:
|
|
|
|
|
stdout_log_enc = output_encoding
|
|
|
|
|
stderr_log_enc = output_encoding
|
|
|
|
|
|
|
|
|
|
async def read_stream(stream, prio, collector: list[bytes], log_enc: str):
|
2026-03-04 08:45:38 +01:00
|
|
|
buf = b""
|
2026-01-27 16:20:57 +01:00
|
|
|
while True:
|
2026-03-04 08:45:38 +01:00
|
|
|
chunk = await stream.read(4096)
|
|
|
|
|
if not chunk:
|
2026-01-27 16:20:57 +01:00
|
|
|
break
|
2026-03-04 08:45:38 +01:00
|
|
|
collector.append(chunk)
|
2026-02-18 13:38:08 +01:00
|
|
|
if verbose:
|
2026-03-04 08:45:38 +01:00
|
|
|
buf += chunk
|
|
|
|
|
while b"\n" in buf:
|
|
|
|
|
line, buf = buf.split(b"\n", 1)
|
|
|
|
|
__log(prio, line.decode(log_enc, errors="replace"))
|
|
|
|
|
if verbose and buf:
|
|
|
|
|
# flush trailing partial line (no newline)
|
|
|
|
|
__log(prio, buf.decode(log_enc, errors="replace"))
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
tasks = [
|
|
|
|
|
asyncio.create_task(
|
2026-02-18 13:38:08 +01:00
|
|
|
read_stream(proc.stdout, NOTICE, stdout_parts_b, stdout_log_enc)
|
2026-01-27 16:20:57 +01:00
|
|
|
),
|
|
|
|
|
asyncio.create_task(
|
2026-02-18 13:38:08 +01:00
|
|
|
read_stream(proc.stderr, ERR, stderr_parts_b, stderr_log_enc)
|
2026-01-27 16:20:57 +01:00
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if stdin is asyncio.subprocess.PIPE:
|
|
|
|
|
proc.stdin.write(cmd_input.encode(sys.stdout.encoding or "utf-8"))
|
|
|
|
|
await proc.stdin.drain()
|
|
|
|
|
proc.stdin.close()
|
|
|
|
|
|
|
|
|
|
exit_code = await proc.wait()
|
|
|
|
|
await asyncio.gather(*tasks)
|
2026-02-23 10:26:59 +01:00
|
|
|
if want_bytes:
|
|
|
|
|
__check_exit_code(exit_code)
|
2026-01-27 16:20:57 +01:00
|
|
|
|
2026-02-18 13:38:08 +01:00
|
|
|
stdout_b = b"".join(stdout_parts_b) if stdout_parts_b else None
|
|
|
|
|
stderr_b = b"".join(stderr_parts_b) if stderr_parts_b else None
|
|
|
|
|
|
|
|
|
|
if want_bytes:
|
2026-03-03 08:34:27 +01:00
|
|
|
return stdout_b, stderr_b, exit_code
|
2026-02-18 13:38:08 +01:00
|
|
|
|
|
|
|
|
if output_encoding is None:
|
|
|
|
|
stdout_dec_enc = sys.stdout.encoding or "utf-8"
|
|
|
|
|
stderr_dec_enc = sys.stderr.encoding or "utf-8"
|
|
|
|
|
else:
|
|
|
|
|
stdout_dec_enc = output_encoding
|
|
|
|
|
stderr_dec_enc = output_encoding
|
|
|
|
|
|
|
|
|
|
stdout_s = stdout_b.decode(stdout_dec_enc, errors="replace") if stdout_b is not None else None
|
|
|
|
|
stderr_s = stderr_b.decode(stderr_dec_enc, errors="replace") if stderr_b is not None else None
|
2026-02-23 10:26:59 +01:00
|
|
|
|
|
|
|
|
if not want_bytes:
|
|
|
|
|
__check_exit_code(exit_code, stdout=stdout_s, stderr=stderr_s)
|
|
|
|
|
|
2026-03-03 08:34:27 +01:00
|
|
|
return stdout_s, stderr_s, exit_code
|
2026-01-27 16:20:57 +01:00
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
if cwd is not None:
|
|
|
|
|
os.chdir(cwd)
|
2026-03-04 15:26:25 +00:00
|
|
|
if verbose and not interactive:
|
2026-01-27 16:20:57 +01:00
|
|
|
log(NOTICE, '`' + delim + ' <')
|
|
|
|
|
|
|
|
|
|
async def run_curl(args: list[str], parse_json: bool=True, wd=None, throw=None, verbose=False, cmd_input=None) -> dict|str: # export
|
2025-11-18 12:07:02 +01:00
|
|
|
cmd = ['curl']
|
|
|
|
|
if not verbose:
|
|
|
|
|
cmd.append('-s')
|
|
|
|
|
cmd.extend(args)
|
2026-03-03 08:34:27 +01:00
|
|
|
ret, stderr, status = await run_cmd(cmd, wd=wd, throw=throw, verbose=verbose, cmd_input=cmd_input)
|
2025-11-18 12:07:02 +01:00
|
|
|
if parse_json:
|
2025-11-20 10:44:14 +01:00
|
|
|
try:
|
2026-03-03 08:34:27 +01:00
|
|
|
ret = json.loads(ret)
|
2025-11-20 10:44:14 +01:00
|
|
|
except Exception as e:
|
2026-02-27 15:45:08 +01:00
|
|
|
size = 'unknown number of'
|
2026-01-27 16:20:57 +01:00
|
|
|
try:
|
|
|
|
|
size = len(ret)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
2026-02-27 15:45:08 +01:00
|
|
|
print(f'Failed to parse {size} bytes output of command '
|
|
|
|
|
+ f'>{pretty_cmd(cmd, wd)}< ({str(e)}): "{ret}"', file=sys.stderr)
|
2025-11-20 10:44:14 +01:00
|
|
|
raise
|
2026-03-03 08:34:27 +01:00
|
|
|
return ret, stderr, status
|
2025-11-18 12:07:02 +01:00
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
async def run_askpass(askpass_env: list[str], key: AskpassKey, host: str|None=None):
|
2025-11-20 11:29:50 +01:00
|
|
|
assert host is None # Currently unsupported
|
|
|
|
|
for var in askpass_env:
|
|
|
|
|
exe = os.getenv(var)
|
|
|
|
|
if exe is None:
|
|
|
|
|
continue
|
|
|
|
|
exe_arg = ''
|
|
|
|
|
match var:
|
|
|
|
|
case 'GIT_ASKPASS':
|
|
|
|
|
match key:
|
|
|
|
|
case AskpassKey.Username:
|
|
|
|
|
exe_arg += 'Username'
|
|
|
|
|
case AskpassKey.Password:
|
|
|
|
|
exe_arg += 'Password'
|
|
|
|
|
case 'SSH_ASKPASS':
|
|
|
|
|
match key:
|
|
|
|
|
case AskpassKey.Username:
|
|
|
|
|
continue # Can't get user name from SSH_ASKPASS
|
|
|
|
|
case AskpassKey.Password:
|
|
|
|
|
exe_arg += 'Password'
|
2026-03-03 08:34:27 +01:00
|
|
|
ret, stderr, status = await run_cmd([exe, exe_arg], throw=False)
|
2025-11-20 11:29:50 +01:00
|
|
|
if ret is not None:
|
|
|
|
|
return ret
|
|
|
|
|
return None
|
|
|
|
|
|
2026-02-20 18:36:32 +01:00
|
|
|
async def run_sudo(cmd: list[str], mod_env: dict[str, str] = {}, opts: list[str]=[], interactive: bool=True, verbose=True):
|
lib.Distro, ExecContext: Add classes, refactor lib.distro
The code below lib.distro, as left behind by the previous commit, is
geared towards being directly used as a command-line API. This commit
introduces the abstract base class Distro, a proxy for
distribution-specific interactions. The proxy abstracts distro
specifics into an API with proper method prototypes, not
argparse.Namespace contents, and can thus be more easily driven by
arbitrary code.
The Distro class is initialized with a member variable of type
ExecContext, another new class introduced by this commit. It is
designed to abstract the communication channel to the distribution
instance. Currently only one specialization exists, Local, which
interacts with the distribution and root file system it is running
in, but is planned to be subclassed to support interaction via SSH,
serial, chroot, or chains thereof.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-05 17:33:52 +01:00
|
|
|
from .ec.Local import Local
|
|
|
|
|
ec = Local()
|
|
|
|
|
return await ec.sudo(cmd, mod_env, opts, interactive, verbose)
|
2026-02-17 10:19:57 +01:00
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
async def get_username(args: Namespace|None=None, url: str|None=None, askpass_env: list[str]=[]) -> str: # export
|
2025-11-18 12:07:02 +01:00
|
|
|
url_user = None if url is None else urlparse(url).username
|
|
|
|
|
if args is not None:
|
|
|
|
|
if args.username is not None:
|
|
|
|
|
if url_user is not None and url_user != args.username:
|
|
|
|
|
raise Exception(f'Username mismatch: called with --username="{args.username}", URL has user name "{url_user}"')
|
|
|
|
|
return args.username
|
2025-11-20 11:29:50 +01:00
|
|
|
if url_user is not None:
|
2025-11-18 12:07:02 +01:00
|
|
|
return url_user
|
2026-01-27 16:20:57 +01:00
|
|
|
return await run_askpass(askpass_env, AskpassKey.Username)
|
2025-11-18 12:07:02 +01:00
|
|
|
|
2026-01-27 16:20:57 +01:00
|
|
|
async def get_password(args: Namespace|None=None, url: str|None=None, askpass_env: list[str]=[]) -> str: # export
|
2025-11-18 12:07:02 +01:00
|
|
|
if args is None and url is None and not askpass_env:
|
|
|
|
|
raise Exception(f'Neither URL nor command-line arguments nor askpass environment variable available, can\'t get password')
|
|
|
|
|
if args is not None and hasattr(args, 'password'): # use getattr(), because we don't necessarily want to have insecure --password among options
|
|
|
|
|
ret = getattr(args, 'password')
|
|
|
|
|
if ret is not None:
|
|
|
|
|
return ret
|
|
|
|
|
if url is not None:
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
if parsed.password is not None:
|
|
|
|
|
return parsed.password
|
2026-01-27 16:20:57 +01:00
|
|
|
return await run_askpass(askpass_env, AskpassKey.Password)
|
2026-02-18 14:18:26 +01:00
|
|
|
|
2026-02-19 07:33:59 +01:00
|
|
|
async def get_profile_env(throw: bool=True, keep: Iterable[str]|bool=False) -> dict[str, str]: # export
|
|
|
|
|
"""
|
|
|
|
|
Get a fresh environment from /etc/profile
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
keep:
|
|
|
|
|
- False -> Don't keep anything
|
|
|
|
|
- True -> Keep what's in the current environment
|
|
|
|
|
- List of strings -> Keep those variables
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dictionary with fresh environment
|
|
|
|
|
"""
|
|
|
|
|
env: dict[str,str]|None = None
|
|
|
|
|
if keep == False or isinstance(keep, Iterable):
|
|
|
|
|
env = {
|
|
|
|
|
'HOME': os.environ.get('HOME', '/'),
|
|
|
|
|
'USER': os.environ.get('USER', ''),
|
|
|
|
|
'PATH': '/usr/bin:/bin',
|
|
|
|
|
}
|
|
|
|
|
# Run bash as a login shell, which sources /etc/profile, then print environment as NUL-separated key=value pairs
|
|
|
|
|
cmd = ['/usr/bin/env', '-i', '/bin/bash', '-lc', 'env -0']
|
2026-03-03 08:34:27 +01:00
|
|
|
stdout, stderr, status = await run_cmd(cmd, throw=throw, output_encoding="bytes", verbose=True, env=env)
|
2026-02-18 14:18:26 +01:00
|
|
|
ret: dict[str, str] = {}
|
|
|
|
|
for entry in stdout.split(b"\0"):
|
|
|
|
|
if not entry:
|
|
|
|
|
continue
|
|
|
|
|
key, val = entry.split(b"=", 1)
|
|
|
|
|
ret[key.decode()] = val.decode()
|
2026-02-19 07:33:59 +01:00
|
|
|
if isinstance(keep, Iterable):
|
|
|
|
|
for key in keep:
|
|
|
|
|
val = os.getenv(key)
|
|
|
|
|
if val is not None:
|
|
|
|
|
ret[key] = val
|
2026-02-18 14:18:26 +01:00
|
|
|
return ret
|