From 8c47726a8d6a670b7fc2848b253974f1618f94f6 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:43:16 +0200 Subject: [PATCH 01/12] lib.App: Fix crash on invalid log options The --log-level and --log-flags options are added without a type converter, so argparse never validates their values. _build_parser() then hands the raw string to set_log_level() and set_log_flags() during its first parse, and an unparseable value such as "INVALID" crashes __init__() with a raw ValueError traceback instead of a usage error. Pass type = parse_log_level() and type = parse_log_flags() when adding the options, so that argparse reports invalid values with the standard usage error and exit status 2. argparse only applies the converter to command-line strings, so the int and LogFlag defaults are unaffected. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index de0289a6..52cd4f15 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -35,10 +35,16 @@ class App: # export def _add_arguments(self, parser: ArgumentParser) -> None: self.__parser.add_argument( - '--log-flags', help = 'Log flags', default = self.__default_log_flags + '--log-flags', + help = 'Log flags', + default = self.__default_log_flags, + type = parse_log_flags, ) self.__parser.add_argument( - '--log-level', help = 'Log level', default = self.__default_log_level + '--log-level', + help = 'Log level', + default = self.__default_log_level, + type = parse_log_level, ) self.__parser.add_argument( '--log-file', help = 'Log file', default = self.__default_log_file -- 2.55.0 From 679e66898ea04720109868b3f9c3b8f69b11442f Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 12:01:24 +0200 Subject: [PATCH 02/12] lib.App: Implement __aenter__() and __aexit__() __aenter__() and __aexit__() are empty stubs. Using the application as an async context manager therefore binds None in the as clause, and releases nothing on exit: an AsyncRunner created through call_async() keeps running in its thread, and since that thread is not a daemon, the process does not exit after the block. Return self from __aenter__(), and call close() from __aexit__(), so that the context manager binds the application and releases all resources on exit, whether the block exits normally or with an exception. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 52cd4f15..a298c1cf 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -222,8 +222,8 @@ class App: # export self.__eloop.close() self.__eloop = None - async def __aenter__(self) -> None: - pass + async def __aenter__(self) -> App: + return self async def __aexit__( self, @@ -231,7 +231,7 @@ class App: # export exc: BaseException | None, tb: types.TracebackType | None, ) -> None: - pass + self.close() async def __run(self, argv: list[str] | None = None) -> None: -- 2.55.0 From 157fa86fb79a76b7fe2dd1f294d688652e9198cf Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:56:33 +0200 Subject: [PATCH 03/12] lib.App: Release async runner in close() close() closes the application's own event loop, but the AsyncRunner is only released in the finally block of run(). An application that creates a runner through call_async() and then calls close(), for instance through the async context manager, therefore leaks the runner, and close() does not fulfill its contract of releasing all resources. Move the AsyncRunner cleanup from the finally block of run() into close() and reset the own-loop flag when the loop is closed, so that close() releases everything and run() only has to call it. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index a298c1cf..6eb45581 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -216,11 +216,16 @@ class App: # export ) def close(self) -> None: + """Close the application and release all resources""" + if self.__async_runner is not None: + self.__async_runner.close() + self.__async_runner = None if self.__own_eloop: if self.__eloop is not None: if not self.__eloop.is_closed(): self.__eloop.close() self.__eloop = None + self.__own_eloop = False async def __aenter__(self) -> App: return self @@ -344,9 +349,6 @@ class App: # export try: ret = self.eloop.run_until_complete(self.__run(argv)) finally: - if self.__async_runner: - self.__async_runner.close() - self.__async_runner = None self.close() return ret -- 2.55.0 From 845601ff10937c79096623f4db160c828f132ce5 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:59:10 +0200 Subject: [PATCH 04/12] lib.App: Restore previous event loop in run() When run() creates an event loop, it installs it with set_event_loop() but never restores the thread's previous loop, so after run() returns, the thread is left with the now-closed loop created by run(). Any code that calls get_event_loop() afterwards gets a closed loop, and on Python 3.13+ a thread that had no loop at all starts emitting or raising deprecation errors that run() caused. Capture the thread's current loop with _get_current_event_loop() before installing a new one, and restore it in the finally block. If there was no previous loop, unset the loop with set_event_loop(None) so that the thread is left without a loop instead of with the closed one. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 6eb45581..263938c2 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -4,6 +4,7 @@ import asyncio import cProfile import os import sys +import warnings from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace from typing import TYPE_CHECKING, Any, cast, override @@ -31,6 +32,17 @@ if TYPE_CHECKING: from typing import TypeVar T = TypeVar('T') +def _get_current_event_loop() -> asyncio.AbstractEventLoop | None: + """Return the current event loop of this thread, or None if there is + none, without creating one implicitly or emitting a deprecation + warning.""" + with warnings.catch_warnings(): + warnings.simplefilter('error', DeprecationWarning) + try: + return asyncio.get_event_loop() + except (RuntimeError, DeprecationWarning): + return None + class App: # export def _add_arguments(self, parser: ArgumentParser) -> None: @@ -341,7 +353,9 @@ class App: # export return self.__parser def run(self, argv: list[str] | None = None) -> None: + previous_eloop: asyncio.AbstractEventLoop | None = None if self.__eloop is None: + previous_eloop = _get_current_event_loop() eloop = asyncio.new_event_loop() asyncio.set_event_loop(eloop) self.__eloop = eloop @@ -350,6 +364,12 @@ class App: # export ret = self.eloop.run_until_complete(self.__run(argv)) finally: self.close() + # -- Restore the event loop the thread had before run(), or + # unset the loop if there was none. + if previous_eloop is not None: + asyncio.set_event_loop(previous_eloop) + else: + asyncio.set_event_loop(None) return ret -- 2.55.0 From 3fce4b27f8c05963145aef7ff7dd7489cc93660d Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:38:04 +0200 Subject: [PATCH 05/12] lib.App: Rebuild parser from run() argv __init__() builds the parser and the lazy subcommand registration inside it decides which subcommands to register by re-parsing sys.argv. run() then parses a different argv, so if the caller passes an argv that is deeper than the one in sys.argv, the required subparsers have not been registered and the invocation fails with an "unrecognized arguments" error. run_sub_commands() passes argv to run(), so the mismatch is reachable from the public API. Move the parser construction from __init__() into _build_parser() and call it from run() when an argv is given, so that registration and parsing are driven by the same command line. The top-level command instances are created once in __init__() and reused when the parser is rebuilt. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 97 ++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 263938c2..b106b216 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -105,6 +105,47 @@ class App: # export eloop: asyncio.AbstractEventLoop | None = None, ) -> None: + from .Cmd import AbstractCmd + + self.__args: Namespace | None = None + self.__cmdline: str | None = None + self.__description = description + + self.__default_log_flags = self._default_log_flags( + LogFlag.STDERR | LogFlag.POSITION | LogFlag.PRIO | LogFlag.COLOR + ) + if (env := os.getenv(self._default_log_flags_env(), None)) is not None: + self.__default_log_flags = parse_log_flags(env) + + self.__default_log_level = self._default_log_level(NOTICE) + if (env := os.getenv(self._default_log_level_env(), None)) is not None: + self.__default_log_level = parse_log_level(env) + + self.__default_log_file = self._default_log_file(None) + if (env := os.getenv(self._default_log_file_env(), None)) is not None: + self.__default_log_file = env + + self.__back_trace = self._default_show_backtrace(False) + if (env := os.getenv(self._default_show_backtrace_env())) is not None: + self.__back_trace = env.lower() in ['1', 'true'] + + set_log_flags(self.__default_log_flags) + set_log_level(self.__default_log_level) + + self.__async_runner: AsyncRunner | None = None + self.__eloop = eloop + self.__own_eloop = False + + cmd_classes: LoadTypes[AbstractCmd] = LoadTypes( + modules if modules else ['__main__'], + type_name_filter = name_filter, + type_filter = [AbstractCmd], + ) + self.__cmds: list[AbstractCmd] = [cmd_class(self) for cmd_class in cmd_classes] + self._build_parser() + + def _build_parser(self, argv: list[str] | None = None) -> None: + def add_cmd_to_parser(cmd: AbstractCmd, parsers: Any) -> ArgumentParser: parser = cast( 'ArgumentParser', @@ -157,7 +198,7 @@ class App: # export sc.cmd, sc.parser, sc.cmd.children, all = all ) return - args, _ = self.__parser.parse_known_args() + args, _ = self.__parser.parse_known_args(argv) cmd_name = getattr(args, 'command', None) if cmd_name in scs: sc = scs[cmd_name] @@ -165,61 +206,27 @@ class App: # export from .Cmd import AbstractCmd - self.__args: Namespace | None = None - self.__cmdline: str | None = None - - self.__default_log_flags = self._default_log_flags( - LogFlag.STDERR | LogFlag.POSITION | LogFlag.PRIO | LogFlag.COLOR + cmdline = sys.argv if argv is None else argv + if argv is None: + argv = sys.argv[1:] + add_all_parsers = ( + '-h' in argv or '--help' in argv or '_ARGCOMPLETE' in os.environ ) - if (env := os.getenv(self._default_log_flags_env(), None)) is not None: - self.__default_log_flags = parse_log_flags(env) - - self.__default_log_level = self._default_log_level(NOTICE) - if (env := os.getenv(self._default_log_level_env(), None)) is not None: - self.__default_log_level = parse_log_level(env) - - self.__default_log_file = self._default_log_file(None) - if (env := os.getenv(self._default_log_file_env(), None)) is not None: - self.__default_log_file = env - - self.__back_trace = self._default_show_backtrace(False) - if (env := os.getenv(self._default_show_backtrace_env())) is not None: - self.__back_trace = env.lower() in ['1', 'true'] - - set_log_flags(self.__default_log_flags) - set_log_level(self.__default_log_level) - - self.__async_runner: AsyncRunner | None = None - self.__eloop = eloop - self.__own_eloop = False self.__parser = ArgumentParser( formatter_class = ArgumentDefaultsHelpFormatter, - description = description, + description = self.__description, add_help = False, ) self._add_arguments(self.__parser) - args, _ = self.__parser.parse_known_args() + args, _ = self.__parser.parse_known_args(argv) set_log_flags(args.log_flags) set_log_level(args.log_level) - log(DEBUG, f'-------------- Running: >{pretty_cmd(sys.argv)}<') + log(DEBUG, f'-------------- Running: >{pretty_cmd(cmdline)}<') - cmd_classes: LoadTypes[AbstractCmd] = LoadTypes( - modules if modules else ['__main__'], - type_name_filter = name_filter, - type_filter = [AbstractCmd], - ) - add_all_parsers = ( - '-h' in sys.argv or '--help' in sys.argv or '_ARGCOMPLETE' in os.environ - ) - add_cmds_to_parser( - self, - self.__parser, - [cmd_class(self) for cmd_class in cmd_classes], - all = add_all_parsers, - ) + add_cmds_to_parser(self, self.__parser, self.__cmds, all = add_all_parsers) # -- Add help only now, wouldn't want to have parse_known_args() exit # on --help with subcommands missing @@ -361,6 +368,8 @@ class App: # export self.__eloop = eloop self.__own_eloop = True try: + if argv is not None: + self._build_parser(argv) ret = self.eloop.run_until_complete(self.__run(argv)) finally: self.close() -- 2.55.0 From f706477f18538e6faed028dcbec7117ab0e5fc45 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:55:05 +0200 Subject: [PATCH 06/12] lib.App: Use args parameter in _run() _run() receives the parsed arguments as its args parameter, but then checks the private __args attribute for the func attribute and resolves the command function through the args property. Both refer to the same object today, so the mixing is harmless, but it obscures the data flow and would silently diverge if a caller ever passed a namespace other than the stored one. Use the args parameter consistently in _run(). Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index b106b216..276816b2 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -318,11 +318,11 @@ class App: # export # want to do something else, for instance if you don't have sub-commands, # or if want to do anything before and / or after the subcommands. async def _run(self, args: Namespace) -> None | int: - if not hasattr(self.__args, 'func'): + if not hasattr(args, 'func'): self.__parser.print_help() return None # Run sub-command. Overwrite if you want to do anything before or after - return cast('None | int', await self.args.func(args)) + return cast('None | int', await args.func(args)) def call_async(self, awaitable: Awaitable[T], timeout: float | None = None) -> T: return self.async_runner.call(awaitable, timeout) -- 2.55.0 From 0be539a40a7103da07334db9a13ac5c3418944f5 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:53:23 +0200 Subject: [PATCH 07/12] lib.App: Use parser in _add_arguments() _add_arguments() adds the global options to self.__parser instead of to the parser it receives. The two are the same object, because the only caller passes self.__parser, so the change is not observable. Use the parser parameter instead, so that the method honors its argument the way Cmd.add_arguments() does, and so that the global options can be shared with other parsers, e.g. the subcommand parsers, without rewriting this method. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 276816b2..2df96c67 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -46,28 +46,28 @@ def _get_current_event_loop() -> asyncio.AbstractEventLoop | None: class App: # export def _add_arguments(self, parser: ArgumentParser) -> None: - self.__parser.add_argument( + parser.add_argument( '--log-flags', help = 'Log flags', default = self.__default_log_flags, type = parse_log_flags, ) - self.__parser.add_argument( + parser.add_argument( '--log-level', help = 'Log level', default = self.__default_log_level, type = parse_log_level, ) - self.__parser.add_argument( + parser.add_argument( '--log-file', help = 'Log file', default = self.__default_log_file ) - self.__parser.add_argument( + parser.add_argument( '--backtrace', help = 'Show exception backtraces', action = 'store_true', default = self.__back_trace, ) - self.__parser.add_argument( + parser.add_argument( '--write-profile', help = 'Profile code and store output to file', default = None, -- 2.55.0 From 3ac649cdf1843d53639adbc7aca7ce651f5f6ea5 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Fri, 14 Aug 2026 23:41:34 +0200 Subject: [PATCH 08/12] lib.App: Tidy up subcommand registration The subcommand registration in _build_parser() defines a SubCommand helper class inside the add_cmds_to_parser() closure, so a fresh class object is created on every call. It also stores command names and aliases in a dictionary without checking for duplicates, so a colliding name or alias is silently overwritten, and it relies on every subparser level sharing the dest = 'command' attribute to descend one level per re-parse, an invariant that is not documented anywhere. Hoist the helper to a module-level _SubCommand NamedTuple, log a warning when a subcommand name or alias collides with an earlier one at the same level, and document the dest = 'command' invariant next to the re-parse. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 2df96c67..123aad2f 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -7,13 +7,15 @@ import sys import warnings from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, Namespace -from typing import TYPE_CHECKING, Any, cast, override +from typing import TYPE_CHECKING, Any, NamedTuple, cast, override from .AsyncRunner import AsyncRunner +from .Cmd import AbstractCmd from .log import ( DEBUG, ERR, NOTICE, + WARNING, LogFlag, log, log_m, @@ -43,6 +45,11 @@ def _get_current_event_loop() -> asyncio.AbstractEventLoop | None: except (RuntimeError, DeprecationWarning): return None +class _SubCommand(NamedTuple): + + cmd: AbstractCmd + parser: ArgumentParser + class App: # export def _add_arguments(self, parser: ArgumentParser) -> None: @@ -105,8 +112,6 @@ class App: # export eloop: asyncio.AbstractEventLoop | None = None, ) -> None: - from .Cmd import AbstractCmd - self.__args: Namespace | None = None self.__cmdline: str | None = None self.__description = description @@ -171,23 +176,25 @@ class App: # export if not cmds: return - class SubCommand: - - def __init__(self, cmd: AbstractCmd, parser: Any): - self.cmd = cmd - self.parser = parser - title = 'Available subcommands' if isinstance(parent, AbstractCmd): title += ' of ' + parent.name subparsers = parser.add_subparsers( title = title, metavar = '', dest = 'command' ) - scs: dict[str, SubCommand] = {} + scs: dict[str, _SubCommand] = {} for cmd in cmds: cmd.set_parent(parent) - scs[cmd.name] = SubCommand(cmd, add_cmd_to_parser(cmd, subparsers)) + if cmd.name in scs: + log(WARNING, f'Duplicate subcommand name: {cmd.name}') + scs[cmd.name] = _SubCommand(cmd, add_cmd_to_parser(cmd, subparsers)) for alias in cmd.aliases: + if alias != cmd.name and alias in scs: + log( + WARNING, + f'Subcommand alias "{alias}" of "{cmd.name}" ' + 'collides with an earlier subcommand', + ) scs[alias] = scs[cmd.name] if all: seen: set[int] = set() @@ -198,14 +205,16 @@ class App: # export sc.cmd, sc.parser, sc.cmd.children, all = all ) return + # -- Re-parse the command line to find the invoked subcommand. + # This works because every level below uses dest = 'command', + # so each pass descends one level further into the command + # tree. args, _ = self.__parser.parse_known_args(argv) cmd_name = getattr(args, 'command', None) if cmd_name in scs: sc = scs[cmd_name] add_cmds_to_parser(sc.cmd, sc.parser, sc.cmd.children, all = all) - from .Cmd import AbstractCmd - cmdline = sys.argv if argv is None else argv if argv is None: argv = sys.argv[1:] -- 2.55.0 From d8ed0c95d3db65efe2ed304ae45f90210b3ac515 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 00:00:13 +0200 Subject: [PATCH 09/12] lib.App: Warn on invalid exit status __run() only accepts a return value from _run() as the process exit status if it is an int between 0 and 255, and silently drops any other value. A command that returns, for instance, 300 therefore exits with status 0, which presents a failure as a success to the caller without any trace of the mistake. Log an error when the returned exit status is out of range so that the programming error is visible, while still exiting with 0 instead of passing an invalid status to the shell. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 123aad2f..01e3bb3c 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -300,9 +300,17 @@ class App: # export pr.enable() try: - ret = await self._run(self.__args) - if isinstance(ret, int) and ret >= 0 and ret <= 0xFF: - exit_status = ret + result = await self._run(self.__args) + if isinstance(result, int): + if 0 <= result <= 0xFF: + exit_status = result + else: + log( + WARNING, + f'Command returned invalid exit status {result}, ' + 'using 1 instead', + ) + exit_status = 1 except Exception as e: log_m(ERR, f'Failed: {repr(e) if self.__back_trace else str(e)}') exit_status = 1 -- 2.55.0 From 32d46df18ddbc6e308b1598e7e76b0d62f1a25b7 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 00:01:20 +0200 Subject: [PATCH 10/12] lib.App: Log reason for skipped completion The shell completion setup in __run() catches every exception and silently ignores it. If argcomplete is missing or its initialization fails, the completion is simply not available and there is no trace of why, even when logging is turned up to debug level. Log the reason at debug level: one message when the argcomplete import fails and one with the exception for any other failure. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 01e3bb3c..25faf496 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -284,8 +284,10 @@ class App: # export argcomplete.autocomplete(self.__parser, default_completer = NoopCompleter()) - except Exception: - pass + except ImportError: + log(DEBUG, 'argcomplete is not installed, shell completion disabled') + except Exception as e: + log(DEBUG, f'Shell completion disabled: {e}') self.__args = self.__parser.parse_args(args = argv) -- 2.55.0 From d9cc2f30121b59e2cc2fbc25cd8e8e3413332b52 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 00:02:17 +0200 Subject: [PATCH 11/12] lib.App: Pass None default to os.getenv() The os.getenv() calls in __init__() that read the log and backtrace defaults mostly pass None as the explicit default value, but the one for the show-backtrace environment variable relies on the implicit default. Pass None explicitly there as well, so that all the calls read the same way. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/App.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/App.py b/src/python/jw/pkg/lib/App.py index 25faf496..ebd3fd6d 100644 --- a/src/python/jw/pkg/lib/App.py +++ b/src/python/jw/pkg/lib/App.py @@ -131,7 +131,7 @@ class App: # export self.__default_log_file = env self.__back_trace = self._default_show_backtrace(False) - if (env := os.getenv(self._default_show_backtrace_env())) is not None: + if (env := os.getenv(self._default_show_backtrace_env(), None)) is not None: self.__back_trace = env.lower() in ['1', 'true'] set_log_flags(self.__default_log_flags) -- 2.55.0 From 75b6603a3f5029915dd312eadead930e99ff03d9 Mon Sep 17 00:00:00 2001 From: Jan Lindemann Date: Sat, 15 Aug 2026 00:05:23 +0200 Subject: [PATCH 12/12] lib.Cmd: Add single Cmd to add_subcommands() add_subcommands() advertises Cmd and list[Cmd] in its signature, but a single Cmd instance raises NotImplementedError, and since the list branch handles every element through the same method, a list of Cmd instances is broken as well. The only working forms are Types and lists of Types. Handle a single Cmd instance by reparenting it to the caller and appending it to the children, tracking its class like the class-based path does. Instances whose name is already taken by a child are rejected, mirroring the duplicate-class handling, because argparse cannot register two subparsers under the same name. Assisted-by: unsloth/Qwen3.8-27B-GGUF:Q4_K_M with pi.dev v0.84.2 Signed-off-by: Jan Lindemann --- src/python/jw/pkg/lib/Cmd.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/python/jw/pkg/lib/Cmd.py b/src/python/jw/pkg/lib/Cmd.py index 51fea68f..dc7147ad 100644 --- a/src/python/jw/pkg/lib/Cmd.py +++ b/src/python/jw/pkg/lib/Cmd.py @@ -88,7 +88,15 @@ class AbstractCmd(abc.ABC): self, cmds: Cmd | list[Cmd] | Types[Any] | list[Types[Any]] ) -> None: if isinstance(cmds, Cmd): - raise NotImplementedError('Single Cmd should be handled elsewhere') + if any(child.name == cmds.name for child in self.__children): + raise Exception( + f'Can\'t register subcommand with already taken name "{cmds.name}"' + ) + self.__child_classes.append(type(cmds)) + cmds.set_parent(self) + self.__children.append(cmds) + assert len(self.__children) == len(self.__child_classes) + return if isinstance(cmds, list): for cmd in cmds: self.add_subcommands(cmd) -- 2.55.0