docker-stack 2.2.5__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docker_stack-2.2.5 → docker_stack-2.3.0}/PKG-INFO +13 -1
- {docker_stack-2.2.5 → docker_stack-2.3.0}/README.md +12 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/cli.py +74 -6
- docker_stack-2.3.0/docker_stack/conflict_prompt.py +134 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/manager_api.py +384 -41
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/PKG-INFO +13 -1
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/SOURCES.txt +2 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/setup.py +1 -1
- docker_stack-2.3.0/tests/test_conflict_prompt.py +218 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_docker_stack.py +103 -1
- docker_stack-2.3.0/tests/test_manager_api.py +965 -0
- docker_stack-2.2.5/tests/test_manager_api.py +0 -467
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/__init__.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/command_runner.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/compose.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/docker_objects.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/envsubst.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/envsubst_merge.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/helpers.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/login.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/markers.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/merge_conf.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/registry.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/shell_auth.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack/url_parser.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/dependency_links.txt +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/entry_points.txt +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/requires.txt +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/docker_stack.egg-info/top_level.txt +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/pyproject.toml +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/setup.cfg +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_docker_objects.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_load_env.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_login.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_node_ls.py +0 -0
- {docker_stack-2.2.5 → docker_stack-2.3.0}/tests/test_shell_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docker-stack
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: CLI for deploying and managing Docker stacks.
|
|
5
5
|
Home-page: https://github.com/mesudip/docker-stack
|
|
6
6
|
Author: Sudip Bhattarai
|
|
@@ -213,6 +213,18 @@ are sent unchanged to the Docker CLI.
|
|
|
213
213
|
- **Docker Stack Versioning and Config Backup for Rollback:**
|
|
214
214
|
The utility automatically versions your Docker configs and secrets, allowing for easy tracking of changes and seamless rollbacks to previous states. This provides a safety net for your deployments, ensuring you can always revert to a stable configuration.
|
|
215
215
|
|
|
216
|
+
- **Waiting for another deploy of the same stack (Docker-Manager):**
|
|
217
|
+
Docker-Manager runs one apply per stack at a time. When `docker-stack deploy` or `docker-stack checkout` finds another run in progress (a UI deploy, a CI image bump, another operator), it waits and prints who started it:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
[manager] waiting: a deploy started by alice 42s ago is still running (waiting up to 300s, set DOCKER_MANAGER_DEPLOY_WAIT_SECS to change)
|
|
221
|
+
[manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- It retries every 5 seconds until the stack is free or `DOCKER_MANAGER_DEPLOY_WAIT_SECS` runs out (default: the deploy timeout; `0` fails immediately). CI runs wait the same way, without the prompt.
|
|
225
|
+
- At a terminal, pressing Enter twice within 3 seconds asks the manager to abort the running deployment and deploys as soon as the stack is released. This needs stack deploy permission and is offered once per run. The aborted run stops orchestrating, but daemon requests it already made still complete; your deploy then applies over that state.
|
|
226
|
+
- Ctrl+C exits without touching the other run.
|
|
227
|
+
|
|
216
228
|
## Why Use It?
|
|
217
229
|
|
|
218
230
|
Vanilla Docker Stack deployments can sometimes lack the flexibility needed for dynamic environments or robust secret management. This utility bridges those gaps by:
|
|
@@ -187,6 +187,18 @@ are sent unchanged to the Docker CLI.
|
|
|
187
187
|
- **Docker Stack Versioning and Config Backup for Rollback:**
|
|
188
188
|
The utility automatically versions your Docker configs and secrets, allowing for easy tracking of changes and seamless rollbacks to previous states. This provides a safety net for your deployments, ensuring you can always revert to a stable configuration.
|
|
189
189
|
|
|
190
|
+
- **Waiting for another deploy of the same stack (Docker-Manager):**
|
|
191
|
+
Docker-Manager runs one apply per stack at a time. When `docker-stack deploy` or `docker-stack checkout` finds another run in progress (a UI deploy, a CI image bump, another operator), it waits and prints who started it:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
[manager] waiting: a deploy started by alice 42s ago is still running (waiting up to 300s, set DOCKER_MANAGER_DEPLOY_WAIT_SECS to change)
|
|
195
|
+
[manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- It retries every 5 seconds until the stack is free or `DOCKER_MANAGER_DEPLOY_WAIT_SECS` runs out (default: the deploy timeout; `0` fails immediately). CI runs wait the same way, without the prompt.
|
|
199
|
+
- At a terminal, pressing Enter twice within 3 seconds asks the manager to abort the running deployment and deploys as soon as the stack is released. This needs stack deploy permission and is offered once per run. The aborted run stops orchestrating, but daemon requests it already made still complete; your deploy then applies over that state.
|
|
200
|
+
- Ctrl+C exits without touching the other run.
|
|
201
|
+
|
|
190
202
|
## Why Use It?
|
|
191
203
|
|
|
192
204
|
Vanilla Docker Stack deployments can sometimes lack the flexibility needed for dynamic environments or robust secret management. This utility bridges those gaps by:
|
|
@@ -36,11 +36,16 @@ from docker_stack.shell_auth import (
|
|
|
36
36
|
run_managed_shell,
|
|
37
37
|
shell_session_active,
|
|
38
38
|
)
|
|
39
|
+
from docker_stack.conflict_prompt import ForceDeployPrompt
|
|
39
40
|
from docker_stack.manager_api import (
|
|
41
|
+
DEPLOY_WAIT_ENV,
|
|
40
42
|
FEATURE_STACK_DEPLOY,
|
|
41
43
|
FEATURE_STACK_QUERY,
|
|
42
44
|
ManagerApiClient,
|
|
45
|
+
describe_active_deployment,
|
|
46
|
+
describe_run,
|
|
43
47
|
discover_manager_client,
|
|
48
|
+
format_deploy_error_body,
|
|
44
49
|
)
|
|
45
50
|
from docker_stack.registry import DockerRegistry
|
|
46
51
|
from .envsubst import LineCheckResult, SubstitutionError, envsubst, envsubst_load_file
|
|
@@ -1500,6 +1505,7 @@ class DockerStack:
|
|
|
1500
1505
|
options=options,
|
|
1501
1506
|
)
|
|
1502
1507
|
else:
|
|
1508
|
+
prompt = ForceDeployPrompt.create()
|
|
1503
1509
|
try:
|
|
1504
1510
|
payload = manager_client.deploy_stack_stream(
|
|
1505
1511
|
stack=stack_name,
|
|
@@ -1507,6 +1513,7 @@ class DockerStack:
|
|
|
1507
1513
|
compose=rendered_content,
|
|
1508
1514
|
options=options,
|
|
1509
1515
|
on_event=DockerStack._print_manager_deploy_event,
|
|
1516
|
+
wait_for_poll=prompt.wait if prompt else None,
|
|
1510
1517
|
)
|
|
1511
1518
|
except RuntimeError as exc:
|
|
1512
1519
|
message = str(exc)
|
|
@@ -1571,6 +1578,51 @@ class DockerStack:
|
|
|
1571
1578
|
message = data.get("message") or data.get("error") or ""
|
|
1572
1579
|
if message:
|
|
1573
1580
|
print(f"[manager] {service}: {status}: {message}", flush=True)
|
|
1581
|
+
elif event_name == "deployment":
|
|
1582
|
+
deployment_id = data.get("deployment_id")
|
|
1583
|
+
if deployment_id:
|
|
1584
|
+
mode = data.get("mode") or "deployment"
|
|
1585
|
+
print(f"[manager] {mode} {deployment_id}", flush=True)
|
|
1586
|
+
elif event_name == "waiting":
|
|
1587
|
+
DockerStack._print_manager_wait(data)
|
|
1588
|
+
|
|
1589
|
+
@staticmethod
|
|
1590
|
+
def _print_manager_wait(active: Dict[str, object]) -> None:
|
|
1591
|
+
"""Narrate the wait for another run of this stack, and any attempt to force past it.
|
|
1592
|
+
|
|
1593
|
+
The manager will not queue an interactive deploy for us, so the CLI polls
|
|
1594
|
+
and reports each phase: waiting, forcing (asking the manager to abort the
|
|
1595
|
+
holder), and how that abort ended.
|
|
1596
|
+
"""
|
|
1597
|
+
if not isinstance(active, dict):
|
|
1598
|
+
active = {}
|
|
1599
|
+
phase = str(active.get("phase") or "waiting")
|
|
1600
|
+
run = describe_run(active)
|
|
1601
|
+
if phase == "waiting":
|
|
1602
|
+
budget = active.get("wait_budget_secs")
|
|
1603
|
+
suffix = f" (waiting up to {budget}s, set {DEPLOY_WAIT_ENV} to change)" if isinstance(budget, int) else ""
|
|
1604
|
+
message = f"waiting: {describe_active_deployment(active)}{suffix}"
|
|
1605
|
+
elif phase == "forcing":
|
|
1606
|
+
message = f"forcing: asking the manager to abort {run}"
|
|
1607
|
+
elif phase == "aborted":
|
|
1608
|
+
message = f"aborted {run}; deploying now"
|
|
1609
|
+
elif phase == "aborted_other":
|
|
1610
|
+
message = f"aborted a different run than shown: {run}; deploying now"
|
|
1611
|
+
elif phase == "free":
|
|
1612
|
+
message = "the stack is free; deploying now"
|
|
1613
|
+
elif phase == "not_released":
|
|
1614
|
+
message = f"asked the manager to abort {run}, but it has not released the stack yet; waiting for it to finish"
|
|
1615
|
+
elif phase == "forbidden":
|
|
1616
|
+
message = f"you do not have permission to abort {run}; still waiting"
|
|
1617
|
+
elif phase == "still_held":
|
|
1618
|
+
message = f"the stack is still held by {run}; waiting"
|
|
1619
|
+
elif phase == "force_used":
|
|
1620
|
+
message = "force was already used once in this run; waiting for the stack to free up"
|
|
1621
|
+
elif phase == "interrupted":
|
|
1622
|
+
message = "interrupted while asking the manager to abort; that request may still complete"
|
|
1623
|
+
else:
|
|
1624
|
+
message = f"{phase}: {run}"
|
|
1625
|
+
print(f"[manager] {message}", file=sys.stderr, flush=True)
|
|
1574
1626
|
|
|
1575
1627
|
@staticmethod
|
|
1576
1628
|
def _rollback_via_manager(
|
|
@@ -1580,10 +1632,13 @@ class DockerStack:
|
|
|
1580
1632
|
namespace: str,
|
|
1581
1633
|
version: str,
|
|
1582
1634
|
) -> Optional[str]:
|
|
1635
|
+
prompt = ForceDeployPrompt.create()
|
|
1583
1636
|
payload = manager_client.rollback_stack(
|
|
1584
1637
|
stack=stack_name,
|
|
1585
1638
|
namespace=namespace,
|
|
1586
1639
|
version=version,
|
|
1640
|
+
on_wait=DockerStack._print_manager_wait,
|
|
1641
|
+
wait_for_poll=prompt.wait if prompt else None,
|
|
1587
1642
|
)
|
|
1588
1643
|
warnings = payload.get("warnings") or []
|
|
1589
1644
|
for warning in warnings:
|
|
@@ -1868,7 +1923,7 @@ def _humanize_manager_error(message: str) -> str:
|
|
|
1868
1923
|
return message
|
|
1869
1924
|
if not isinstance(body, dict):
|
|
1870
1925
|
return message
|
|
1871
|
-
detail = str(body.get("message", "")).strip()
|
|
1926
|
+
detail = format_deploy_error_body(body) if body.get("code") else str(body.get("message", "")).strip()
|
|
1872
1927
|
if not detail:
|
|
1873
1928
|
return message
|
|
1874
1929
|
incident = str(body.get("incident_id") or body.get("trace_id") or "").strip()
|
|
@@ -1896,6 +1951,17 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
|
|
|
1896
1951
|
"by this stack. Either allow that external network in the Docker-Manager deployment rule, attach the service to "
|
|
1897
1952
|
"a stack-owned network, or relabel/recreate the existing network with the expected stack ownership before deploying."
|
|
1898
1953
|
)
|
|
1954
|
+
if "is still running" in lowered and ("gave up after waiting" in lowered or "http 409" in lowered):
|
|
1955
|
+
return (
|
|
1956
|
+
"Suggestion: another run holds this stack's deploy lock. Rerun and let it wait, raise "
|
|
1957
|
+
f"{DEPLOY_WAIT_ENV} to queue longer, press Enter twice while waiting at a terminal to abort that run "
|
|
1958
|
+
"and force your deploy, or abort it from the Docker-Manager stack page."
|
|
1959
|
+
)
|
|
1960
|
+
if "failed services:" in lowered:
|
|
1961
|
+
return (
|
|
1962
|
+
"Suggestion: every service operation has finished; the failed ones are listed above with their incident ids. "
|
|
1963
|
+
"Fix the cause and redeploy, or use the rollback offered on the Docker-Manager stack page to restore the previous specs."
|
|
1964
|
+
)
|
|
1899
1965
|
if "stack not found" in lowered:
|
|
1900
1966
|
return (
|
|
1901
1967
|
"Suggestion: the manager was reached but has no stack by that name in that namespace. "
|
|
@@ -1918,9 +1984,10 @@ def _manager_deploy_suggestion(message: str) -> Optional[str]:
|
|
|
1918
1984
|
def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
|
|
1919
1985
|
output = _error_output(exc)
|
|
1920
1986
|
lines = [f"docker-stack: command failed with exit code {exc.returncode}: {_format_command(exc.cmd)}"]
|
|
1921
|
-
if output
|
|
1922
|
-
|
|
1923
|
-
|
|
1987
|
+
humanized = _humanize_manager_error(output) if output else ""
|
|
1988
|
+
if humanized:
|
|
1989
|
+
lines.append(humanized)
|
|
1990
|
+
suggestion = _manager_deploy_suggestion(humanized or output)
|
|
1924
1991
|
if suggestion:
|
|
1925
1992
|
lines.extend(["", suggestion])
|
|
1926
1993
|
return "\n".join(lines)
|
|
@@ -1928,8 +1995,9 @@ def _format_called_process_error(exc: subprocess.CalledProcessError) -> str:
|
|
|
1928
1995
|
|
|
1929
1996
|
def _format_runtime_error(exc: RuntimeError) -> str:
|
|
1930
1997
|
message = str(exc).strip() or exc.__class__.__name__
|
|
1931
|
-
|
|
1932
|
-
|
|
1998
|
+
humanized = _humanize_manager_error(message)
|
|
1999
|
+
lines = [f"docker-stack: {humanized}"]
|
|
2000
|
+
suggestion = _manager_deploy_suggestion(humanized)
|
|
1933
2001
|
if suggestion:
|
|
1934
2002
|
lines.extend(["", suggestion])
|
|
1935
2003
|
return "\n".join(lines)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Terminal prompt shown while a deploy waits for another run of the same stack.
|
|
2
|
+
|
|
3
|
+
Docker-Manager refuses to queue interactive applies: it answers ``409
|
|
4
|
+
deployment_in_progress`` and leaves the choice between waiting and aborting to
|
|
5
|
+
whoever is on the other end. On a CLI that is the person at the terminal, so
|
|
6
|
+
while the client polls for the stack to free up this prompt listens for two
|
|
7
|
+
Enter presses in quick succession and reports ``"force"``; the caller then asks
|
|
8
|
+
the manager to abort the running deployment. Ctrl+C is left alone and keeps
|
|
9
|
+
exiting the CLI.
|
|
10
|
+
|
|
11
|
+
Enter is line-oriented, so no terminal mode is changed and there is nothing to
|
|
12
|
+
restore on the way out. The prompt is only created when both stdin and stderr
|
|
13
|
+
are terminals and the process is in the foreground; anywhere else (CI, pipes,
|
|
14
|
+
the GitHub Action's heredoc) the caller simply sleeps between polls.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import os
|
|
19
|
+
import select
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
DOUBLE_ENTER_SECS = 3.0
|
|
25
|
+
WINDOWS_POLL_SECS = 0.1
|
|
26
|
+
HINT = "[manager] press Enter twice to force your deploy (aborts that run; changes it already made to the daemon stay), Ctrl+C to quit"
|
|
27
|
+
SECOND_ENTER_HINT = f"[manager] press Enter again within {int(DOUBLE_ENTER_SECS)}s to force the deploy"
|
|
28
|
+
|
|
29
|
+
ENTER = "enter"
|
|
30
|
+
OTHER = "other"
|
|
31
|
+
TIMEOUT = "timeout"
|
|
32
|
+
EOF = "eof"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _PosixEnterReader:
|
|
36
|
+
def __init__(self, stdin, fd: int):
|
|
37
|
+
self._stdin = stdin
|
|
38
|
+
self._fd = fd
|
|
39
|
+
|
|
40
|
+
def wait_for_enter(self, timeout: float) -> str:
|
|
41
|
+
try:
|
|
42
|
+
readable, _, _ = select.select([self._fd], [], [], max(0.0, timeout))
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return EOF
|
|
45
|
+
if not readable:
|
|
46
|
+
return TIMEOUT
|
|
47
|
+
line = self._stdin.readline()
|
|
48
|
+
if line == "":
|
|
49
|
+
return EOF
|
|
50
|
+
return ENTER if line.strip() == "" else OTHER
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _WindowsEnterReader:
|
|
54
|
+
def __init__(self, msvcrt_module):
|
|
55
|
+
self._msvcrt = msvcrt_module
|
|
56
|
+
|
|
57
|
+
def wait_for_enter(self, timeout: float) -> str:
|
|
58
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
59
|
+
while True:
|
|
60
|
+
if self._msvcrt.kbhit():
|
|
61
|
+
char = self._msvcrt.getwch()
|
|
62
|
+
return ENTER if char in ("\r", "\n") else OTHER
|
|
63
|
+
remaining = deadline - time.monotonic()
|
|
64
|
+
if remaining <= 0:
|
|
65
|
+
return TIMEOUT
|
|
66
|
+
time.sleep(min(WINDOWS_POLL_SECS, remaining))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ForceDeployPrompt:
|
|
70
|
+
"""Listens for a double Enter between polls; see the module docstring."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, stderr, reader):
|
|
73
|
+
self._stderr = stderr
|
|
74
|
+
self._reader = reader
|
|
75
|
+
self._hinted = False
|
|
76
|
+
self._dead = False
|
|
77
|
+
self._first_enter_at: Optional[float] = None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def create(cls) -> Optional["ForceDeployPrompt"]:
|
|
81
|
+
"""Build a prompt when a person can see and answer it, else ``None``."""
|
|
82
|
+
stdin, stderr = sys.stdin, sys.stderr
|
|
83
|
+
if stdin is None or stderr is None:
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
if not stdin.isatty() or not stderr.isatty():
|
|
87
|
+
return None
|
|
88
|
+
fd = stdin.fileno()
|
|
89
|
+
except (AttributeError, ValueError, OSError, io.UnsupportedOperation):
|
|
90
|
+
return None
|
|
91
|
+
if os.name == "nt":
|
|
92
|
+
try:
|
|
93
|
+
import msvcrt # type: ignore[import-not-found]
|
|
94
|
+
except ImportError:
|
|
95
|
+
return None
|
|
96
|
+
return cls(stderr, _WindowsEnterReader(msvcrt))
|
|
97
|
+
try:
|
|
98
|
+
# Reading a terminal from a background job stops the process (SIGTTIN).
|
|
99
|
+
if os.tcgetpgrp(fd) != os.getpgrp():
|
|
100
|
+
return None
|
|
101
|
+
except (AttributeError, OSError):
|
|
102
|
+
return None
|
|
103
|
+
return cls(stderr, _PosixEnterReader(stdin, fd))
|
|
104
|
+
|
|
105
|
+
def _say(self, message: str) -> None:
|
|
106
|
+
print(message, file=self._stderr, flush=True)
|
|
107
|
+
|
|
108
|
+
def wait(self, active: Any, seconds: float) -> Optional[str]:
|
|
109
|
+
"""Wait up to ``seconds`` for the next poll; ``"force"`` if Enter was pressed twice."""
|
|
110
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
111
|
+
if not self._hinted:
|
|
112
|
+
self._say(HINT)
|
|
113
|
+
self._hinted = True
|
|
114
|
+
while True:
|
|
115
|
+
remaining = deadline - time.monotonic()
|
|
116
|
+
if remaining <= 0:
|
|
117
|
+
return None
|
|
118
|
+
if self._dead:
|
|
119
|
+
time.sleep(remaining)
|
|
120
|
+
return None
|
|
121
|
+
result = self._reader.wait_for_enter(remaining)
|
|
122
|
+
if result == TIMEOUT:
|
|
123
|
+
return None
|
|
124
|
+
if result == EOF:
|
|
125
|
+
self._dead = True
|
|
126
|
+
continue
|
|
127
|
+
if result != ENTER:
|
|
128
|
+
continue
|
|
129
|
+
now = time.monotonic()
|
|
130
|
+
if self._first_enter_at is not None and now - self._first_enter_at <= DOUBLE_ENTER_SECS:
|
|
131
|
+
self._first_enter_at = None
|
|
132
|
+
return "force"
|
|
133
|
+
self._first_enter_at = now
|
|
134
|
+
self._say(SECOND_ENTER_HINT)
|