composable-data-stack 0.4.0__py3-none-any.whl
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.
- cli/__init__.py +1 -0
- cli/diagnostics.py +13 -0
- cli/graph.py +58 -0
- cli/image_updates.py +326 -0
- cli/image_verification.py +484 -0
- cli/loader.py +180 -0
- cli/main.py +1656 -0
- cli/overlay.py +239 -0
- cli/planner.py +618 -0
- cli/preflight.py +418 -0
- cli/renderer.py +791 -0
- cli/resolver.py +28 -0
- cli/resources/__init__.py +1 -0
- cli/resources/rule-schema.json +274 -0
- cli/resources/rule-set.json +919 -0
- cli/secrets.py +169 -0
- cli/security.py +768 -0
- cli/security_common.py +41 -0
- cli/state.py +112 -0
- cli/up_runner.py +257 -0
- cli/validator.py +570 -0
- composable_data_stack-0.4.0.dist-info/METADATA +872 -0
- composable_data_stack-0.4.0.dist-info/RECORD +27 -0
- composable_data_stack-0.4.0.dist-info/WHEEL +5 -0
- composable_data_stack-0.4.0.dist-info/entry_points.txt +2 -0
- composable_data_stack-0.4.0.dist-info/licenses/LICENSE +201 -0
- composable_data_stack-0.4.0.dist-info/top_level.txt +1 -0
cli/security_common.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
8
|
+
|
|
9
|
+
SECRET_KEY_RE = re.compile(r"(?i)(password|secret|token|key|credential|passwd|pwd)")
|
|
10
|
+
|
|
11
|
+
_SECRET_KEY_WORDS = (
|
|
12
|
+
"password",
|
|
13
|
+
"passwd",
|
|
14
|
+
"pass",
|
|
15
|
+
"pwd",
|
|
16
|
+
"secret",
|
|
17
|
+
"token",
|
|
18
|
+
"key",
|
|
19
|
+
"credential",
|
|
20
|
+
"apikey",
|
|
21
|
+
"accesskey",
|
|
22
|
+
"secretkey",
|
|
23
|
+
"passphrase",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SECRET_KEY_SEGMENT_RE = re.compile(
|
|
27
|
+
r"(?i)(?:^|[-_])(?:" + "|".join(_SECRET_KEY_WORDS) + r")(?:$|[^a-z0-9])"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ENVIRONMENT_TO_CLASS = {
|
|
31
|
+
"local": "local",
|
|
32
|
+
"development": "dev",
|
|
33
|
+
"staging": "staging",
|
|
34
|
+
"production": "prod",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def infer_profile_class(profile: dict[str, Any]) -> str:
|
|
39
|
+
"""Map a profile's declared environment to the security policy class."""
|
|
40
|
+
environment = (profile or {}).get("metadata", {}).get("environment", "local")
|
|
41
|
+
return ENVIRONMENT_TO_CLASS.get(environment, "local")
|
cli/state.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Groups `docker compose ps -a --format json` output by health state for
|
|
3
|
+
`cds state`. Provider-neutral: reads only the generic fields Compose
|
|
4
|
+
itself emits (Service/Name, Health, State), no module- or
|
|
5
|
+
service-specific knowledge.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_compose_ps_json(raw_output: str) -> list[dict[str, Any]]:
|
|
14
|
+
"""
|
|
15
|
+
Parses `docker compose ps --format json` output in either shape.
|
|
16
|
+
Returns [] for blank output. Skips lines that aren't valid JSON
|
|
17
|
+
objects rather than failing the whole parse on one bad line.
|
|
18
|
+
"""
|
|
19
|
+
text = raw_output.strip()
|
|
20
|
+
if not text:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
parsed = json.loads(text)
|
|
25
|
+
if isinstance(parsed, list):
|
|
26
|
+
return [entry for entry in parsed if isinstance(entry, dict)]
|
|
27
|
+
if isinstance(parsed, dict):
|
|
28
|
+
return [parsed]
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
services: list[dict[str, Any]] = []
|
|
33
|
+
for line in text.splitlines():
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if not line:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
entry = json.loads(line)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(entry, dict):
|
|
42
|
+
services.append(entry)
|
|
43
|
+
return services
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bucket_for(service: dict[str, Any]) -> str:
|
|
47
|
+
health = str(service.get("Health") or "").strip()
|
|
48
|
+
if health:
|
|
49
|
+
return health.upper()
|
|
50
|
+
|
|
51
|
+
state = str(service.get("State") or "").strip().lower()
|
|
52
|
+
if state == "exited":
|
|
53
|
+
exit_code_raw = service.get("ExitCode")
|
|
54
|
+
try:
|
|
55
|
+
exit_code = int(exit_code_raw)
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return "UNKNOWN"
|
|
58
|
+
return "HEALTHY EXIT" if exit_code == 0 else "UNHEALTHY EXIT"
|
|
59
|
+
if state == "running":
|
|
60
|
+
return "RUNNING"
|
|
61
|
+
|
|
62
|
+
# Paused/Dead/etc. aren't states normal cds workflows produce, no
|
|
63
|
+
# dedicated bucket for them, they fall in with everything else unknown.
|
|
64
|
+
return "UNKNOWN"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def group_services_by_health(services: list[dict[str, Any]]) -> dict[str, list[str]]:
|
|
68
|
+
"""
|
|
69
|
+
Groups parsed compose-ps entries into {bucket_name: [service_name, ...]},
|
|
70
|
+
both deterministically sorted. Bucket is Health when the service has a
|
|
71
|
+
healthcheck ("starting"/"healthy"/"unhealthy"); else "healthy exit"/
|
|
72
|
+
"unhealthy exit" by ExitCode when State is "exited"; else "running";
|
|
73
|
+
else "UNKNOWN" (covers Paused/Dead/etc., which normal cds workflows
|
|
74
|
+
don't produce).
|
|
75
|
+
"""
|
|
76
|
+
buckets: dict[str, set[str]] = {}
|
|
77
|
+
for service in services:
|
|
78
|
+
name = str(service.get("Service") or service.get("Name") or "").strip()
|
|
79
|
+
if not name:
|
|
80
|
+
continue
|
|
81
|
+
bucket = _bucket_for(service)
|
|
82
|
+
buckets.setdefault(bucket, set()).add(name)
|
|
83
|
+
|
|
84
|
+
return {bucket: sorted(names) for bucket, names in sorted(buckets.items())}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_COLORS = {
|
|
88
|
+
"HEALTHY": "\033[32m",
|
|
89
|
+
"RUNNING": "\033[32m",
|
|
90
|
+
"UNHEALTHY": "\033[31m",
|
|
91
|
+
"HEALTHY EXIT": "\033[92m",
|
|
92
|
+
"UNHEALTHY EXIT": "\033[91m",
|
|
93
|
+
"STARTING": "\033[33m",
|
|
94
|
+
"UNKNOWN": "\033[2m",
|
|
95
|
+
}
|
|
96
|
+
_RESET = "\033[0m"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_state_output(grouped: dict[str, list[str]], use_color: bool = False) -> str:
|
|
100
|
+
if not grouped:
|
|
101
|
+
return "No services found."
|
|
102
|
+
|
|
103
|
+
lines = []
|
|
104
|
+
for bucket, names in grouped.items():
|
|
105
|
+
label = f"{bucket}:"
|
|
106
|
+
if use_color:
|
|
107
|
+
color = _COLORS.get(bucket, "")
|
|
108
|
+
label = f"{color}{label}{_RESET}" if color else label
|
|
109
|
+
lines.append(label)
|
|
110
|
+
for name in names:
|
|
111
|
+
lines.append(f" - {name}")
|
|
112
|
+
return "\n".join(lines)
|
cli/up_runner.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# cli/up_runner.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import subprocess # nosec B404
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import IO
|
|
13
|
+
|
|
14
|
+
from .state import format_state_output, group_services_by_health, parse_compose_ps_json
|
|
15
|
+
|
|
16
|
+
_BUILD_SERVICE_RE = re.compile(r"^([\w][\w.-]*?):\s+Building\b")
|
|
17
|
+
|
|
18
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 2.0
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS = 180.0
|
|
20
|
+
|
|
21
|
+
_SETTLED_BUCKETS = {"HEALTHY", "RUNNING", "HEALTHY EXIT", "UNHEALTHY EXIT", "UNHEALTHY"}
|
|
22
|
+
_FAILURE_BUCKETS = {"UNHEALTHY", "UNHEALTHY EXIT"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_log_path(profile_name: str, logs_dir: Path | None = None) -> Path:
|
|
26
|
+
"""
|
|
27
|
+
Default log path for a `cds up` run: `.cds/logs/up-<profile>-<UTC
|
|
28
|
+
timestamp>.log`, relative to the current working directory. Slashes
|
|
29
|
+
and spaces in `profile_name` (profiles can be passed as paths) are
|
|
30
|
+
flattened so the result is always a single valid filename.
|
|
31
|
+
|
|
32
|
+
`logs_dir` is injectable so tests don't have to write into a real
|
|
33
|
+
`.cds/logs` under the repo checkout.
|
|
34
|
+
"""
|
|
35
|
+
base = logs_dir if logs_dir is not None else Path(".cds") / "logs"
|
|
36
|
+
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
37
|
+
safe_profile = profile_name.strip().replace("/", "-").replace("\\", "-").replace(" ", "-") or "profile"
|
|
38
|
+
return base / f"up-{safe_profile}-{timestamp}.log"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_streamed(
|
|
42
|
+
cmd: list[str],
|
|
43
|
+
log_file: IO[str],
|
|
44
|
+
echo: bool = True,
|
|
45
|
+
group_by_image: bool = False,
|
|
46
|
+
service_to_image: dict[str, str] | None = None,
|
|
47
|
+
use_color: bool = True,
|
|
48
|
+
) -> int:
|
|
49
|
+
"""
|
|
50
|
+
Runs `cmd` with stdout+stderr merged, writing each line to
|
|
51
|
+
`log_file` as it arrives (flushed immediately, so `tail -f` on the
|
|
52
|
+
log file works while the command is still running) and, if `echo`,
|
|
53
|
+
to this process's stdout too.
|
|
54
|
+
|
|
55
|
+
When `group_by_image` is True and `echo` is True, the output is
|
|
56
|
+
annotated with section headers that identify which Docker Compose
|
|
57
|
+
service each build phase belongs to. The log file always receives
|
|
58
|
+
the raw, un-annotated output.
|
|
59
|
+
|
|
60
|
+
Section headers are colored only when `use_color` is True and
|
|
61
|
+
stdout is a TTY, matching the ANSI handling in `_default_redraw`
|
|
62
|
+
(e.g. CI logs and redirected output stay plain).
|
|
63
|
+
|
|
64
|
+
Returns the command's exit code. Raises FileNotFoundError if
|
|
65
|
+
`cmd[0]` isn't on PATH, same as subprocess.run.
|
|
66
|
+
"""
|
|
67
|
+
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) # nosec B603
|
|
68
|
+
if process.stdout is None:
|
|
69
|
+
raise RuntimeError("subprocess.Popen returned no stdout despite stdout=PIPE")
|
|
70
|
+
try:
|
|
71
|
+
current_group: str | None = None
|
|
72
|
+
seen_groups: set[str] = set()
|
|
73
|
+
image_container_counts = Counter((service_to_image or {}).values())
|
|
74
|
+
for line in process.stdout:
|
|
75
|
+
log_file.write(line)
|
|
76
|
+
log_file.flush()
|
|
77
|
+
if echo:
|
|
78
|
+
if group_by_image:
|
|
79
|
+
m = _BUILD_SERVICE_RE.match(line)
|
|
80
|
+
if m:
|
|
81
|
+
service = m.group(1)
|
|
82
|
+
image = (service_to_image or {}).get(service, service)
|
|
83
|
+
if image != current_group:
|
|
84
|
+
current_group = image
|
|
85
|
+
if image not in seen_groups:
|
|
86
|
+
seen_groups.add(image)
|
|
87
|
+
count = image_container_counts.get(image, 1)
|
|
88
|
+
label = f"Building {image}"
|
|
89
|
+
if count > 1:
|
|
90
|
+
label += f" ({count} containers)"
|
|
91
|
+
header = f"── {label} "
|
|
92
|
+
header += "─" * max(1, 60 - len(header))
|
|
93
|
+
if use_color and sys.stdout.isatty():
|
|
94
|
+
sys.stdout.write(f"\n\033[36m{header}\033[0m\n")
|
|
95
|
+
else:
|
|
96
|
+
sys.stdout.write(f"\n{header}\n")
|
|
97
|
+
sys.stdout.write(line)
|
|
98
|
+
sys.stdout.flush()
|
|
99
|
+
finally:
|
|
100
|
+
process.stdout.close()
|
|
101
|
+
return process.wait()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def start_log_tail(compose_path: str, log_file: IO[str]) -> subprocess.Popen:
|
|
105
|
+
"""
|
|
106
|
+
Starts `docker compose logs -f` in the background, piped only to
|
|
107
|
+
`log_file` (not the terminal; the terminal is showing the live
|
|
108
|
+
state view while this runs). Caller is responsible for stopping it
|
|
109
|
+
with `stop_log_tail` once the stack settles or `cds up` exits.
|
|
110
|
+
"""
|
|
111
|
+
logs_cmd = ["docker", "compose", "-f", compose_path, "logs", "-f", "--no-color"]
|
|
112
|
+
return subprocess.Popen(logs_cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True) # nosec B603
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def start_up_in_background(cmd: list[str], log_file: IO[str]) -> subprocess.Popen:
|
|
116
|
+
"""
|
|
117
|
+
Starts `docker compose up --detach` in the background, piped only to
|
|
118
|
+
`log_file`. Unlike `run_streamed`, this does not block: `docker
|
|
119
|
+
compose up` can itself block for a long time waiting on
|
|
120
|
+
healthcheck-gated `depends_on` dependencies to become healthy before
|
|
121
|
+
it returns, even though `--detach` is passed. Running it in the
|
|
122
|
+
background lets the live state view (which polls `docker compose ps`
|
|
123
|
+
directly) start rendering immediately instead of waiting for `up` to
|
|
124
|
+
finish. Caller is responsible for reaping it with `process.wait()`.
|
|
125
|
+
"""
|
|
126
|
+
return subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True) # nosec B603
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def stop_log_tail(process: subprocess.Popen, timeout: float = 5.0) -> None:
|
|
130
|
+
"""Terminates a background log-tail process started by start_log_tail."""
|
|
131
|
+
if process.poll() is not None:
|
|
132
|
+
return
|
|
133
|
+
process.terminate()
|
|
134
|
+
try:
|
|
135
|
+
process.wait(timeout=timeout)
|
|
136
|
+
except subprocess.TimeoutExpired:
|
|
137
|
+
process.kill()
|
|
138
|
+
process.wait()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_settled(grouped: dict[str, list[str]], expected_service_count: int | None) -> bool:
|
|
142
|
+
if not grouped:
|
|
143
|
+
return expected_service_count == 0
|
|
144
|
+
|
|
145
|
+
pending = sum(len(names) for bucket, names in grouped.items() if bucket not in _SETTLED_BUCKETS)
|
|
146
|
+
if pending:
|
|
147
|
+
return False
|
|
148
|
+
if expected_service_count is not None:
|
|
149
|
+
seen = sum(len(names) for names in grouped.values())
|
|
150
|
+
if seen < expected_service_count:
|
|
151
|
+
return False
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _default_redraw(text: str) -> None:
|
|
156
|
+
if sys.stdout.isatty():
|
|
157
|
+
sys.stdout.write("\033[2J\033[H")
|
|
158
|
+
sys.stdout.write(text + "\n")
|
|
159
|
+
else:
|
|
160
|
+
print(text)
|
|
161
|
+
sys.stdout.flush()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def poll_state_until_settled(
|
|
165
|
+
compose_path: str,
|
|
166
|
+
*,
|
|
167
|
+
expected_service_count: int | None = None,
|
|
168
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
169
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
170
|
+
use_color: bool = False,
|
|
171
|
+
sleep_fn: Callable[[float], None] = time.sleep,
|
|
172
|
+
now_fn: Callable[[], float] = time.monotonic,
|
|
173
|
+
ps_fn: Callable[[], subprocess.CompletedProcess] | None = None,
|
|
174
|
+
redraw_fn: Callable[[str], None] | None = None,
|
|
175
|
+
up_done_fn: Callable[[], int | None] | None = None,
|
|
176
|
+
on_up_finished: Callable[[int], None] | None = None,
|
|
177
|
+
) -> tuple[bool, dict[str, list[str]]]:
|
|
178
|
+
"""
|
|
179
|
+
Polls `docker compose ps -a --format json` every `poll_interval`
|
|
180
|
+
seconds, redrawing the grouped `cds state` view each time, until
|
|
181
|
+
every service `docker compose ps` reports is in a terminal bucket
|
|
182
|
+
(HEALTHY, RUNNING, HEALTHY EXIT, UNHEALTHY EXIT, or UNHEALTHY) or
|
|
183
|
+
`timeout` seconds elapse.
|
|
184
|
+
|
|
185
|
+
Returns `(settled, grouped)`. `settled` is False if the loop timed
|
|
186
|
+
out, or if any service ended in UNHEALTHY / UNHEALTHY EXIT.
|
|
187
|
+
|
|
188
|
+
`ps_fn`, `sleep_fn`, `now_fn`, and `redraw_fn` are injectable so this
|
|
189
|
+
can be unit tested with a fake clock and canned `ps` output instead
|
|
190
|
+
of real Docker calls and real sleeping.
|
|
191
|
+
|
|
192
|
+
`up_done_fn`, if given, is polled once per iteration and must return
|
|
193
|
+
`None` while `docker compose up` is still running, or its exit code
|
|
194
|
+
once it has finished. This lets the caller run `up` in the
|
|
195
|
+
background while this loop redraws the live view immediately,
|
|
196
|
+
without either process blocking the other:
|
|
197
|
+
|
|
198
|
+
- If `up` exits non-zero, this returns `(False, grouped)` right
|
|
199
|
+
away instead of burning through the full `timeout`, since
|
|
200
|
+
services that `up` never started will never settle.
|
|
201
|
+
- The `timeout` clock only starts once `up` finishes successfully,
|
|
202
|
+
so a stack whose healthchecks legitimately outlast `timeout`
|
|
203
|
+
isn't penalized for time `up` itself spent blocked on
|
|
204
|
+
healthcheck-gated `depends_on` dependencies. Omitting `up_done_fn`
|
|
205
|
+
preserves the old behavior of starting the clock immediately.
|
|
206
|
+
|
|
207
|
+
`on_up_finished`, if given, is called exactly once, the first time
|
|
208
|
+
`up_done_fn` reports a successful result (exit code 0), so callers can
|
|
209
|
+
defer setup (e.g. starting a log tail) until `up` is done rather
|
|
210
|
+
than running it concurrently with `up`'s own output.
|
|
211
|
+
"""
|
|
212
|
+
if ps_fn is None:
|
|
213
|
+
def ps_fn() -> subprocess.CompletedProcess:
|
|
214
|
+
ps_cmd = ["docker", "compose", "-f", compose_path, "ps", "-a", "--format", "json"]
|
|
215
|
+
return subprocess.run(ps_cmd, capture_output=True, text=True) # nosec B603
|
|
216
|
+
|
|
217
|
+
if redraw_fn is None:
|
|
218
|
+
redraw_fn = _default_redraw
|
|
219
|
+
|
|
220
|
+
if up_done_fn is None:
|
|
221
|
+
# No background `up` process to track: behave as if it had
|
|
222
|
+
# already finished successfully, so the timeout clock starts
|
|
223
|
+
# immediately (matches the pre-existing behavior).
|
|
224
|
+
def up_done_fn() -> int | None:
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
start: float | None = None
|
|
228
|
+
up_finished_seen = False
|
|
229
|
+
grouped: dict[str, list[str]] = {}
|
|
230
|
+
while True:
|
|
231
|
+
ps_result = ps_fn()
|
|
232
|
+
services = parse_compose_ps_json(ps_result.stdout) if ps_result.returncode == 0 else []
|
|
233
|
+
grouped = group_services_by_health(services)
|
|
234
|
+
redraw_fn(format_state_output(grouped, use_color=use_color))
|
|
235
|
+
|
|
236
|
+
if _is_settled(grouped, expected_service_count):
|
|
237
|
+
has_failure = any(bucket in _FAILURE_BUCKETS and names for bucket, names in grouped.items())
|
|
238
|
+
return (not has_failure), grouped
|
|
239
|
+
|
|
240
|
+
up_exit_code = up_done_fn()
|
|
241
|
+
if up_exit_code is not None:
|
|
242
|
+
if not up_finished_seen:
|
|
243
|
+
up_finished_seen = True
|
|
244
|
+
if up_exit_code == 0 and on_up_finished is not None:
|
|
245
|
+
on_up_finished(up_exit_code)
|
|
246
|
+
if up_exit_code != 0:
|
|
247
|
+
# `up` itself already failed; services it never started
|
|
248
|
+
# will never settle, so don't wait out the full timeout.
|
|
249
|
+
return False, grouped
|
|
250
|
+
if start is None:
|
|
251
|
+
start = now_fn()
|
|
252
|
+
|
|
253
|
+
if start is not None and now_fn() - start >= timeout:
|
|
254
|
+
return False, grouped
|
|
255
|
+
|
|
256
|
+
sleep_fn(poll_interval)
|
|
257
|
+
|