Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 32306c1

Browse files
authored
Merge pull request #458 from jumpstarter-dev/backport-448-to-release-0.6
[Backport release-0.6] Handle Ctrl-C in j command
2 parents bc2d451 + 3eb497c commit 32306c1

4 files changed

Lines changed: 122 additions & 14 deletions

File tree

packages/jumpstarter-cli-common/jumpstarter_cli_common/exceptions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import types
12
from functools import wraps
3+
from types import TracebackType
24

35
import asyncclick as click
46

@@ -42,3 +44,62 @@ def wrapped(*args, **kwargs):
4244
raise
4345

4446
return wrapped
47+
48+
49+
# https://peps.python.org/pep-0785/#reference-implementation
50+
def leaf_exceptions(self: BaseExceptionGroup, *, fix_tracebacks: bool = True) -> list[BaseException]:
51+
"""
52+
Return a flat list of all 'leaf' exceptions.
53+
54+
If fix_tracebacks is True, each leaf will have the traceback replaced
55+
with a composite so that frames attached to intermediate groups are
56+
still visible when debugging. Pass fix_tracebacks=False to disable
57+
this modification, e.g. if you expect to raise the group unchanged.
58+
"""
59+
60+
def _flatten(group: BaseExceptionGroup, parent_tb: TracebackType | None = None):
61+
group_tb = group.__traceback__
62+
combined_tb = _combine_tracebacks(parent_tb, group_tb)
63+
result = []
64+
for exc in group.exceptions:
65+
if isinstance(exc, BaseExceptionGroup):
66+
result.extend(_flatten(exc, combined_tb))
67+
elif fix_tracebacks:
68+
tb = _combine_tracebacks(combined_tb, exc.__traceback__)
69+
result.append(exc.with_traceback(tb))
70+
else:
71+
result.append(exc)
72+
return result
73+
74+
return _flatten(self)
75+
76+
77+
def _combine_tracebacks(
78+
tb1: TracebackType | None,
79+
tb2: TracebackType | None,
80+
) -> TracebackType | None:
81+
"""
82+
Combine two tracebacks, putting tb1 frames before tb2 frames.
83+
84+
If either is None, return the other.
85+
"""
86+
if tb1 is None:
87+
return tb2
88+
if tb2 is None:
89+
return tb1
90+
91+
# Convert tb1 to a list of frames
92+
frames = []
93+
current = tb1
94+
while current is not None:
95+
frames.append((current.tb_frame, current.tb_lasti, current.tb_lineno))
96+
current = current.tb_next
97+
98+
# Create a new traceback starting with tb2
99+
new_tb = tb2
100+
101+
# Add frames from tb1 to the beginning (in reverse order)
102+
for frame, lasti, lineno in reversed(frames):
103+
new_tb = types.TracebackType(tb_next=new_tb, tb_frame=frame, tb_lasti=lasti, tb_lineno=lineno)
104+
105+
return new_tb
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import signal
2+
3+
import asyncclick as click
4+
from anyio import open_signal_receiver
5+
from anyio.abc import CancelScope
6+
7+
8+
# Reference: https://github.com/agronholm/anyio/blob/4.9.0/docs/signals.rst
9+
async def signal_handler(scope: CancelScope):
10+
with open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signals:
11+
async for signum in signals:
12+
match signum:
13+
case signal.SIGINT:
14+
click.echo("SIGINT pressed, terminating", err=True)
15+
case signal.SIGTERM:
16+
click.echo("SIGTERM received, terminating", err=True)
17+
case _:
18+
pass
19+
20+
scope.cancel()
21+
22+
break

packages/jumpstarter-cli/jumpstarter_cli/j.py

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,48 @@
1+
import concurrent
12
import sys
3+
from contextlib import ExitStack
24

35
import asyncclick as click
4-
from jumpstarter_cli_common.exceptions import handle_exceptions
6+
from anyio import create_task_group, get_cancelled_exc_class, run, to_thread
7+
from anyio.from_thread import BlockingPortal
8+
from jumpstarter_cli_common.exceptions import async_handle_exceptions, leaf_exceptions
9+
from jumpstarter_cli_common.signal import signal_handler
510

6-
from jumpstarter.utils.env import env
11+
from jumpstarter.utils.env import env_async
12+
13+
14+
async def j_async():
15+
@async_handle_exceptions
16+
async def cli():
17+
async with BlockingPortal() as portal:
18+
with ExitStack() as stack:
19+
async with env_async(portal, stack) as client:
20+
async with client.log_stream_async():
21+
await to_thread.run_sync(lambda: client.cli()(standalone_mode=False))
22+
23+
try:
24+
async with create_task_group() as tg:
25+
tg.start_soon(signal_handler, tg.cancel_scope)
26+
27+
try:
28+
await cli()
29+
finally:
30+
tg.cancel_scope.cancel()
31+
32+
except* click.ClickException as excgroup:
33+
for exc in leaf_exceptions(excgroup):
34+
exc.show()
35+
36+
sys.exit(1)
37+
except* (
38+
get_cancelled_exc_class(),
39+
concurrent.futures._base.CancelledError,
40+
) as _:
41+
sys.exit(2)
742

843

944
def j():
10-
with env() as client:
11-
12-
@handle_exceptions
13-
def cli():
14-
with client.log_stream():
15-
client.cli()(standalone_mode=False)
16-
17-
try:
18-
cli()
19-
except click.ClickException as e:
20-
e.show()
21-
sys.exit(1)
45+
run(j_async)
2246

2347

2448
if __name__ == "__main__":

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ dev = [
5959
]
6060

6161
[tool.ruff]
62+
target-version = "py311"
6263
exclude = ["packages/jumpstarter-protocol"]
6364
line-length = 120
6465

0 commit comments

Comments
 (0)