mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
cli/interrupt: route SIGINT through a session handler
A SIGINT delivered while the event loop is parked in its selector is raised in the loop's own frame: it escapes run_until_complete, the Runner cancels the main task, and the REPL is left half-shut-down (no prompt, and the next line the human types is eaten by an abandoned prompt reader) until the following Ctrl+C kills the process. Off-loop prompts (ask_user_* / wait / tool confirms run in a worker thread via asyncio.to_thread) and the auto-retry countdown are the usual windows. Keep one asyncio-level SIGINT handler installed for the whole session instead: it cancels the innermost registered target (run_cancellable registers the turn task, sleep_cancellable the retry countdown, off_loop_prompt the waiting prompt) and never raises. Reads that block on the main thread inside a coroutine (the REPL entry prompt, synchronous click prompts) suspend the router and take SIGINT as a KeyboardInterrupt. Off-loop readline prompts cannot be interrupted without abandoning their reader thread on stdin, so they ignore SIGINT with a one-line hint.
This commit is contained in:
+11
-1
@@ -17,7 +17,11 @@ from plyngent.cli.editor import (
|
||||
resolve_config_path,
|
||||
)
|
||||
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
|
||||
from plyngent.cli.interrupt import install_keyboard_interrupt_sigint
|
||||
from plyngent.cli.interrupt import (
|
||||
install_keyboard_interrupt_sigint,
|
||||
install_sigint_router,
|
||||
uninstall_sigint_router,
|
||||
)
|
||||
from plyngent.cli.limits import install_cli_limit_hooks
|
||||
from plyngent.cli.repl import run_repl
|
||||
from plyngent.cli.retry import run_user_text_with_retries
|
||||
@@ -251,6 +255,11 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
yolo: YoloMode | None = "on" if yes else None
|
||||
|
||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||
# A SIGINT must never surface as a KeyboardInterrupt raised in the event
|
||||
# loop's own frame (it would tear this run — and the shutdown below — down
|
||||
# half-finished). The router cancels the innermost registered target instead
|
||||
# and stays installed through cleanup.
|
||||
_ = install_sigint_router()
|
||||
try:
|
||||
from plyngent.cli.provider_recovery import ensure_provider_ready
|
||||
|
||||
@@ -402,6 +411,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
PtyManager.close_all()
|
||||
uninstall_sigint_router()
|
||||
|
||||
|
||||
def _configure_logging(level: str) -> None:
|
||||
|
||||
+166
-52
@@ -4,7 +4,10 @@ import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Generator
|
||||
@@ -12,21 +15,17 @@ if TYPE_CHECKING:
|
||||
|
||||
type SigHandler = Callable[[int, FrameType | None], None] | int | None
|
||||
|
||||
# Depth of nested pause_task_cancel_for_prompt. Must be a plain int (not ContextVar):
|
||||
# asyncio.add_signal_handler freezes the ContextVar snapshot at install time, so a
|
||||
# ContextVar reset after reinstall would leave SIGINT permanently non-cancelling.
|
||||
_prompt_depth: int = 0
|
||||
_reinstall_holder: list[Callable[[], None] | None] = [None]
|
||||
|
||||
class InterruptTarget(Protocol):
|
||||
"""Something a pending SIGINT can cancel (a task, a prompt read, a hint)."""
|
||||
|
||||
def allow_task_cancel() -> bool:
|
||||
"""Whether the CLI SIGINT handler should cancel the in-flight turn task."""
|
||||
return _prompt_depth == 0
|
||||
def cancel(self) -> object:
|
||||
"""Cancel the pending work; the return value is ignored.
|
||||
|
||||
|
||||
def set_sigint_reinstall(callback: Callable[[], None] | None) -> None:
|
||||
"""Register how to re-bind SIGINT after a TTY prompt (set by run_cancellable)."""
|
||||
_reinstall_holder[0] = callback
|
||||
Declared loosely (``object``) so ``asyncio.Task.cancel`` — which returns
|
||||
``bool`` and takes an optional message — satisfies it.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def raise_keyboard_interrupt_sigint(signum: int, frame: FrameType | None) -> None:
|
||||
@@ -56,57 +55,172 @@ def install_keyboard_interrupt_sigint() -> None:
|
||||
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SIGINT routing (session scope)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RouterState:
|
||||
"""Session SIGINT routing state (one instance, kept in ``_router``)."""
|
||||
|
||||
# Cancel targets, innermost last: a SIGINT cancels the innermost one.
|
||||
targets: list[InterruptTarget] = field(default_factory=list)
|
||||
installed: bool = False
|
||||
# Nesting depth of main-thread blocking reads. A plain counter (not a
|
||||
# ContextVar): asyncio.add_signal_handler freezes the ContextVar snapshot at
|
||||
# install time, so a ContextVar reset after reinstall would leave SIGINT
|
||||
# permanently routed to the wrong place.
|
||||
blocking_reads: int = 0
|
||||
|
||||
|
||||
_router = _RouterState()
|
||||
|
||||
|
||||
def sigint_routed() -> bool:
|
||||
"""Whether the session SIGINT router currently owns SIGINT."""
|
||||
return _router.installed
|
||||
|
||||
|
||||
def install_sigint_router() -> bool:
|
||||
"""Install the session SIGINT handler; False when the loop cannot take it.
|
||||
|
||||
A SIGINT delivered while the event loop is parked in its selector is raised
|
||||
in the *loop's* frame: it escapes ``run_until_complete``, the Runner cancels
|
||||
the main task and the REPL (or a half-finished shutdown) is left broken —
|
||||
the next Ctrl+C then kills the process. Keeps an asyncio-level SIGINT
|
||||
handler installed for the whole session instead: it cancels the innermost
|
||||
target registered with :func:`sigint_cancels` and never raises.
|
||||
|
||||
Blocking main-thread reads suspend the router via
|
||||
:func:`pause_task_cancel_for_prompt`. Platforms without asyncio signal
|
||||
handlers (Windows proactor) return False and keep the plain
|
||||
KeyboardInterrupt behaviour. Idempotent.
|
||||
"""
|
||||
if _router.installed:
|
||||
return True
|
||||
if not _add_router_handler():
|
||||
return False
|
||||
_router.installed = True
|
||||
return True
|
||||
|
||||
|
||||
def uninstall_sigint_router() -> None:
|
||||
"""Drop the session SIGINT handler and restore the KeyboardInterrupt one."""
|
||||
if not _router.installed:
|
||||
return
|
||||
_router.installed = False
|
||||
_remove_router_handler()
|
||||
_router.targets.clear()
|
||||
install_keyboard_interrupt_sigint()
|
||||
|
||||
|
||||
def _add_router_handler() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGINT, _route_sigint)
|
||||
except RuntimeError, NotImplementedError, ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _remove_router_handler() -> None:
|
||||
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
||||
_ = asyncio.get_running_loop().remove_signal_handler(signal.SIGINT)
|
||||
|
||||
|
||||
def _route_sigint() -> None:
|
||||
"""Cancel the innermost target (none → ignore: never escape the loop)."""
|
||||
if _router.targets:
|
||||
_ = _router.targets[-1].cancel()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sigint_cancels(target: InterruptTarget) -> Generator[None]:
|
||||
"""Route SIGINT to ``target.cancel()`` while inside (innermost wins)."""
|
||||
_router.targets.append(target)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for index in range(len(_router.targets) - 1, -1, -1):
|
||||
if _router.targets[index] is target:
|
||||
del _router.targets[index]
|
||||
break
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pause_task_cancel_for_prompt() -> Generator[None]:
|
||||
"""Disable turn-task cancel during blocking TTY prompts (confirm, etc.).
|
||||
"""Give SIGINT to a blocking *main-thread* read instead of routing it.
|
||||
|
||||
Must run on the main thread (signal handlers are main-thread only).
|
||||
Restores the default SIGINT handler so ``click.confirm`` can receive
|
||||
KeyboardInterrupt / Abort instead of the asyncio turn being cancelled.
|
||||
The read (REPL entry prompt, synchronous click confirm/prompt) runs on the
|
||||
main thread inside a coroutine, so the resulting KeyboardInterrupt is raised
|
||||
in the reader's own frame: its handlers see it, and neither the in-flight
|
||||
turn nor the event loop is cancelled. Use it only around blocking reads — an
|
||||
``await`` that parks the loop in its selector must keep the router installed
|
||||
(:func:`install_sigint_router`).
|
||||
|
||||
Nested prompts are supported via a depth counter. The asyncio SIGINT
|
||||
handler is reinstalled only after depth returns to 0, and only after
|
||||
cancel is re-enabled, so the handler never freezes ``allow=False``.
|
||||
Suspends the router; nested calls restore it when the outermost exits.
|
||||
"""
|
||||
|
||||
global _prompt_depth # noqa: PLW0603
|
||||
_prompt_depth += 1
|
||||
loop_handler_removed = False
|
||||
_router.blocking_reads += 1
|
||||
resuming = _router.blocking_reads == 1 and _router.installed
|
||||
if resuming:
|
||||
_remove_router_handler()
|
||||
previous: SigHandler = signal.SIG_DFL
|
||||
try:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
loop_handler_removed = True
|
||||
except RuntimeError, NotImplementedError, ValueError:
|
||||
loop_handler_removed = False
|
||||
|
||||
try:
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
_ = signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
except ValueError:
|
||||
# Not on the main thread — skip OS signal rebinding.
|
||||
previous = signal.SIG_DFL
|
||||
previous = signal.getsignal(signal.SIGINT)
|
||||
_ = signal.signal(signal.SIGINT, raise_keyboard_interrupt_sigint)
|
||||
except ValueError:
|
||||
# Not on the main thread — signal handlers cannot be rebound here.
|
||||
previous = signal.SIG_DFL
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with contextlib.suppress(ValueError):
|
||||
_ = signal.signal(signal.SIGINT, previous)
|
||||
_prompt_depth = max(0, _prompt_depth - 1)
|
||||
# Reinstall only when fully out of prompts and cancel is allowed again.
|
||||
# Order matters: decrement depth first so allow_task_cancel() is True
|
||||
# before reinstall (and handlers never freeze allow=False).
|
||||
if _prompt_depth == 0 and loop_handler_removed:
|
||||
reinstall = _reinstall_holder[0]
|
||||
if reinstall is not None:
|
||||
with contextlib.suppress(RuntimeError, NotImplementedError, ValueError):
|
||||
reinstall()
|
||||
_router.blocking_reads = max(0, _router.blocking_reads - 1)
|
||||
if resuming:
|
||||
_ = _add_router_handler()
|
||||
|
||||
|
||||
async def run_in_prompt_thread[**P, R](func: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
"""Run a blocking TTY prompt off the event loop with cancel paused.
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CancelHook:
|
||||
"""SIGINT target for a poll-based prompt read (abort that read)."""
|
||||
|
||||
Pause/SIGINT rebinding happens on the main thread; only the prompt body
|
||||
runs in a worker so the asyncio loop stays free and turn cancel is disabled.
|
||||
cancel_read: Callable[[], None]
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.cancel_read()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _WaitingPrompt:
|
||||
"""SIGINT target for a prompt read that cannot be interrupted.
|
||||
|
||||
A readline read blocks inside the C library on a worker thread; aborting it
|
||||
would abandon that thread on stdin, where it would eat the next line the
|
||||
human types. Explaining itself once is the best available answer.
|
||||
"""
|
||||
with pause_task_cancel_for_prompt():
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
_hinted: bool = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
if self._hinted:
|
||||
return
|
||||
self._hinted = True
|
||||
click.secho(
|
||||
"Ctrl+C ignored while a prompt is waiting — answer it to continue",
|
||||
fg="yellow",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def off_loop_prompt(cancel: Callable[[], None] | None = None) -> Generator[None]:
|
||||
"""A human prompt is waiting in a worker thread.
|
||||
|
||||
While inside, SIGINT must not cancel the enclosing turn: poll-based reads
|
||||
(``cancel`` hook) abort just that read, readline-based ones ignore it with a
|
||||
one-line hint (see :class:`_WaitingPrompt`).
|
||||
"""
|
||||
target: InterruptTarget = _WaitingPrompt() if cancel is None else _CancelHook(cancel)
|
||||
with sigint_cancels(target):
|
||||
yield
|
||||
|
||||
@@ -7,7 +7,7 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
from plyngent.cli.interrupt import off_loop_prompt, pause_task_cancel_for_prompt
|
||||
from plyngent.prompting import (
|
||||
ChoiceOption,
|
||||
NonInteractiveError,
|
||||
@@ -521,10 +521,14 @@ async def prompt_workspace_mismatch_async(
|
||||
|
||||
|
||||
def install_cli_limit_hooks() -> None:
|
||||
"""Register interactive continue hooks and prompt cancel-pause.
|
||||
"""Register interactive continue hooks and prompt guards.
|
||||
|
||||
Command denylist policy confirm is set on :class:`~plyngent.tools.context.InstanceState`
|
||||
when the REPL starts (no process-global hook).
|
||||
Off-loop prompts (worker-thread reads) register their own SIGINT target so a
|
||||
Ctrl+C there never cancels the enclosing turn; synchronous prompts read on
|
||||
the main thread and take SIGINT as a KeyboardInterrupt instead. Command
|
||||
denylist policy confirm is set on
|
||||
:class:`~plyngent.tools.context.InstanceState` when the REPL starts (no
|
||||
process-global hook).
|
||||
"""
|
||||
configure_prompting(pause_factory=pause_task_cancel_for_prompt)
|
||||
configure_prompting(pause_factory=off_loop_prompt)
|
||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
import click
|
||||
|
||||
from plyngent.cli.input_text import read_repl_entry
|
||||
from plyngent.cli.interrupt import pause_task_cancel_for_prompt
|
||||
from plyngent.cli.readline_setup import setup_readline
|
||||
from plyngent.cli.retry import run_user_text_with_retries
|
||||
from plyngent.cli.slash import handle_slash
|
||||
@@ -25,7 +26,7 @@ def _echo_user(text: str) -> None:
|
||||
|
||||
def _echo_interrupted() -> None:
|
||||
# A further Ctrl+C during this output must stay benign (same rule as
|
||||
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
|
||||
# retry._echo_cancel_lines): on platforms without asyncio signal handlers
|
||||
# the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
click.echo()
|
||||
@@ -51,16 +52,22 @@ async def run_repl(state: ReplState) -> None:
|
||||
click.echo('Type /help for commands. Multiline: """ … """. Empty line is ignored.')
|
||||
|
||||
while True:
|
||||
# The prompt read blocks the main thread inside this coroutine, so hand
|
||||
# SIGINT to it: Ctrl+C raises KeyboardInterrupt here (caught below and
|
||||
# inside read_repl_entry) and re-prompts. Everywhere else the session
|
||||
# SIGINT router owns the signal (turn cancel, prompt cancel).
|
||||
try:
|
||||
entry = read_repl_entry()
|
||||
with pause_task_cancel_for_prompt():
|
||||
entry = read_repl_entry()
|
||||
except EOFError:
|
||||
click.echo()
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
# Stray interrupt outside the read (e.g. right after the router was
|
||||
# suspended): re-prompt rather than leave the REPL.
|
||||
_echo_interrupted()
|
||||
continue
|
||||
|
||||
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
|
||||
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
|
||||
# executing — echoing user text, expire_yolo_once, gaps between
|
||||
# statements. Swallow it and re-prompt instead of exiting the REPL.
|
||||
try:
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
+27
-49
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -10,7 +9,7 @@ import click
|
||||
|
||||
from plyngent.agent import AssistantMessageEvent
|
||||
from plyngent.cli.display import render_events
|
||||
from plyngent.cli.interrupt import allow_task_cancel, set_sigint_reinstall
|
||||
from plyngent.cli.interrupt import install_sigint_router, sigint_cancels
|
||||
from plyngent.cli.limits import reset_auto_continue_turn
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -47,7 +46,9 @@ async def sleep_cancellable(seconds: float) -> bool:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return True
|
||||
await asyncio.sleep(min(0.5, remaining))
|
||||
sleeper = asyncio.ensure_future(asyncio.sleep(min(0.5, remaining)))
|
||||
with sigint_cancels(sleeper):
|
||||
await sleeper
|
||||
except asyncio.CancelledError:
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
@@ -57,56 +58,32 @@ async def sleep_cancellable(seconds: float) -> bool:
|
||||
async def run_cancellable[T](coro: Coroutine[object, object, T]) -> T:
|
||||
"""Await ``coro`` as a task; Ctrl+C / SIGINT cancels the task.
|
||||
|
||||
Interactive prompts (max-rounds / destructive confirm) temporarily disable
|
||||
task cancel so the user can answer instead of aborting the whole turn.
|
||||
Interactive prompts (max-rounds / destructive confirm) register their own
|
||||
SIGINT target for as long as they wait, so the turn is not cancelled while
|
||||
the human is answering (see :func:`~plyngent.cli.interrupt.off_loop_prompt`).
|
||||
|
||||
Raises:
|
||||
asyncio.CancelledError: If the task was cancelled (including via SIGINT).
|
||||
"""
|
||||
task: asyncio.Task[T] = asyncio.create_task(coro)
|
||||
loop = asyncio.get_running_loop()
|
||||
installed = False
|
||||
# What to restore once the turn-cancel handler is removed. remove_signal_handler
|
||||
# leaves SIG_DFL, which would terminate the process on a stray Ctrl+C between
|
||||
# turns; restore the handler from before (the CLI installs a
|
||||
# KeyboardInterrupt-raising one so the REPL can catch it).
|
||||
previous_sigint = signal.getsignal(signal.SIGINT)
|
||||
|
||||
def _on_sigint() -> None:
|
||||
# allow_task_cancel() uses a process-level depth counter (not ContextVar)
|
||||
# so this remains correct even if the handler was installed under a
|
||||
# frozen context (asyncio signal handles capture contextvars).
|
||||
if allow_task_cancel() and not task.done():
|
||||
_ = task.cancel()
|
||||
|
||||
def _reinstall() -> None:
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
return
|
||||
|
||||
# Keep a loop-level SIGINT handler in place for the rest of the session, so
|
||||
# a Ctrl+C that lands while the loop is parked in its selector cancels this
|
||||
# task instead of being raised in the loop's own frame.
|
||||
_ = install_sigint_router()
|
||||
try:
|
||||
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
||||
installed = True
|
||||
set_sigint_reinstall(_reinstall)
|
||||
except NotImplementedError, RuntimeError, ValueError:
|
||||
installed = False
|
||||
|
||||
try:
|
||||
return await task
|
||||
except KeyboardInterrupt:
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
raise asyncio.CancelledError from None
|
||||
with sigint_cancels(task):
|
||||
try:
|
||||
return await task
|
||||
except KeyboardInterrupt:
|
||||
# Platforms without asyncio signal handlers (Windows proactor)
|
||||
# keep the CLI's KeyboardInterrupt handler, so the interrupt
|
||||
# surfaces here while the task is still awaiting.
|
||||
if not task.done():
|
||||
_ = task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
raise asyncio.CancelledError from None
|
||||
finally:
|
||||
set_sigint_reinstall(None)
|
||||
if installed:
|
||||
with contextlib.suppress(NotImplementedError, RuntimeError, ValueError):
|
||||
_ = loop.remove_signal_handler(signal.SIGINT)
|
||||
with contextlib.suppress(ValueError):
|
||||
_ = signal.signal(signal.SIGINT, previous_sigint)
|
||||
# Only cancel if still running (e.g. KeyboardInterrupt path above).
|
||||
# Do not cancel a finished task — that would mask success.
|
||||
if not task.done():
|
||||
@@ -143,9 +120,10 @@ def _echo_turn_usage(agent: ChatAgent) -> None:
|
||||
def _echo_cancel_lines(*lines: str) -> None:
|
||||
"""Print post-cancel lines, treating a further Ctrl+C as benign.
|
||||
|
||||
After a turn-cancel the asyncio SIGINT handler is removed, so a second
|
||||
Ctrl+C during this output would otherwise raise KeyboardInterrupt and
|
||||
exit the REPL instead of returning to the prompt.
|
||||
The session SIGINT router keeps the loop safe between turns, but platforms
|
||||
without asyncio signal handlers (Windows proactor) deliver a second Ctrl+C
|
||||
here as a KeyboardInterrupt, which must not exit the REPL instead of
|
||||
returning to the prompt.
|
||||
"""
|
||||
try:
|
||||
for line in lines:
|
||||
|
||||
Reference in New Issue
Block a user