jw-pkg/src/python/jw/pkg/lib/util.py
Jan Lindemann 1e613a39c6 App, cmds, lib: Fix Any returns from typed functions
Add type annotations and casts to functions that were returning Any
where a specific type was declared, satisfying the new warn_return_any
mypy rule.

Fixes:
- log.py: get_caller_pos return type via cast
- AsyncRunner.py: cast T for fut.result()
- util.py: cast for getattr result, str() for args.username
- FileContext.py: verbose_default bool annotation
- SSHClient.py: cast SSHClient for dynamic import
- lib/App.py: cast ArgumentParser, add return types to inner funcs
- pm/rpm.py, dpkg.py: cast Iterable[Package]
- App.py: cast for self.args.func(), add return types to inner funcs
- BaseCmdPkgRelations.py: cast str for args.delimiter

Assisted-by: unsloth/Qwen3.6-35B-A3B-GGUF:IQ4_NL and pi.dev 0.81.1
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-08-09 12:07:37 +00:00

300 lines
9.2 KiB
Python

from __future__ import annotations
import json
import os
import sys
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Iterable, TypeVar, cast
from .base import Input, InputMode, Result
from .log import DEBUG, ERR, log
from .Uri import Uri
if TYPE_CHECKING:
from argparse import Namespace
from .ExecContext import ExecContext
from .FileContext import FileContext
from .ProcFilter import ProcFilter, ProcPipeline
T = TypeVar('T')
class AskpassKey(Enum):
Username = auto()
Password = auto()
def pretty_cmd(cmd: list[str] | None = None, wd: str | None = None) -> str:
if cmd is None:
cmd = sys.argv
tokens = [cmd[0]]
for token in cmd[1:]:
if token.find(' ') != -1:
token = '"' + token + '"'
tokens.append(token)
ret = ' '.join(tokens)
if wd is not None:
ret += f' in {wd}'
return ret
# See ExecContext.run() for what this function does
async def run_cmd(
*args: Any,
ec: ExecContext | None = None,
verbose: bool | None = None,
cmd_input: Input = InputMode.NonInteractive,
**kwargs: Any,
) -> Result:
if verbose is None:
verbose = False if ec is None else ec.verbose_default
if ec is None:
from .ec.Local import Local
interactive = cmd_input == InputMode.Interactive
ec = Local(verbose_default = verbose, interactive = interactive)
kwargs['verbose'] = verbose
return await ec.run(*args, **kwargs)
async def run_curl(
args: list[str],
wd: str | None = None,
throw: bool | None = None,
verbose: bool | None = None,
cmd_input: Input = InputMode.NonInteractive,
ec: ExecContext | None = None,
decode: bool = False,
) -> Result:
if verbose is None:
verbose = False if ec is None else ec.verbose_default
cmd = ['curl']
if not verbose:
cmd.append('-s')
cmd.extend(args)
return await run_cmd(
cmd, wd = wd, throw = throw, verbose = verbose, cmd_input = cmd_input, ec = ec
)
async def run_curl_into(
expected_type: type[T],
args: list[str],
**kwargs: Any,
) -> T:
result = await run_curl(args, **kwargs)
stdout = result.stdout_str
try:
ret = json.loads(stdout)
except Exception as e:
log(
ERR,
f'Failed to parse {len(stdout)} bytes of Curl output ({str(e)})',
file = sys.stderr,
)
raise
if not isinstance(ret, expected_type):
raise TypeError(
f'Expected {expected_type.__name__}, got {type(ret).__name__} from Curl'
)
return ret
async def run_askpass(
askpass_env: list[str],
key: AskpassKey,
host: str | None = None,
ec: ExecContext | None = None,
throw: bool = False,
) -> str | None:
if host is not None: # Currently unsupported
raise NotImplementedError(f'Tried to run askpass with host "{host}"')
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'
case _:
pass
result = await run_cmd([exe, exe_arg], throw = throw, ec = ec)
if result.status == 0 and result.stdout_or_none is not None:
ret = result.stdout_str_or_none
if ret:
return ret
msg = (
f"Trying to get user data from {', '.join(askpass_env)} didn't produce anything"
)
if throw:
raise Exception(msg)
log(DEBUG, msg)
return None
async def run_sudo(
cmd: list[str],
*args: Any,
interactive: bool = True,
ec: ExecContext | None = None,
**kwargs: Any,
) -> Result:
if ec is None:
from .ec.Local import Local
ec = Local(interactive = interactive)
return await ec.sudo(cmd, *args, **kwargs)
async def get(
uri: str | Uri,
*args: Any,
ctx: FileContext | None = None,
content_filter: ProcFilter | list[ProcFilter] | ProcPipeline | None = None,
**kwargs: Any,
) -> Result:
uri = Uri.pimp(uri)
if ctx is None or uri.id != ctx.uri.id:
from .FileContext import FileContext
ctx = FileContext.create(uri)
from .ProcFilter import run as run_pipeline
return await run_pipeline(await ctx.get(uri.path, *args, **kwargs), content_filter)
async def copy(
src_uri: str | Iterable[str],
dst: str | FileContext,
owner: str | None = None,
group: str | None = None,
mode: int | None = None,
throw: bool = True,
) -> Exception | str | list[str]:
if not isinstance(src_uri, str):
ret: list[str] = []
for uri in src_uri: # TODO: Group identical netlocs into one CopyContext
rr = await copy(uri, dst, owner, group, mode, throw)
if isinstance(rr, Exception):
return rr
if isinstance(rr, list):
ret.extend(rr)
if isinstance(rr, str):
ret.append(rr)
else:
raise Exception(f'copy() returned unexpected type {type(rr)}')
return ret
from .CopyContext import CopyContext
async with CopyContext(src_uri, dst) as ctx:
try:
result = await ctx.src.get(ctx.src.root, throw = True)
dst_path = ctx.dst.root
if await ctx.dst.is_dir(ctx.dst.root):
dst_path += '/' + os.path.basename(src_uri)
await ctx.dst.put(
path = dst_path,
content = result.stdout,
owner = owner,
group = group,
mode = mode,
throw = True,
)
return dst_path
except Exception as e:
if throw:
raise
log(ERR, f'Failed to copy {src_uri} -> {dst} ({str(e)})')
return e
assert False, 'Unreachable code'
async def get_username( # export
args: Namespace | None = None,
url: str | None = None,
askpass_env: list[str] = [],
ec: ExecContext | None = None,
) -> str | None:
url_user = None if url is None else Uri(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}", '
f'URL has user name "{url_user}"'
)
return str(args.username)
if url_user is not None:
return url_user
return await run_askpass(askpass_env, AskpassKey.Username, ec = ec)
async def get_password( # export
args: Namespace | None = None,
url: str | None = None,
askpass_env: list[str] = [],
ec: ExecContext | None = None,
) -> str | None:
if args is None and url is None and not askpass_env:
raise Exception(
'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 = cast('str | None', getattr(args, 'password'))
if ret is not None:
return ret
if url is not None:
ret = Uri(url).password
if ret is not None:
return ret
return await run_askpass(askpass_env, AskpassKey.Password, ec = ec)
async def get_profile_env( # export
throw: bool = True,
keep: Iterable[str] | bool = False,
ec: ExecContext | None = None,
) -> dict[str, str]:
"""
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
"""
mod_env: dict[str, str] | None = None
if (not keep) or isinstance(keep, Iterable):
mod_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']
result = await run_cmd(
cmd, throw = throw, verbose = True, mod_env = mod_env, ec = ec
)
ret: dict[str, str] = {}
stdout = result.stdout_or_none
if stdout is not None:
for entry in stdout.rstrip(b'\0').split(b'\0'):
if not entry:
continue
bkey, bval = entry.split(b'=', 1)
ret[bkey.decode()] = bval.decode()
if isinstance(keep, Iterable):
for key in keep:
val = os.getenv(key)
if val is not None:
ret[key] = val
return ret