grewbrowser 0.1.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.
@@ -0,0 +1,101 @@
1
+ """grewbrowser — a stealth Playwright launcher for Chromium."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .behavior.config import HumanConfig, resolve_config
6
+ from .behavior.human import humanize_context, humanize_context_async, install
7
+ from .core.binary_manager import ChromiumBinary, binary_info, resolve_binary
8
+ from .core.download import (
9
+ InstalledBuild,
10
+ ensure_chromium,
11
+ installed_builds,
12
+ cache_dir as browser_cache_dir,
13
+ )
14
+ from .diagnostics import Check, evaluate_checks, observe, run_checks
15
+ from .core.flags import IGNORE_DEFAULT_ARGS, build_args, build_stealth_args
16
+ from .core.profile import BrowserProfile, ScreenProfile, random_seed
17
+ from .exceptions import (
18
+ ActionabilityError,
19
+ BinaryNotFoundError,
20
+ FingerprintError,
21
+ GeoIPError,
22
+ GrewBrowserError,
23
+ IsolatedWorldError,
24
+ LaunchError,
25
+ PoolClosedError,
26
+ ProxyConfigError,
27
+ RelayError,
28
+ )
29
+ from .fingerprint.scripts import build_fingerprint_script
30
+ from .launcher import (
31
+ AsyncBrowserSession,
32
+ BrowserSession,
33
+ launch,
34
+ launch_async,
35
+ launch_persistent_context,
36
+ launch_persistent_context_async,
37
+ )
38
+ from .network.geoip import GeoIdentity, resolve_exit_ip, resolve_geo
39
+ from .network.proxy import ProxyConfig, parse_proxy
40
+ from .network.relay import AuthRelay, RelayHandle, start_relay, start_relay_async
41
+ from .session.pool import BrowserPool, SyncBrowserPool
42
+ from .session.teardown import TeardownGuard, global_guard, guard
43
+
44
+ __version__ = "0.1.0"
45
+
46
+ __all__ = [
47
+ "launch",
48
+ "launch_async",
49
+ "launch_persistent_context",
50
+ "launch_persistent_context_async",
51
+ "BrowserSession",
52
+ "AsyncBrowserSession",
53
+ "BrowserProfile",
54
+ "ScreenProfile",
55
+ "random_seed",
56
+ "build_stealth_args",
57
+ "build_args",
58
+ "IGNORE_DEFAULT_ARGS",
59
+ "build_fingerprint_script",
60
+ "ChromiumBinary",
61
+ "resolve_binary",
62
+ "binary_info",
63
+ "ensure_chromium",
64
+ "installed_builds",
65
+ "InstalledBuild",
66
+ "browser_cache_dir",
67
+ "run_checks",
68
+ "evaluate_checks",
69
+ "observe",
70
+ "Check",
71
+ "HumanConfig",
72
+ "resolve_config",
73
+ "humanize_context",
74
+ "humanize_context_async",
75
+ "install",
76
+ "ProxyConfig",
77
+ "parse_proxy",
78
+ "GeoIdentity",
79
+ "resolve_geo",
80
+ "resolve_exit_ip",
81
+ "AuthRelay",
82
+ "RelayHandle",
83
+ "start_relay",
84
+ "start_relay_async",
85
+ "BrowserPool",
86
+ "SyncBrowserPool",
87
+ "TeardownGuard",
88
+ "guard",
89
+ "global_guard",
90
+ "GrewBrowserError",
91
+ "BinaryNotFoundError",
92
+ "LaunchError",
93
+ "ProxyConfigError",
94
+ "RelayError",
95
+ "GeoIPError",
96
+ "FingerprintError",
97
+ "IsolatedWorldError",
98
+ "ActionabilityError",
99
+ "PoolClosedError",
100
+ "__version__",
101
+ ]
@@ -0,0 +1,248 @@
1
+ """Command line interface: ``python -m grewbrowser <command>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ import sys
9
+ from typing import Any
10
+
11
+ from .core.binary_manager import binary_info, clear_cache
12
+ from .core.download import (
13
+ cache_dir,
14
+ clear_downloads,
15
+ ensure_chromium,
16
+ installed_builds,
17
+ platform_tag,
18
+ remove_build,
19
+ verify_build,
20
+ )
21
+ from .exceptions import GrewBrowserError
22
+
23
+
24
+ def _install(args: argparse.Namespace) -> int:
25
+ """Download grewbrowser's own browser build."""
26
+ build = ensure_chromium(args.version, source=args.source, force=args.force)
27
+ print(f"installed {build.version} ({build.source})")
28
+ print(f" executable: {build.executable}")
29
+ print(f" sha256 : {build.archive_sha256}")
30
+ print("\nIt is now the default for channel='auto', so launch() needs no arguments.")
31
+ return 0
32
+
33
+
34
+ def _list(args: argparse.Namespace) -> int:
35
+ """List cached builds."""
36
+ builds = installed_builds()
37
+ print(f"cache: {cache_dir()} (platform {platform_tag()})")
38
+ if not builds:
39
+ print(" no builds yet -- run 'python -m grewbrowser install'")
40
+ return 0
41
+ for build in builds:
42
+ state = "ok" if verify_build(build) else "BROKEN"
43
+ print(f" {build.version:<20} {state:<7} {build.source:<20} {build.executable}")
44
+ return 0
45
+
46
+
47
+ def _info(args: argparse.Namespace) -> int:
48
+ """Report which browser a launch would resolve to."""
49
+ clear_cache()
50
+ for key, value in binary_info(channel=args.channel).items():
51
+ print(f"{key:9} {value}")
52
+ return 0
53
+
54
+
55
+ def _open(args: argparse.Namespace) -> int:
56
+ """Open a URL in a real window, to eyeball the result."""
57
+ from . import launch
58
+
59
+ with launch(headless=args.headless, channel=args.channel, geoip=False) as session:
60
+ print(f"browser : {session.binary.channel} {session.binary.version}")
61
+ print(
62
+ f"identity: seed={session.seed} {session.profile.os_family} "
63
+ f"{session.profile.locale} {session.profile.timezone_id}"
64
+ )
65
+ page = session.new_page()
66
+ page.goto(args.url, wait_until="domcontentloaded")
67
+ print(f"title : {page.title()}")
68
+ if not args.headless:
69
+ input("Press Enter to close...")
70
+ return 0
71
+
72
+
73
+ def _check(args: argparse.Namespace) -> int:
74
+ """Run the self-checks and compare headless against headful."""
75
+ from .diagnostics import run_checks
76
+
77
+ modes = {"both": (True, False), "headless": (True,), "headful": (False,)}[args.mode]
78
+ results: dict[bool, list] = {}
79
+ for headless in modes:
80
+ label = "headless" if headless else "headful"
81
+ print(f"running {label} ...", flush=True)
82
+ profile, checks = run_checks(
83
+ headless=headless,
84
+ url=args.url,
85
+ channel=args.channel,
86
+ seed=args.seed,
87
+ include_behaviour=not args.fast,
88
+ )
89
+ results[headless] = checks
90
+ print(
91
+ f" identity: seed={profile.seed} {profile.os_family} "
92
+ f"Chrome/{profile.chrome_major} {profile.screen.width}x{profile.screen.height}\n"
93
+ )
94
+
95
+ width = max(len(check.name) for checks in results.values() for check in checks)
96
+ header = f"{'check'.ljust(width)} " + " ".join(
97
+ ("headless" if headless else "headful ") for headless in modes
98
+ )
99
+ print(header)
100
+ print("-" * len(header))
101
+
102
+ core_failures = 0
103
+ env_failures = 0
104
+ differences = 0
105
+ annotations: list[str] = []
106
+ by_name: dict[str, dict[bool, Any]] = {}
107
+ order: list[str] = []
108
+ for headless, checks in results.items():
109
+ for check in checks:
110
+ if check.name not in by_name:
111
+ order.append(check.name)
112
+ by_name.setdefault(check.name, {})[headless] = check
113
+
114
+ def render(tier: str) -> None:
115
+ nonlocal core_failures, env_failures, differences
116
+ printed = False
117
+ for name in order:
118
+ per_mode = by_name[name]
119
+ sample = next(iter(per_mode.values()))
120
+ if sample.tier != tier:
121
+ continue
122
+ if not printed:
123
+ heading = "coherence" if tier == "core" else "this machine"
124
+ print(f"\n[{heading}]")
125
+ printed = True
126
+ cells = []
127
+ verdicts = set()
128
+ for headless in modes:
129
+ check = per_mode.get(headless)
130
+ if check is None:
131
+ cells.append(" -- ")
132
+ continue
133
+ verdicts.add(check.ok)
134
+ if not check.ok:
135
+ if tier == "core":
136
+ core_failures += 1
137
+ else:
138
+ env_failures += 1
139
+ cells.append(
140
+ " pass " if check.ok else (" FAIL " if tier == "core" else " n/a ")
141
+ )
142
+ differing = len(verdicts) > 1
143
+ differences += 1 if differing else 0
144
+ marker = " <- differs" if differing else ""
145
+ print(f"{name.ljust(width)} " + " ".join(cells) + marker)
146
+ if not all(verdicts):
147
+ for headless in modes:
148
+ check = per_mode.get(headless)
149
+ if check is not None and not check.ok:
150
+ label = "headless" if headless else "headful"
151
+ print(f"{' '.ljust(width)} {label}: {check.detail}")
152
+ if tier == "core":
153
+ annotations.append(f"{name} [{label}]: {check.detail}")
154
+
155
+ render("core")
156
+ render("environment")
157
+
158
+ total = sum(len(checks) for checks in results.values())
159
+ passed = total - core_failures - env_failures
160
+ print(f"\n{passed}/{total} checks passed")
161
+ if env_failures:
162
+ print(
163
+ f"{env_failures} depend on the host, not on grewbrowser "
164
+ "(no GPU, missing fonts, no speech voices on a CI box)"
165
+ )
166
+ if len(modes) > 1:
167
+ print(
168
+ f"{differences} check(s) differ between headless and headful"
169
+ if differences
170
+ else "headless and headful agree on every check"
171
+ )
172
+
173
+ if annotations and os.environ.get("GITHUB_ACTIONS") == "true":
174
+ for line in annotations:
175
+ print(f"::error title=grewbrowser check::{line}")
176
+
177
+ return 1 if core_failures else 0
178
+
179
+
180
+ def _clean(args: argparse.Namespace) -> int:
181
+ """Delete cached builds."""
182
+ if args.version:
183
+ removed = remove_build(args.version)
184
+ print(f"removed {args.version}" if removed else f"no cached build {args.version}")
185
+ return 0 if removed else 1
186
+ count = clear_downloads()
187
+ print(f"removed {count} build(s) from {cache_dir()}")
188
+ return 0
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ """Parse arguments and run the chosen command."""
193
+ parser = argparse.ArgumentParser(prog="python -m grewbrowser")
194
+ parser.add_argument("-q", "--quiet", action="store_true", help="suppress progress logs")
195
+ commands = parser.add_subparsers(dest="command", required=True)
196
+
197
+ install = commands.add_parser("install", help="download grewbrowser's own Chromium")
198
+ install.add_argument("--version", default=None, help="exact version or snapshot revision")
199
+ install.add_argument(
200
+ "--source",
201
+ default="chrome-for-testing",
202
+ choices=("chrome-for-testing", "chromium-snapshots"),
203
+ )
204
+ install.add_argument("--force", action="store_true", help="re-download even if cached")
205
+ install.set_defaults(handler=_install)
206
+
207
+ listing = commands.add_parser("list", help="list cached builds")
208
+ listing.set_defaults(handler=_list)
209
+
210
+ info = commands.add_parser("info", help="show which browser a launch would use")
211
+ info.add_argument("--channel", default="auto")
212
+ info.set_defaults(handler=_info)
213
+
214
+ opener = commands.add_parser("open", help="open a URL in a real window")
215
+ opener.add_argument("url", nargs="?", default="https://example.com")
216
+ opener.add_argument("--channel", default="auto")
217
+ opener.add_argument("--headless", action="store_true")
218
+ opener.set_defaults(handler=_open)
219
+
220
+ check = commands.add_parser("check", help="self-check, headless vs headful")
221
+ check.add_argument("--mode", default="both", choices=("both", "headless", "headful"))
222
+ check.add_argument("--url", default=None, help="run the probes on this page")
223
+ check.add_argument("--channel", default="auto")
224
+ check.add_argument("--seed", type=int, default=None, help="pin the identity")
225
+ check.add_argument("--fast", action="store_true", help="skip the input-behaviour probes")
226
+ check.set_defaults(handler=_check)
227
+
228
+ clean = commands.add_parser("clean", help="delete cached builds")
229
+ clean.add_argument("--version", default=None, help="delete only this version")
230
+ clean.set_defaults(handler=_clean)
231
+
232
+ args = parser.parse_args(argv)
233
+ logging.basicConfig(
234
+ level=logging.WARNING if args.quiet else logging.INFO,
235
+ format="%(message)s",
236
+ )
237
+ try:
238
+ return int(args.handler(args))
239
+ except GrewBrowserError as exc:
240
+ print(f"error: {exc}", file=sys.stderr)
241
+ return 1
242
+ except KeyboardInterrupt:
243
+ print("\ninterrupted", file=sys.stderr)
244
+ return 130
245
+
246
+
247
+ if __name__ == "__main__":
248
+ raise SystemExit(main())
grewbrowser/_driver.py ADDED
@@ -0,0 +1,61 @@
1
+ """Shared Playwright drivers, reference counted per thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ __all__ = ["acquire_sync", "release_sync", "active_sync_drivers"]
11
+
12
+ logger = logging.getLogger("grewbrowser")
13
+
14
+ _local = threading.local()
15
+ _counter_lock = threading.Lock()
16
+ _live = 0
17
+
18
+
19
+ @dataclass
20
+ class _Driver:
21
+ playwright: Any
22
+ refcount: int = 0
23
+
24
+
25
+ def acquire_sync() -> Any:
26
+ """Return this thread's Playwright driver, starting it on first use."""
27
+ global _live
28
+ driver = getattr(_local, "driver", None)
29
+ if driver is None:
30
+ from playwright.sync_api import sync_playwright
31
+
32
+ driver = _Driver(sync_playwright().start())
33
+ _local.driver = driver
34
+ with _counter_lock:
35
+ _live += 1
36
+ driver.refcount += 1
37
+ return driver.playwright
38
+
39
+
40
+ def release_sync() -> None:
41
+ """Give back a driver reference, stopping it when the last one goes."""
42
+ global _live
43
+ driver = getattr(_local, "driver", None)
44
+ if driver is None:
45
+ return
46
+ driver.refcount -= 1
47
+ if driver.refcount > 0:
48
+ return
49
+ _local.driver = None
50
+ with _counter_lock:
51
+ _live -= 1
52
+ try:
53
+ driver.playwright.stop()
54
+ except Exception as exc:
55
+ logger.debug("Stopping the Playwright driver failed: %s", exc)
56
+
57
+
58
+ def active_sync_drivers() -> int:
59
+ """How many threads currently hold a started driver."""
60
+ with _counter_lock:
61
+ return _live
@@ -0,0 +1,43 @@
1
+ """Human-like interaction primitives that replace Playwright's own."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .config import HumanConfig, Preset, Range, resolve_config
6
+ from .curves import MoveStep, Point, WheelStep, plan_move, plan_stroke, plan_wheel
7
+ from .human import (
8
+ AsyncHumanEngine,
9
+ HumanEngine,
10
+ engine_for,
11
+ humanize_context,
12
+ humanize_context_async,
13
+ humanize_page,
14
+ humanize_page_async,
15
+ install,
16
+ )
17
+ from .keyboard import KeyEvent, plan_typing
18
+ from .world import AsyncIsolatedWorld, IsolatedWorld
19
+
20
+ __all__ = [
21
+ "HumanConfig",
22
+ "Preset",
23
+ "Range",
24
+ "resolve_config",
25
+ "Point",
26
+ "MoveStep",
27
+ "WheelStep",
28
+ "plan_move",
29
+ "plan_stroke",
30
+ "plan_wheel",
31
+ "KeyEvent",
32
+ "plan_typing",
33
+ "HumanEngine",
34
+ "AsyncHumanEngine",
35
+ "humanize_page",
36
+ "humanize_page_async",
37
+ "humanize_context",
38
+ "humanize_context_async",
39
+ "engine_for",
40
+ "install",
41
+ "IsolatedWorld",
42
+ "AsyncIsolatedWorld",
43
+ ]
@@ -0,0 +1,197 @@
1
+ """Tunable parameters for the human-behaviour layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import random
7
+ import time
8
+ from dataclasses import dataclass, fields, replace
9
+ from typing import Final, Literal
10
+
11
+ __all__ = [
12
+ "Range",
13
+ "HumanConfig",
14
+ "Preset",
15
+ "resolve_config",
16
+ "rand",
17
+ "rand_range",
18
+ "rand_int_range",
19
+ "rand_lognormal",
20
+ "sleep_ms",
21
+ ]
22
+
23
+ Range = tuple[float, float]
24
+
25
+ Preset = Literal["default", "careful", "brisk"]
26
+
27
+ FRAME_MS: Final[float] = 1000.0 / 60.0
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class HumanConfig:
32
+ """Every timing knob of the behaviour layer.
33
+ All durations are milliseconds. ``Range`` values are sampled log-normally
34
+ between roughly the 5th and 95th percentile, so the occasional long pause
35
+ appears without the mean drifting.
36
+ """
37
+
38
+ typing_delay: float = 150.0
39
+ typing_delay_spread: float = 70.0
40
+ key_hold: Range = (60.0, 175.0)
41
+ word_pause_chance: float = 0.15
42
+ word_pause: Range = (300.0, 1000.0)
43
+ shift_down_delay: Range = (60.0, 220.0)
44
+ shift_up_delay: Range = (-60.0, 30.0)
45
+ mistype_chance: float = 0.04
46
+ mistype_notice_delay: Range = (100.0, 300.0)
47
+ mistype_correct_delay: Range = (50.0, 150.0)
48
+ field_switch_delay: Range = (800.0, 1500.0)
49
+
50
+ move_time_base: float = 160.0
51
+ move_time_per_bit: float = 75.0
52
+ move_time_spread: float = 0.3
53
+ move_min_steps: int = 2
54
+ move_max_steps: int = 200
55
+ wobble_max: float = 1.5
56
+ overshoot_chance: float = 0.35
57
+ overshoot_px: Range = (2.0, 6.0)
58
+
59
+ aim_delay_input: Range = (60.0, 140.0)
60
+ aim_delay_button: Range = (80.0, 200.0)
61
+ hold_input: Range = (40.0, 100.0)
62
+ hold_button: Range = (60.0, 150.0)
63
+ input_x_fraction: Range = (0.05, 0.30)
64
+
65
+ notch_px: float = 0.0
66
+ notch_pause_fast: Range = (30.0, 80.0)
67
+ notch_pause_slow: Range = (80.0, 200.0)
68
+ accel_notches: Range = (2, 3)
69
+ decel_notches: Range = (2, 3)
70
+ scroll_overshoot_chance: float = 0.10
71
+ scroll_overshoot_px: Range = (50.0, 150.0)
72
+ scroll_settle_delay: Range = (300.0, 600.0)
73
+
74
+ idle_drift_px: float = 3.0
75
+ idle_pause: Range = (300.0, 1000.0)
76
+ idle_between_actions: bool = False
77
+ idle_between_duration: Range = (0.3, 0.8)
78
+
79
+ action_timeout: float = 30_000.0
80
+ poll_interval: float = 50.0
81
+ stability_epsilon: float = 1.0
82
+
83
+ initial_cursor_x: Range = (400.0, 700.0)
84
+ initial_cursor_y: Range = (45.0, 60.0)
85
+
86
+ def merged(self, **overrides: object) -> HumanConfig:
87
+ """Return a copy with *overrides* applied.
88
+ Raises:
89
+ TypeError: If an override does not name a configuration field.
90
+ """
91
+ known = {f.name for f in fields(self)}
92
+ unknown = set(overrides) - known
93
+ if unknown:
94
+ raise TypeError(f"Unknown HumanConfig field(s): {', '.join(sorted(unknown))}")
95
+ return replace(self, **overrides)
96
+
97
+
98
+ _CAREFUL = HumanConfig(
99
+ typing_delay=210.0,
100
+ typing_delay_spread=95.0,
101
+ word_pause_chance=0.25,
102
+ word_pause=(400.0, 1600.0),
103
+ mistype_chance=0.06,
104
+ move_time_base=220.0,
105
+ move_time_per_bit=95.0,
106
+ overshoot_chance=0.45,
107
+ aim_delay_input=(90.0, 220.0),
108
+ aim_delay_button=(120.0, 300.0),
109
+ idle_between_actions=True,
110
+ )
111
+
112
+ _BRISK = HumanConfig(
113
+ typing_delay=105.0,
114
+ typing_delay_spread=45.0,
115
+ word_pause_chance=0.08,
116
+ mistype_chance=0.02,
117
+ move_time_base=120.0,
118
+ move_time_per_bit=55.0,
119
+ overshoot_chance=0.25,
120
+ )
121
+
122
+ _PRESETS: Final[dict[str, HumanConfig]] = {
123
+ "default": HumanConfig(),
124
+ "careful": _CAREFUL,
125
+ "brisk": _BRISK,
126
+ }
127
+
128
+
129
+ def resolve_config(
130
+ humanize: bool | str | HumanConfig | dict[str, object] | None,
131
+ ) -> HumanConfig | None:
132
+ """Normalise the ``humanize=`` argument of the launcher.
133
+ Args:
134
+ humanize: ``True``/``None`` for the default preset, ``False`` to
135
+ disable the layer, a preset name, a ready
136
+ :class:`HumanConfig`, or a mapping of field overrides applied on
137
+ top of the default preset.
138
+ Returns:
139
+ The resolved configuration, or ``None`` when the layer is disabled.
140
+ Raises:
141
+ ValueError: If a preset name is unknown.
142
+ TypeError: If the value is of an unsupported type, or a mapping names
143
+ an unknown field.
144
+ """
145
+ if humanize is False:
146
+ return None
147
+ if humanize is None or humanize is True:
148
+ return _PRESETS["default"]
149
+ if isinstance(humanize, HumanConfig):
150
+ return humanize
151
+ if isinstance(humanize, str):
152
+ try:
153
+ return _PRESETS[humanize]
154
+ except KeyError:
155
+ raise ValueError(
156
+ f"Unknown humanize preset {humanize!r}; "
157
+ f"choose from {', '.join(sorted(_PRESETS))}"
158
+ ) from None
159
+ if isinstance(humanize, dict):
160
+ return _PRESETS["default"].merged(**humanize)
161
+ raise TypeError(f"Unsupported humanize value of type {type(humanize).__name__}")
162
+
163
+
164
+ def rand(low: float, high: float) -> float:
165
+ """Uniform sample in ``[low, high]``."""
166
+ return random.uniform(low, high)
167
+
168
+
169
+ def rand_range(span: Range) -> float:
170
+ """Log-normal sample whose 5th/95th percentiles are roughly *span*.
171
+ A uniform sample over a human delay range produces a flat distribution no
172
+ person generates; the log-normal shape gives the characteristic long tail.
173
+ Ranges that cross zero (such as ``shift_up_delay``) fall back to uniform.
174
+ """
175
+ low, high = span
176
+ if low <= 0 or high <= low:
177
+ return random.uniform(low, high)
178
+ median = math.sqrt(low * high)
179
+ sigma = math.log(high / low) / 3.29
180
+ return min(high * 1.5, median * math.exp(random.gauss(0.0, sigma)))
181
+
182
+
183
+ def rand_int_range(span: Range) -> int:
184
+ """Integer sample in ``[low, high]``, inclusive."""
185
+ low, high = span
186
+ return random.randint(int(low), int(high))
187
+
188
+
189
+ def rand_lognormal(median: float, sigma: float) -> float:
190
+ """Log-normal sample around *median*."""
191
+ return median * math.exp(random.gauss(0.0, sigma))
192
+
193
+
194
+ def sleep_ms(milliseconds: float) -> None:
195
+ """Block for *milliseconds*, ignoring non-positive values."""
196
+ if milliseconds > 0:
197
+ time.sleep(milliseconds / 1000.0)