jw.pkg: Fix "make check" static code check fallout

The previous commits have put rules for linting and formatting via ruff, yapf, mypy and pyright into place. They are checked with the make check target, and this commit adds the fixes for the target to succeed.

It does some refactoring where type checking dug up dirty bits, and also adds lots of churn in the Python code. To a good deal, that's owed to mere formatting changes. It would have been better to seperate those from syntax and refactoring fixes into multiple commits, so that the interesting changes don't drown in the formatting nose. However, that would have been a lot of additional work only to be thrown away by later commits, hence this commit has a big diff in one piece. The size of the diff is regrettable but hopefully a one-off: What it buys is automatic format checking for CI and predictble formats for smaller diffs in the future.

Rules that "make check" enforces are, in the following order

- Syntax checkers:

- ruff check . - mypy . - pyright

- Format check:

- yapf --diff --recursive .

The refactoring includes:

- Turn the Result class into a more elaborate object, capable of doing more heavy lifting around stderr and stdout decoding, summarizing outcome, and matching error strings.
Aside from fixing broken type checks, this also removes lots of boilerplate calling code which is currently used for handling possible call outcome scenarios. Trying to access an inexistent, decoded string should raise a meaningful exception by itself now, which removes lots of code with case distinctions.

- Fix Cmd type hierarchy:

- Add the AbstractCmd class above Cmd. This is necessary because the checker rightfully complains it can't instantiate a Cmd instance where constructor arguments were needed. They never were, but the type used at the instantiating code's location in jw.pkg.App so claims.
- Lots of sub- and sub-subcommands are derived from the base class of the invoking command. That provides some properties shared across the ancestor hierarchy of a command, but is semantically unsound. Fix that by introducing jw.pkg.BaseCmd class as a place to provide basic helpers shared across all commands used in a jw.pkg.App's context, and derive all command classes from that afresh. The parent command is still reachable via a common parent property.

Formatting changes are conforming to PEP-8, mostly, with minor tweaks. All in all they include the following changes.

- Remove # -*- coding: utf-8 -*-

The line was needed by Python 2 which is not supported anylonger. For Python 3, the default encoding is UTF-8, anyway.
- Allow to run "make py-format" without having it produce any changes. It's basically "yapf --in-place --recursive ." with some code style settings, see conf/topdir/pyproject.toml. The settings may be debatable. I've had custom tweaks in place on that target, too, but then again, IDEs would have more hassle to integrate that.

- Introduce a 88 character line length limit

- One import per line, reshuffle them semantically, see [tool.isort] in pyproject.toml.

- Hide imports needed for type-checking only behind

if TYPE_CHECKING
- Spaces around assignments accounts for much churn. Having having no spaces in inline parameter list assignments and default parameter values would arguably be more compact where it's useful. On the other hand, I have not found a code formatter which allows spaces around assignments in parameter lists broken into one per line and that's often better than a wall of text.
- Add two spaces before # export, as this seems to be mandated by PEP-8

- Use single quotes by default

Signed-off-by: Jan Lindemann <jan@janware.com>
This commit is contained in:
Jan Lindemann 2026-05-27 07:16:05 +02:00
commit 6db73873e7
Signed by: Jan Lindemann
GPG key ID: 3750640C9E25DD61
97 changed files with 3229 additions and 1893 deletions

View file

@ -1,19 +1,17 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import abc, re
import abc
from enum import Enum, auto
from typing import TYPE_CHECKING, Self
from functools import cached_property, cache
from functools import cached_property
from typing import TYPE_CHECKING
from .log import DEBUG, ERR, log
from .Uri import Uri
if TYPE_CHECKING:
from typing import Self
from .log import *
from .base import Input, InputMode, Result, StatResult
from .Uri import Uri
from .ProcFilter import ProcPipeline
from .base import Result, StatResult
from .ProcFilter import ProcFilter, ProcPipeline
class FileContext(abc.ABC):
@ -22,24 +20,27 @@ class FileContext(abc.ABC):
Out = auto()
def __init__(
self,
uri: str|Uri,
interactive: bool|None = None,
verbose_default = False,
chroot: bool = False,
in_pipe: ProcPipeline|None = None,
out_pipe: ProcPipeline|None = None,
):
self,
uri: str | Uri,
interactive: bool | None = None,
verbose_default = False,
chroot: bool = False,
in_pipe: ProcPipeline | None = None,
out_pipe: ProcPipeline | None = None,
):
self.__uri = Uri.pimp(uri)
self.__chroot = chroot
self.__interactive = interactive
self.__verbose_default = verbose_default
self.__log_name: str|None = None
self.__log_name: str | None = None
self.__in_pipe = in_pipe
self.__out_pipe = out_pipe
self.__open_count = 0
if not verbose_default in [True, False]:
raise ValueError(f'Tried to instantiate FileContext with verbose_default = "{verbose_default}"')
if verbose_default not in [True, False]:
raise ValueError(
'Tried to instantiate FileContext with verbose_default '
f'= "{verbose_default}"'
)
async def __aenter__(self):
await self.open()
@ -91,7 +92,9 @@ class FileContext(abc.ABC):
if self.__open_count == 1:
await self._close()
self.__open_count -= 1
assert self.__open_count >= 0, f'Closed file context "{self}" more often than opened'
assert self.__open_count >= 0, (
f'Closed file context "{self}" more often than opened'
)
@property
def uri(self) -> Uri:
@ -106,7 +109,7 @@ class FileContext(abc.ABC):
return self.__uri.path
@property
def username(self) -> str|None:
def username(self) -> str | None:
return self.__uri.username
@property
@ -114,7 +117,7 @@ class FileContext(abc.ABC):
return self.__uri.id
@property
def interactive(self) -> bool|None:
def interactive(self) -> bool | None:
return self.__interactive
@property
@ -123,29 +126,24 @@ class FileContext(abc.ABC):
@abc.abstractmethod
async def _get(
self,
path: str,
wd: str|None,
throw: bool,
verbose: bool|None,
title: str
self, path: str, wd: str | None, throw: bool, verbose: bool | None, title: str
) -> Result:
raise NotImplementedError()
async def get(
self,
path: str,
wd: str|None = None,
wd: str | None = None,
throw: bool = True,
verbose: bool|None = None,
title: str=None,
verbose: bool | None = None,
title: str | None = None,
) -> Result:
ret = await self._get(
self._chroot(path),
wd = wd,
throw = throw,
verbose = verbose,
title = title,
title = title or f'Fetching {path} from {self.uri}',
)
return await self.__in_pipe.run(ret) if self.__in_pipe else ret
@ -153,13 +151,13 @@ class FileContext(abc.ABC):
self,
path: str,
content: bytes,
wd: str|None,
wd: str | None,
throw: bool,
verbose: bool|None,
verbose: bool | None,
title: str,
owner: str|None,
group: str|None,
mode: str|None,
owner: str | None,
group: str | None,
mode: str | None,
atomic: bool,
) -> Result:
raise NotImplementedError()
@ -167,26 +165,26 @@ class FileContext(abc.ABC):
async def put(
self,
path: str,
content: str,
wd: str|None = None,
content: bytes,
wd: str | None = None,
throw: bool = True,
verbose: bool|None = None,
title: str = None,
owner: str|None = None,
group: str|None = None,
mode: int|None = None,
atomic: bool = False
verbose: bool | None = None,
title: str | None = None,
owner: str | None = None,
group: str | None = None,
mode: int | None = None,
atomic: bool = False,
) -> Result:
mode_str = None if mode is None else oct(mode).replace('0o', '0')
if self.__out_pipe is not None:
content = self.__out_pipe.run(content).stdout
result = await self.__out_pipe.run(content)
return await self._put(
self._chroot(path),
content,
result.stdout,
wd = wd,
throw = throw,
verbose = verbose,
title = title,
title = title or f'Pushing content to {path} on {self.uri}',
owner = owner,
group = group,
mode = mode_str,
@ -194,55 +192,73 @@ class FileContext(abc.ABC):
)
async def _unlink(self, path: str) -> None:
raise NotImplementedError(f'{self.log_name}: unlink("{path}") is not implemented')
raise NotImplementedError(
f'{self.log_name}: unlink("{path}") is not implemented'
)
async def unlink(self, path: str) -> None:
return await self._unlink(self._chroot(path))
async def _erase(self, path: str) -> None:
raise NotImplementedError(f'{self.log_name}: erase("{path}") is not implemented')
raise NotImplementedError(
f'{self.log_name}: erase("{path}") is not implemented'
)
async def erase(self, path: str) -> None:
return await self._erase(self._chroot(path))
async def _rename(self, src: str, dst: str) -> None:
raise NotImplementedError(f'{self.log_name}: rename("{path}") is not implemented')
raise NotImplementedError(
f'{self.log_name}: rename("{src}" -> "{dst}") is not implemented'
)
async def rename(self, src: str, dst: str) -> None:
return await self._rename(src, dst)
async def _mkdir(self, path: str, mode: int) -> None:
raise NotImplementedError(f'{self.log_path}: mkdir({path}) is not implemented')
raise NotImplementedError(f'{self.log_name}: mkdir({path}) is not implemented')
async def mkdir(self, path: str, mode: int=0o777) -> None:
async def mkdir(self, path: str, mode: int = 0o777) -> None:
return await self._mkdir(path, mode)
async def _mktemp(self, tmpl: str, directory: bool) -> None:
raise NotImplementedError(f'{self.log_name}: mktemp("{path}") is not implemented')
async def _mktemp(self, tmpl: str, directory: bool) -> str:
raise NotImplementedError(
f'{self.log_name}: mktemp("{tmpl}") is not implemented'
)
async def mktemp(self, tmpl: str, directory: bool=False) -> None:
async def mktemp(self, tmpl: str, directory: bool = False) -> str:
return await self._mktemp(self._chroot(tmpl), directory)
async def _chown(self, path: str, owner: str|None, group: str|None) -> None:
raise NotImplementedError(f'{self.log_name}: chown("{path}") is not implemented')
async def _chown(self, path: str, owner: str | None, group: str | None) -> None:
raise NotImplementedError(
f'{self.log_name}: chown("{path}") is not implemented'
)
async def chown(self, path: str, owner: str|None=None, group: str|None=None) -> None:
async def chown(
self, path: str, owner: str | None = None, group: str | None = None
) -> None:
if owner is None and group is None:
raise ValueError(f'Tried to change ownership of {path} specifying neither owner nor group')
raise ValueError(
f'Tried to change ownership of {path} with neither owner nor group'
)
return await self._chown(self._chroot(path), owner, group)
async def _chmod(self, path: str, mode: int) -> None:
raise NotImplementedError(f'{self.log_name}: chmod("{path}") is not implemented')
raise NotImplementedError(
f'{self.log_name}: chmod("{path}") is not implemented'
)
async def chmod(self, path: str, mode: int) -> None:
return await self._chmod(self._chroot(path), mode)
async def _stat(self, path: str, follow_symlinks: bool) -> StatResult:
raise NotImplementedError(f'{self.log_name}: lstat("{path}") is not implemented')
raise NotImplementedError(
f'{self.log_name}: lstat("{path}") is not implemented'
)
async def stat(self, path: str, follow_symlinks: bool=True) -> StatResult:
async def stat(self, path: str, follow_symlinks: bool = True) -> StatResult:
if not isinstance(path, str):
raise TypeError(f"path must be str, got {type(path).__name__}")
raise TypeError(f'path must be str, got {type(path).__name__}')
return await self._stat(self._chroot(path), follow_symlinks)
async def _file_exists(self, path: str) -> bool:
@ -261,10 +277,17 @@ class FileContext(abc.ABC):
async def _is_dir(self, path: str, follow_symlinks: bool) -> bool:
import stat
try:
return stat.S_ISDIR((await self._stat(path, follow_symlinks)).mode)
except NotImplementedError:
log(DEBUG, f'{self.log_name} doesn\'t implement stat(), judging by trailing slash if {path} is a directory')
log(
DEBUG,
(
f"{self.log_name} doesn't implement stat(), judging by trailing "
'slash if {path} is a directory'
),
)
return path[-1] == '/'
except FileNotFoundError as e:
log(DEBUG, f'{self.log_name}: Failed to stat({path}) ({str(e)})')
@ -274,22 +297,28 @@ class FileContext(abc.ABC):
raise
return False
async def is_dir(self, path: str, follow_symlinks=True) -> bool:
return await self._is_dir(self._chroot(path), follow_symlinks=follow_symlinks)
async def is_dir(self, path: str, follow_symlinks = True) -> bool:
return await self._is_dir(self._chroot(path), follow_symlinks = follow_symlinks)
@classmethod
def create(cls, uri: str|Uri, *args, **kwargs) -> Self:
def create(cls, uri: str | Uri, *args, **kwargs) -> FileContext:
uri = Uri.pimp(uri)
match uri.protocol:
case 'local' | 'file':
from .ec.Local import Local
return Local(uri, *args, **kwargs)
case 'ssh':
from .ec.SSHClient import ssh_client
return ssh_client(uri, *args, **kwargs)
case 'http' | 'https':
from .ec.Curl import Curl
return Curl(uri, *args, **kwargs)
case _:
pass
raise Exception(f'Can\'t create file context instance for "{uri}" with unsupported protocol "{uri.protocol}"')
raise Exception(
f'Can\'t create file context instance for "{uri}" with unsupported '
f'protocol "{uri.protocol}"'
)