ghost-api-cli 0.2.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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/site.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Which site a run belongs to: the key for its memory and its output folder.
|
|
2
|
+
|
|
3
|
+
Usually that is the host ("hn.algolia.com"). When the start URL begins with a path that
|
|
4
|
+
names a section of the site, that first segment is kept too ("www.google.com/maps"), so
|
|
5
|
+
one big host's separate apps (Maps, Search, Support...) don't share memory and files.
|
|
6
|
+
The host alone still decides what counts as the site's own API versus a third party.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from ghost_api.analysis.url_grouping import looks_like_id
|
|
16
|
+
|
|
17
|
+
# A section name: a short plain word, no dots (files), no encoded data.
|
|
18
|
+
_SECTION_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,39}$")
|
|
19
|
+
# Locale prefixes (/en/, /en-us/, /pt_BR/) say which language, not which app.
|
|
20
|
+
_LOCALE_RE = re.compile(r"^[a-z]{2}([-_][A-Za-z]{2})?$")
|
|
21
|
+
_HOST_RE = r"[a-z0-9]([a-z0-9.-]*[a-z0-9])?"
|
|
22
|
+
SITE_RE = re.compile(rf"^{_HOST_RE}(/[A-Za-z][A-Za-z0-9_-]{{0,39}})?$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def site_key(url: str) -> str:
|
|
26
|
+
"""'https://www.google.com/maps/@12.9,77.6' -> 'www.google.com/maps';
|
|
27
|
+
'https://hn.algolia.com/' -> 'hn.algolia.com'."""
|
|
28
|
+
parts = urlsplit(url)
|
|
29
|
+
host = (parts.hostname or "").lower()
|
|
30
|
+
segments = [s for s in parts.path.split("/") if s]
|
|
31
|
+
if segments and is_section(segments[0]):
|
|
32
|
+
return f"{host}/{segments[0]}"
|
|
33
|
+
return host
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_section(segment: str) -> bool:
|
|
37
|
+
return bool(
|
|
38
|
+
_SECTION_RE.match(segment) and not _LOCALE_RE.match(segment) and not looks_like_id(segment)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def site_host(site: str) -> str:
|
|
43
|
+
"""The host part: 'www.google.com/maps' -> 'www.google.com'."""
|
|
44
|
+
return site.split("/", 1)[0]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_site(name: str) -> bool:
|
|
48
|
+
"""A safe site name (used in file paths and URLs): host, optionally /section."""
|
|
49
|
+
return bool(SITE_RE.match(name))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# In dashboard URLs a site is one path segment, so its "/" is written as "~".
|
|
53
|
+
def to_url_id(site: str) -> str:
|
|
54
|
+
return site.replace("/", "~")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def from_url_id(url_id: str) -> str:
|
|
58
|
+
return url_id.replace("~", "/", 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SiteAmbiguous(ValueError):
|
|
62
|
+
def __init__(self, name: str, choices: list[str]) -> None:
|
|
63
|
+
self.choices = choices
|
|
64
|
+
super().__init__(f"{name} has several sites: {', '.join(choices)}. Name one of them.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_site(name: str, known: Iterable[str]) -> str:
|
|
68
|
+
"""What the user typed -> a stored site name.
|
|
69
|
+
|
|
70
|
+
Accepts a site ('www.google.com/maps'), a URL, or a bare domain. A bare domain with
|
|
71
|
+
exactly one stored site under it means that site; with several, SiteAmbiguous.
|
|
72
|
+
Unknown names are returned as typed (the caller reports "nothing stored")."""
|
|
73
|
+
name = name.strip().rstrip("/")
|
|
74
|
+
if "://" in name:
|
|
75
|
+
name = site_key(name)
|
|
76
|
+
sites = set(known)
|
|
77
|
+
if name in sites:
|
|
78
|
+
return name
|
|
79
|
+
under = sorted(s for s in sites if s.startswith(name + "/"))
|
|
80
|
+
if len(under) == 1:
|
|
81
|
+
return under[0]
|
|
82
|
+
if under:
|
|
83
|
+
raise SiteAmbiguous(name, under)
|
|
84
|
+
return name
|
ghost_api/specdiff.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""`ghost-api verify-spec`: compare a published OpenAPI spec with what the site really does.
|
|
2
|
+
|
|
3
|
+
Documentation drifts from reality. Using the endpoints Ghost API observed and verified for
|
|
4
|
+
a site, this reports:
|
|
5
|
+
|
|
6
|
+
undocumented the site uses it, the spec doesn't mention it
|
|
7
|
+
not seen the spec documents it, the site never used it while explored
|
|
8
|
+
(it may be unused, removed, or just not reached)
|
|
9
|
+
field gaps for endpoints in both: reply fields the spec doesn't list, and spec
|
|
10
|
+
fields the real replies never contained
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from ghost_api.analysis.graphql import split_template
|
|
24
|
+
from ghost_api.analysis.models import Endpoint
|
|
25
|
+
|
|
26
|
+
METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SpecError(Exception):
|
|
30
|
+
"""The file is not an OpenAPI document Ghost API can read."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EndpointCheck:
|
|
35
|
+
endpoint: str # "GET /api/items/{}"
|
|
36
|
+
status: str # "documented", "undocumented", "not seen"
|
|
37
|
+
extra_fields: list[str] = field(default_factory=list) # in replies, not in the spec
|
|
38
|
+
missing_fields: list[str] = field(default_factory=list) # in the spec, never in replies
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_spec(path: Path) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
text = path.read_text(encoding="utf-8")
|
|
44
|
+
data = json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
|
|
45
|
+
except (OSError, ValueError, yaml.YAMLError) as exc:
|
|
46
|
+
raise SpecError(f"{path.name} can't be read: {exc}") from exc
|
|
47
|
+
if not isinstance(data, dict) or not isinstance(data.get("paths"), dict):
|
|
48
|
+
raise SpecError(f"{path.name} is not an OpenAPI document (no 'paths')")
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _key(method: str, path: str) -> str:
|
|
53
|
+
return f"{method.upper()} {re.sub(r'{[^}]*}', '{}', path.rstrip('/') or '/')}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve(spec: dict[str, Any], node: Any, depth: int = 0) -> dict[str, Any]:
|
|
57
|
+
if not isinstance(node, dict) or depth > 10:
|
|
58
|
+
return {}
|
|
59
|
+
ref = node.get("$ref")
|
|
60
|
+
if isinstance(ref, str) and ref.startswith("#/"):
|
|
61
|
+
target: Any = spec
|
|
62
|
+
for part in ref[2:].split("/"):
|
|
63
|
+
target = target.get(part, {}) if isinstance(target, dict) else {}
|
|
64
|
+
return _resolve(spec, target, depth + 1)
|
|
65
|
+
return node
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _spec_fields(spec: dict[str, Any], operation: dict[str, Any]) -> set[str] | None:
|
|
69
|
+
"""Top-level fields of the operation's success reply, or None when not described."""
|
|
70
|
+
for code in ("200", "201", "2XX", "default"):
|
|
71
|
+
reply = _resolve(spec, (operation.get("responses") or {}).get(code))
|
|
72
|
+
content = reply.get("content") or {}
|
|
73
|
+
media = next((v for k, v in content.items() if "json" in k), None)
|
|
74
|
+
if media is None:
|
|
75
|
+
continue
|
|
76
|
+
schema = _resolve(spec, media.get("schema"))
|
|
77
|
+
if schema.get("type") == "array" or "items" in schema:
|
|
78
|
+
schema = _resolve(spec, schema.get("items"))
|
|
79
|
+
props = schema.get("properties")
|
|
80
|
+
if isinstance(props, dict):
|
|
81
|
+
return set(props)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _seen_fields(ep: Endpoint) -> set[str]:
|
|
86
|
+
schema = ep.response_schema or {}
|
|
87
|
+
if schema.get("type") == "array" or "items" in schema:
|
|
88
|
+
schema = schema.get("items") or {}
|
|
89
|
+
props = schema.get("properties")
|
|
90
|
+
return set(props) if isinstance(props, dict) else set()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compare(spec: dict[str, Any], endpoints: list[Endpoint]) -> list[EndpointCheck]:
|
|
94
|
+
documented: dict[str, dict[str, Any]] = {}
|
|
95
|
+
for path, item in (spec.get("paths") or {}).items():
|
|
96
|
+
if not isinstance(item, dict):
|
|
97
|
+
continue
|
|
98
|
+
for method, operation in item.items():
|
|
99
|
+
if method in METHODS and isinstance(operation, dict):
|
|
100
|
+
documented[_key(method, str(path))] = operation
|
|
101
|
+
observed: dict[str, Endpoint] = {}
|
|
102
|
+
for ep in endpoints:
|
|
103
|
+
path, _ = split_template(ep.path_template)
|
|
104
|
+
observed.setdefault(_key(ep.method, path), ep)
|
|
105
|
+
checks = []
|
|
106
|
+
for key in sorted(observed.keys() | documented.keys()):
|
|
107
|
+
if key not in documented:
|
|
108
|
+
checks.append(EndpointCheck(key, "undocumented"))
|
|
109
|
+
elif key not in observed:
|
|
110
|
+
checks.append(EndpointCheck(key, "not seen"))
|
|
111
|
+
else:
|
|
112
|
+
check = EndpointCheck(key, "documented")
|
|
113
|
+
listed, seen = _spec_fields(spec, documented[key]), _seen_fields(observed[key])
|
|
114
|
+
if listed is not None and seen:
|
|
115
|
+
check.extra_fields = sorted(seen - listed)
|
|
116
|
+
check.missing_fields = sorted(listed - seen)
|
|
117
|
+
checks.append(check)
|
|
118
|
+
return checks
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def to_json(checks: list[EndpointCheck]) -> list[dict[str, Any]]:
|
|
122
|
+
return [asdict(c) for c in checks]
|
ghost_api/ui/__init__.py
ADDED
|
File without changes
|
ghost_api/ui/keys.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Ctrl+C during a run inside the interactive session, read as a key, not a signal.
|
|
2
|
+
|
|
3
|
+
On Windows a Ctrl+C *signal* also reaches cmd.exe, which runs the `ghost-api.cmd`
|
|
4
|
+
launcher; when Ghost API later exits, cmd asks "Terminate batch job (Y/N)?". While a run
|
|
5
|
+
is going (and only then: never while you type), `ctrl_c_as_key()` switches the console so
|
|
6
|
+
Ctrl+C arrives as an ordinary key, read by a small thread. The first press asks the run
|
|
7
|
+
to stop at the next safe point (`stop_requested()`, checked between steps); a second
|
|
8
|
+
press interrupts at once; a third quits. The session itself keeps going.
|
|
9
|
+
|
|
10
|
+
That console switch also turns off Windows' line editing (Backspace, Enter), so anything
|
|
11
|
+
that reads a typed answer during a run must use `input_paused()`, which puts the normal
|
|
12
|
+
mode back while the user types.
|
|
13
|
+
|
|
14
|
+
On other systems, and when input isn't a console, these do nothing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import _thread
|
|
20
|
+
import contextlib
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable, Iterator
|
|
26
|
+
|
|
27
|
+
ENABLE_PROCESSED_INPUT = 0x0001
|
|
28
|
+
STD_INPUT_HANDLE = -10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Watcher:
|
|
32
|
+
def __init__(self, set_mode: Callable[[bool], None]) -> None:
|
|
33
|
+
self.stop = threading.Event()
|
|
34
|
+
self.pause = threading.Event()
|
|
35
|
+
self.presses = 0
|
|
36
|
+
self.set_mode = set_mode # True: normal console mode (typing works)
|
|
37
|
+
|
|
38
|
+
def run(self) -> None:
|
|
39
|
+
if sys.platform != "win32":
|
|
40
|
+
return
|
|
41
|
+
import msvcrt
|
|
42
|
+
|
|
43
|
+
while not self.stop.is_set():
|
|
44
|
+
if self.pause.is_set() or not msvcrt.kbhit():
|
|
45
|
+
time.sleep(0.05)
|
|
46
|
+
continue
|
|
47
|
+
if self.pause.is_set(): # paused while waiting: leave the key to the reader
|
|
48
|
+
continue
|
|
49
|
+
if msvcrt.getwch() == "\x03":
|
|
50
|
+
self.presses += 1
|
|
51
|
+
pressed(self.presses)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_active: _Watcher | None = None
|
|
55
|
+
_stop = threading.Event()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _say(message: str) -> None:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Set by the session: how to tell the user that a stop is on its way.
|
|
63
|
+
on_stop_requested: Callable[[str], None] = _say
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stop_requested() -> bool:
|
|
67
|
+
"""Has the user asked the current run to stop? (Checked between steps.)"""
|
|
68
|
+
return _stop.is_set()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pressed(presses: int) -> None:
|
|
72
|
+
"""1st Ctrl+C: stop at the next safe point (a browser that is starting or loading a
|
|
73
|
+
page must not be interrupted half-way: it can hang). 2nd: interrupt now. 3rd: quit."""
|
|
74
|
+
if presses == 1:
|
|
75
|
+
_stop.set()
|
|
76
|
+
on_stop_requested("Stopping after the current step... (Ctrl+C again to stop at once)")
|
|
77
|
+
elif presses == 2:
|
|
78
|
+
_thread.interrupt_main()
|
|
79
|
+
else:
|
|
80
|
+
os._exit(130)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextlib.contextmanager
|
|
84
|
+
def ctrl_c_as_key() -> Iterator[None]:
|
|
85
|
+
"""Use around running something (not around reading input)."""
|
|
86
|
+
global _active
|
|
87
|
+
if sys.platform != "win32" or not sys.stdin.isatty() or _active is not None:
|
|
88
|
+
yield
|
|
89
|
+
return
|
|
90
|
+
import ctypes
|
|
91
|
+
|
|
92
|
+
kernel = ctypes.windll.kernel32
|
|
93
|
+
handle = kernel.GetStdHandle(STD_INPUT_HANDLE)
|
|
94
|
+
mode = ctypes.c_uint32()
|
|
95
|
+
if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
96
|
+
yield
|
|
97
|
+
return
|
|
98
|
+
normal = mode.value
|
|
99
|
+
|
|
100
|
+
def set_mode(typing: bool) -> None:
|
|
101
|
+
kernel.SetConsoleMode(handle, normal if typing else normal & ~ENABLE_PROCESSED_INPUT)
|
|
102
|
+
|
|
103
|
+
watcher = _Watcher(set_mode)
|
|
104
|
+
_stop.clear()
|
|
105
|
+
set_mode(False)
|
|
106
|
+
thread = threading.Thread(target=watcher.run, daemon=True)
|
|
107
|
+
_active = watcher
|
|
108
|
+
thread.start()
|
|
109
|
+
try:
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
watcher.stop.set()
|
|
113
|
+
thread.join(timeout=1)
|
|
114
|
+
set_mode(True)
|
|
115
|
+
_active = None
|
|
116
|
+
_stop.clear()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@contextlib.contextmanager
|
|
120
|
+
def input_paused() -> Iterator[None]:
|
|
121
|
+
"""Let the user type an answer during a run: the watcher stops reading keys and the
|
|
122
|
+
console's normal mode (Backspace, Enter, Ctrl+C) comes back until the answer is in."""
|
|
123
|
+
watcher = _active
|
|
124
|
+
if watcher is None:
|
|
125
|
+
yield
|
|
126
|
+
return
|
|
127
|
+
watcher.pause.set()
|
|
128
|
+
time.sleep(0.06) # let the watcher finish any key it was reading
|
|
129
|
+
watcher.set_mode(True)
|
|
130
|
+
try:
|
|
131
|
+
yield
|
|
132
|
+
finally:
|
|
133
|
+
watcher.set_mode(False)
|
|
134
|
+
watcher.pause.clear()
|