r2flow-engine 0.8.11__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.
Files changed (70) hide show
  1. r2flow/__init__.py +116 -0
  2. r2flow/core/__init__.py +28 -0
  3. r2flow/core/asset_tools.py +84 -0
  4. r2flow/core/assets.py +258 -0
  5. r2flow/core/blocking.py +185 -0
  6. r2flow/core/config.py +211 -0
  7. r2flow/core/errors.py +105 -0
  8. r2flow/core/events.py +92 -0
  9. r2flow/core/excel.py +308 -0
  10. r2flow/core/files.py +349 -0
  11. r2flow/core/http_queue.py +416 -0
  12. r2flow/core/logging.py +173 -0
  13. r2flow/core/queue.py +581 -0
  14. r2flow/core/redact.py +67 -0
  15. r2flow/core/registry.py +86 -0
  16. r2flow/core/retry.py +115 -0
  17. r2flow/core/schema.py +92 -0
  18. r2flow/core/selectors.py +113 -0
  19. r2flow/core/tool.py +119 -0
  20. r2flow/core/transactions.py +630 -0
  21. r2flow/facade.py +908 -0
  22. r2flow/flow.py +1188 -0
  23. r2flow/pack.py +916 -0
  24. r2flow/py.typed +0 -0
  25. r2flow/run_flow.py +468 -0
  26. r2flow/trace.py +143 -0
  27. r2flow/windows/__init__.py +0 -0
  28. r2flow/windows/element.py +109 -0
  29. r2flow/windows/selector.py +373 -0
  30. r2flow/windows/selector_rank.py +286 -0
  31. r2flow/windows/tools/__init__.py +90 -0
  32. r2flow/windows/tools/_resolve.py +117 -0
  33. r2flow/windows/tools/click.py +152 -0
  34. r2flow/windows/tools/clipboard.py +82 -0
  35. r2flow/windows/tools/control_action.py +113 -0
  36. r2flow/windows/tools/delay.py +55 -0
  37. r2flow/windows/tools/drag.py +82 -0
  38. r2flow/windows/tools/exists.py +59 -0
  39. r2flow/windows/tools/get_element.py +59 -0
  40. r2flow/windows/tools/get_table.py +156 -0
  41. r2flow/windows/tools/get_text.py +76 -0
  42. r2flow/windows/tools/highlight.py +156 -0
  43. r2flow/windows/tools/hover.py +62 -0
  44. r2flow/windows/tools/image.py +278 -0
  45. r2flow/windows/tools/input_text.py +92 -0
  46. r2flow/windows/tools/keyboard.py +372 -0
  47. r2flow/windows/tools/list_elements.py +107 -0
  48. r2flow/windows/tools/ocr.py +235 -0
  49. r2flow/windows/tools/process.py +485 -0
  50. r2flow/windows/tools/screenshot.py +234 -0
  51. r2flow/windows/tools/scroll.py +100 -0
  52. r2flow/windows/tools/select.py +67 -0
  53. r2flow/windows/tools/selector_capture/__init__.py +43 -0
  54. r2flow/windows/tools/selector_capture/__main__.py +5 -0
  55. r2flow/windows/tools/selector_capture/api.py +148 -0
  56. r2flow/windows/tools/selector_capture/capture.py +387 -0
  57. r2flow/windows/tools/selector_capture/cli.py +152 -0
  58. r2flow/windows/tools/selector_capture/emit.py +160 -0
  59. r2flow/windows/tools/selector_capture/flowgen.py +84 -0
  60. r2flow/windows/tools/selector_capture/generate.py +351 -0
  61. r2flow/windows/tools/selector_capture/record.py +211 -0
  62. r2flow/windows/tools/selector_capture/recorder.py +886 -0
  63. r2flow/windows/tools/set_text.py +116 -0
  64. r2flow/windows/tools/wait.py +149 -0
  65. r2flow/windows/tools/window.py +189 -0
  66. r2flow_engine-0.8.11.dist-info/METADATA +563 -0
  67. r2flow_engine-0.8.11.dist-info/RECORD +70 -0
  68. r2flow_engine-0.8.11.dist-info/WHEEL +4 -0
  69. r2flow_engine-0.8.11.dist-info/entry_points.txt +3 -0
  70. r2flow_engine-0.8.11.dist-info/licenses/LICENSE +21 -0
r2flow/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """R2Flow — Free Python RPA engine for creating automation bots."""
2
+
3
+ from r2flow.core.config import Config, load_config
4
+ from r2flow.core.errors import (
5
+ BusinessError,
6
+ Cancelled,
7
+ ConfigError,
8
+ ElementNotFound,
9
+ InfrastructureError,
10
+ InvalidInput,
11
+ PlatformError,
12
+ ToolError,
13
+ )
14
+ from r2flow.core.http_queue import HttpQueue, HttpQueueError
15
+ from r2flow.core.logging import JsonlEventLogger
16
+ from r2flow.core.queue import (
17
+ ClaimedItem,
18
+ InMemoryQueue,
19
+ LeaseRenewable,
20
+ Queue,
21
+ QueueInfo,
22
+ QueueItem,
23
+ SqliteQueue,
24
+ )
25
+ from r2flow.core.retry import RetryTool
26
+ from r2flow.core.schema import validate_against_schema
27
+ from r2flow.core.selectors import SelectorStore
28
+ from r2flow.core.tool import AbstractTool, Tool, tool
29
+ from r2flow.core.transactions import (
30
+ ItemOutcome,
31
+ TransactionContextMiddleware,
32
+ TransactionReport,
33
+ current_transaction_id,
34
+ run_transactions,
35
+ run_transactions_async,
36
+ )
37
+ from r2flow.facade import (
38
+ ClickResult,
39
+ InputTextResult,
40
+ ProcessHandle,
41
+ SetTextResult,
42
+ R2Flow,
43
+ )
44
+ from r2flow.flow import FlowError, FlowRunner
45
+ from r2flow.pack import (
46
+ PACK_MANIFEST,
47
+ TEMPLATE_FILE,
48
+ build_pack,
49
+ fetch_pack,
50
+ load_manifest,
51
+ load_template,
52
+ publish_pack,
53
+ validate_template,
54
+ verify_pack,
55
+ zip_pack,
56
+ )
57
+
58
+ try:
59
+ from importlib.metadata import PackageNotFoundError
60
+ from importlib.metadata import version as _pkg_version
61
+
62
+ __version__ = _pkg_version("r2flow-engine")
63
+ except PackageNotFoundError: # pragma: no cover — running from an uninstalled tree
64
+ __version__ = "0.8.11"
65
+
66
+ __all__ = [
67
+ "AbstractTool",
68
+ "BusinessError",
69
+ "Cancelled",
70
+ "ClaimedItem",
71
+ "ClickResult",
72
+ "Config",
73
+ "ConfigError",
74
+ "ElementNotFound",
75
+ "FlowError",
76
+ "FlowRunner",
77
+ "HttpQueue",
78
+ "HttpQueueError",
79
+ "InMemoryQueue",
80
+ "InputTextResult",
81
+ "InvalidInput",
82
+ "ItemOutcome",
83
+ "JsonlEventLogger",
84
+ "LeaseRenewable",
85
+ "PACK_MANIFEST",
86
+ "PlatformError",
87
+ "ProcessHandle",
88
+ "Queue",
89
+ "QueueInfo",
90
+ "QueueItem",
91
+ "RetryTool",
92
+ "SelectorStore",
93
+ "SetTextResult",
94
+ "R2Flow",
95
+ "SqliteQueue",
96
+ "InfrastructureError",
97
+ "TEMPLATE_FILE",
98
+ "Tool",
99
+ "ToolError",
100
+ "TransactionContextMiddleware",
101
+ "TransactionReport",
102
+ "build_pack",
103
+ "current_transaction_id",
104
+ "fetch_pack",
105
+ "load_config",
106
+ "load_manifest",
107
+ "load_template",
108
+ "publish_pack",
109
+ "run_transactions",
110
+ "run_transactions_async",
111
+ "tool",
112
+ "validate_against_schema",
113
+ "validate_template",
114
+ "verify_pack",
115
+ "zip_pack",
116
+ ]
@@ -0,0 +1,28 @@
1
+ """Core traits, types, and error definitions."""
2
+
3
+ from r2flow.core.errors import (
4
+ BusinessError,
5
+ Cancelled,
6
+ ConfigError,
7
+ ElementNotFound,
8
+ InfrastructureError,
9
+ InvalidInput,
10
+ PlatformError,
11
+ ToolError,
12
+ )
13
+ from r2flow.core.registry import ToolRegistry
14
+ from r2flow.core.tool import AbstractTool, Tool
15
+
16
+ __all__ = [
17
+ "AbstractTool",
18
+ "BusinessError",
19
+ "Cancelled",
20
+ "ConfigError",
21
+ "ElementNotFound",
22
+ "InfrastructureError",
23
+ "InvalidInput",
24
+ "PlatformError",
25
+ "Tool",
26
+ "ToolError",
27
+ "ToolRegistry",
28
+ ]
@@ -0,0 +1,84 @@
1
+ """Asset tools — fetch secrets at run time without storing them in the flow.
2
+
3
+ The agent injects assets as environment variables (``R2FLOW_ASSET_*`` for a
4
+ text asset, ``R2FLOW_ASSET_<NAME>_<FIELD>`` for credential fields); these
5
+ tools read them back.
6
+
7
+ Both tools set :attr:`AbstractTool.produces_secrets`, so the flow runner
8
+ redacts the returned values from logs/errors and keeps the variables they
9
+ land in out of the run-result snapshot sent back to the orchestrator.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from r2flow.core.assets import asset_provider_from_env
17
+ from r2flow.core.errors import InvalidInput
18
+ from r2flow.core.tool import AbstractTool
19
+
20
+
21
+ def _asset_name(config: dict[str, Any]) -> str:
22
+ name = config.get("name")
23
+ if not isinstance(name, str) or not name.strip():
24
+ raise InvalidInput(
25
+ "Missing required parameter: name (asset name)",
26
+ param="name",
27
+ input_value=name,
28
+ )
29
+ return name.strip()
30
+
31
+
32
+ class AssetGetTool(AbstractTool):
33
+ """Fetch a text asset (secret) by name."""
34
+
35
+ produces_secrets = True
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ return "asset.get"
40
+
41
+ @property
42
+ def description(self) -> str:
43
+ return "Fetches a text asset (secret) from the agent environment by name"
44
+
45
+ def schema(self) -> dict[str, Any]:
46
+ return {
47
+ "type": "object",
48
+ "properties": {
49
+ "name": {
50
+ "type": "string",
51
+ "description": "Asset name (e.g. crm-url) or a credential field (crm.login)",
52
+ },
53
+ },
54
+ "required": ["name"],
55
+ }
56
+
57
+ async def execute(self, config: dict[str, Any]) -> Any:
58
+ return asset_provider_from_env().get(_asset_name(config))
59
+
60
+
61
+ class AssetCredentialTool(AbstractTool):
62
+ """Fetch a credential asset's fields (login / password / …)."""
63
+
64
+ produces_secrets = True
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ return "asset.credential"
69
+
70
+ @property
71
+ def description(self) -> str:
72
+ return "Fetches a credential asset's fields (login, password, …) by name"
73
+
74
+ def schema(self) -> dict[str, Any]:
75
+ return {
76
+ "type": "object",
77
+ "properties": {
78
+ "name": {"type": "string", "description": "Credential asset name (e.g. crm)"},
79
+ },
80
+ "required": ["name"],
81
+ }
82
+
83
+ async def execute(self, config: dict[str, Any]) -> Any:
84
+ return asset_provider_from_env().credential(_asset_name(config))
r2flow/core/assets.py ADDED
@@ -0,0 +1,258 @@
1
+ """Asset provider — runtime secrets for bots.
2
+
3
+ Config files deliberately store only *references* to secrets (asset
4
+ names, GUIDs) — never values. Values are fetched at runtime through an
5
+ :class:`AssetProvider`, called from bot code (``bot.asset("db_password")``)
6
+ instead of flowing through tool configs/results — so they can never
7
+ leak into the JSONL audit log or flow files.
8
+
9
+ The default provider reads environment variables (``R2FLOW_ASSET_*``);
10
+ wire an orchestrator-backed implementation behind the same protocol.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from typing import Any, Protocol, runtime_checkable
22
+
23
+ from r2flow.core.errors import InvalidInput, PlatformError
24
+
25
+ DEFAULT_ASSET_PREFIX = "R2FLOW_ASSET_"
26
+
27
+ _MAX_ASSET_BYTES = 1_000_000
28
+
29
+
30
+ def _check_no_redirect(response: Any, url: str) -> None:
31
+ """Fail when urlopen followed a redirect away from *url*."""
32
+ geturl = getattr(response, "geturl", None)
33
+ if callable(geturl):
34
+ try:
35
+ final = str(geturl())
36
+ except Exception:
37
+ return
38
+ if final and final != url:
39
+ raise PlatformError(f"asset fetch refused redirect to {final!r}", source=None)
40
+
41
+
42
+ def _normalize(name: str) -> str:
43
+ return re.sub(r"[^A-Z0-9]+", "_", name.strip().upper())
44
+
45
+
46
+ @runtime_checkable
47
+ class AssetProvider(Protocol):
48
+ """Secret source contract."""
49
+
50
+ def get(self, name: str) -> str:
51
+ """Return the secret value for *name*; raise if unknown."""
52
+ ...
53
+
54
+ def credential(self, name: str) -> dict[str, str]:
55
+ """Return a credential's fields (or ``{"value": ...}`` for text)."""
56
+ ...
57
+
58
+
59
+ class EnvAssetProvider:
60
+ """Asset provider over ``R2FLOW_ASSET_*`` environment variables.
61
+
62
+ ``bot.asset("db.password")`` reads ``R2FLOW_ASSET_DB_PASSWORD``:
63
+ the name is upper-cased and every non-alphanumeric run becomes a
64
+ single underscore.
65
+ """
66
+
67
+ def __init__(self, prefix: str = DEFAULT_ASSET_PREFIX) -> None:
68
+ if not isinstance(prefix, str) or not prefix:
69
+ raise InvalidInput(
70
+ "prefix must be a non-empty string", param="prefix", input_value=prefix
71
+ )
72
+ self._prefix = prefix
73
+ self._cache: dict[str, str] = {}
74
+
75
+ @property
76
+ def prefix(self) -> str:
77
+ """Env var prefix of this provider."""
78
+ return self._prefix
79
+
80
+ def get(self, name: str) -> str:
81
+ """Look up the env var for *name*.
82
+
83
+ Results are cached for the lifetime of the provider, so a bot
84
+ that fetches the same asset repeatedly pays the environment
85
+ lookup once.
86
+
87
+ Raises:
88
+ InvalidInput: If *name* is empty or the env var is not set.
89
+ """
90
+ if not isinstance(name, str) or not name.strip():
91
+ raise InvalidInput(
92
+ "asset name must be a non-empty string", param="name", input_value=name
93
+ )
94
+ key = self._prefix + re.sub(r"[^A-Z0-9]+", "_", name.strip().upper())
95
+ cached = self._cache.get(key)
96
+ if cached is not None:
97
+ return cached
98
+ value = os.environ.get(key)
99
+ if value is None:
100
+ raise InvalidInput(
101
+ f"Unknown asset {name!r}: environment variable {key!r} is not set",
102
+ param="name",
103
+ input_value=name,
104
+ )
105
+ self._cache[key] = value
106
+ return value
107
+
108
+ def credential(self, name: str) -> dict[str, str]:
109
+ """Credential fields for *name*, or ``{"value": ...}`` for text."""
110
+ fields = credential_fields(name, self._prefix)
111
+ if fields:
112
+ return fields
113
+ return {"value": self.get(name)}
114
+
115
+
116
+ def credential_fields(name: str, prefix: str = DEFAULT_ASSET_PREFIX) -> dict[str, str]:
117
+ """All ``<prefix><NAME>_<FIELD>`` env vars for a credential *name*.
118
+
119
+ The agent injects a credential as one env var per field, e.g. asset
120
+ ``crm`` with ``login``/``password`` becomes ``R2FLOW_ASSET_CRM_LOGIN``
121
+ and ``R2FLOW_ASSET_CRM_PASSWORD``. Keys are returned lower-cased
122
+ (``{"login": ..., "password": ...}``); an empty result means either a
123
+ text asset or an unknown name.
124
+ """
125
+ base = prefix + _normalize(name)
126
+ marker = base + "_"
127
+ fields: dict[str, str] = {}
128
+ for key, value in os.environ.items():
129
+ if key.startswith(marker) and value != "":
130
+ field = key[len(marker) :].lower()
131
+ if field:
132
+ fields[field] = value
133
+ return fields
134
+
135
+
136
+ class HttpAssetProvider:
137
+ """Fetch assets on demand from a r2flow-cloud orchestrator.
138
+
139
+ Resolves an asset by **id/GUID or name** (and ``name.field`` for a
140
+ credential field), so the engine no longer needs the whole vault
141
+ injected into the environment. Only used when the agent configures
142
+ ``R2FLOW_ORCHESTRATOR_URL`` + ``R2FLOW_AGENT_ID`` + a token; otherwise
143
+ :class:`EnvAssetProvider` is used.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ base_url: str,
149
+ *,
150
+ agent_id: str,
151
+ token: str,
152
+ process_id: str | None = None,
153
+ timeout: float = 15.0,
154
+ allow_insecure: bool = False,
155
+ ) -> None:
156
+ parsed = urllib.parse.urlparse(base_url)
157
+ if parsed.scheme not in ("http", "https"):
158
+ raise InvalidInput(
159
+ "orchestrator URL must be http(s)", param="base_url", input_value=base_url
160
+ )
161
+ host = parsed.hostname or ""
162
+ loopback = host in ("127.0.0.1", "::1", "localhost")
163
+ if parsed.scheme == "http" and not loopback and not allow_insecure:
164
+ raise InvalidInput(
165
+ "plain http to a non-loopback orchestrator is refused "
166
+ "(assets would travel in cleartext)",
167
+ param="base_url",
168
+ input_value=base_url,
169
+ )
170
+ self._base = base_url.rstrip("/")
171
+ self._agent_id = str(agent_id)
172
+ self._token = token
173
+ self._process_id = process_id
174
+ self._timeout = timeout
175
+ self._cache: dict[str, dict[str, Any]] = {}
176
+
177
+ def _fetch(self, ref: str) -> dict[str, Any] | None:
178
+ """One asset by id or name; ``None`` on 404 (so ``name.field`` can split)."""
179
+ if ref in self._cache:
180
+ return self._cache[ref]
181
+ url = (
182
+ f"{self._base}/api/agents/{urllib.parse.quote(self._agent_id, safe='')}"
183
+ f"/assets/{urllib.parse.quote(ref, safe='')}"
184
+ )
185
+ if self._process_id:
186
+ url += f"?process_id={urllib.parse.quote(self._process_id, safe='')}"
187
+ request = urllib.request.Request(url)
188
+ # Unredirected: never replayed to a redirect target.
189
+ request.add_unredirected_header("Authorization", f"Bearer {self._token}")
190
+ try:
191
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
192
+ _check_no_redirect(response, url)
193
+ try:
194
+ raw = response.read(_MAX_ASSET_BYTES + 1)
195
+ except TypeError:
196
+ raw = response.read()
197
+ if isinstance(raw, str):
198
+ raw = raw.encode("utf-8")
199
+ if len(raw) > _MAX_ASSET_BYTES:
200
+ raise PlatformError("asset response too large", source=None)
201
+ data: Any = json.loads(bytes(raw).decode("utf-8"))
202
+ except urllib.error.HTTPError as exc:
203
+ exc.close()
204
+ if exc.code == 404:
205
+ return None
206
+ raise PlatformError(f"asset fetch failed with HTTP {exc.code}", source=exc) from exc
207
+ except (urllib.error.URLError, OSError, ValueError) as exc:
208
+ raise PlatformError(f"asset fetch failed: {exc}", source=exc) from exc
209
+ if isinstance(data, dict):
210
+ self._cache[ref] = data
211
+ return data
212
+ return None
213
+
214
+ def get(self, name: str) -> str:
215
+ data = self._fetch(name)
216
+ if data is not None:
217
+ if str(data.get("kind")) == "credential":
218
+ raise InvalidInput(
219
+ f'asset {name!r} is a credential — use "name.field" or asset.credential',
220
+ param="name",
221
+ input_value=name,
222
+ )
223
+ return str(data.get("value") or "")
224
+ if "." in name:
225
+ base, _, field = name.rpartition(".")
226
+ parent = self._fetch(base)
227
+ fields = (parent or {}).get("fields")
228
+ if isinstance(fields, dict) and field in fields:
229
+ return str(fields[field])
230
+ raise InvalidInput(f"Unknown asset {name!r}", param="name", input_value=name)
231
+
232
+ def credential(self, name: str) -> dict[str, str]:
233
+ data = self._fetch(name)
234
+ if data is None and "." in name:
235
+ data = self._fetch(name.rpartition(".")[0])
236
+ if data is None:
237
+ raise InvalidInput(f"Unknown asset {name!r}", param="name", input_value=name)
238
+ if str(data.get("kind")) == "credential":
239
+ fields = data.get("fields") or {}
240
+ if isinstance(fields, dict):
241
+ return {str(key): str(value) for key, value in fields.items()}
242
+ return {}
243
+ return {"value": str(data.get("value") or "")}
244
+
245
+
246
+ def asset_provider_from_env() -> AssetProvider:
247
+ """Orchestrator-backed provider when configured, else env vars.
248
+
249
+ The agent sets ``R2FLOW_ORCHESTRATOR_URL`` / ``R2FLOW_AGENT_ID`` /
250
+ ``R2FLOW_AGENT_TOKEN``; self-host runs use ``R2FLOW_ASSET_*`` directly.
251
+ """
252
+ url = os.environ.get("R2FLOW_ORCHESTRATOR_URL")
253
+ agent_id = os.environ.get("R2FLOW_AGENT_ID")
254
+ token = os.environ.get("R2FLOW_AGENT_TOKEN") or os.environ.get("R2FLOW_API_TOKEN")
255
+ process_id = os.environ.get("R2FLOW_PROCESS_ID") or None
256
+ if url and agent_id and token:
257
+ return HttpAssetProvider(url, agent_id=agent_id, token=token, process_id=process_id)
258
+ return EnvAssetProvider()
@@ -0,0 +1,185 @@
1
+ """Offload blocking calls to a worker thread with a hard timeout.
2
+
3
+ UIA/COM calls can hang indefinitely (dead dialogs, stalled COM apartments).
4
+ Every blocking offload in the windows tools goes through :func:`run_blocking`,
5
+ which bounds the wait: default 30 s, tunable via the ``R2FLOW_BLOCKING_TIMEOUT``
6
+ environment variable. A timeout raises :class:`PlatformError` instead of
7
+ leaving the bot blocked forever.
8
+
9
+ COM threading model
10
+ -------------------
11
+
12
+ UIA elements are apartment-bound COM objects: using a pointer created in
13
+ one thread's apartment from another thread (or after that apartment is
14
+ torn down) fails with ``E_FAIL`` or crashes with an access violation.
15
+ ``uiautomation``'s official guidance is equally strict — "you can't use
16
+ a Control created in a different thread".
17
+
18
+ Therefore all blocking UIA work runs on a single, long-lived worker
19
+ thread that owns one COM apartment (initialized once via
20
+ ``CoInitializeEx``). UIA elements are created and consumed on that same
21
+ thread, so they never cross apartments. The trade-off is serialization —
22
+ irrelevant in practice because bot and flow code is sequential.
23
+
24
+ If a call hangs past *timeout*, the thread is abandoned in place (a hung
25
+ COM call is not interruptible) and the executor is replaced before the
26
+ next call, so one dead dialog cannot poison every subsequent tool call.
27
+ Abandoned threads are joined at interpreter exit.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import functools
34
+ import os
35
+ import threading
36
+ from collections.abc import Callable
37
+ from concurrent.futures import ThreadPoolExecutor
38
+ from typing import Any, TypeVar
39
+
40
+ from r2flow.core.errors import PlatformError
41
+
42
+ _T = TypeVar("_T")
43
+
44
+ _executor_lock = threading.Lock()
45
+ _executor: ThreadPoolExecutor | None = None
46
+ _active_calls = 0
47
+
48
+
49
+ def _default_timeout() -> float:
50
+ raw = os.environ.get("R2FLOW_BLOCKING_TIMEOUT", "30")
51
+ try:
52
+ value = float(raw)
53
+ except ValueError:
54
+ return 30.0
55
+ return value if value > 0 else 30.0
56
+
57
+
58
+ def _com_thread_init() -> None:
59
+ """Initialize this worker thread's COM apartment (once per thread)."""
60
+ try:
61
+ import comtypes
62
+ except Exception: # pragma: no cover — non-Windows
63
+ return
64
+ comtypes.CoInitializeEx()
65
+
66
+
67
+ def _run_with_com(fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
68
+ """Run *fn* with a COM apartment initialized in the current thread.
69
+
70
+ For callers outside the shared worker thread (e.g. the synchronous
71
+ CLI capture path on the main thread). A no-op when ``comtypes`` is
72
+ unavailable (non-Windows hosts).
73
+ """
74
+ try:
75
+ import comtypes
76
+ except Exception: # pragma: no cover — non-Windows
77
+ return fn(*args, **kwargs)
78
+ comtypes.CoInitializeEx()
79
+ try:
80
+ return fn(*args, **kwargs)
81
+ finally:
82
+ comtypes.CoUninitialize()
83
+
84
+
85
+ def _get_executor() -> ThreadPoolExecutor:
86
+ """The shared single-thread executor (created on first use)."""
87
+ global _executor
88
+ with _executor_lock:
89
+ if _executor is None:
90
+ _executor = ThreadPoolExecutor(
91
+ max_workers=1,
92
+ thread_name_prefix="r2flow-blocking",
93
+ initializer=_com_thread_init,
94
+ )
95
+ return _executor
96
+
97
+
98
+ def _abandon_executor() -> bool:
99
+ """Drop the executor after a hung call (a fresh one is made lazily).
100
+
101
+ Only safe when this call is the sole user of the executor: abandoning
102
+ it shuts down pending/running futures, so concurrent calls would see
103
+ ``CancelledError`` or ``RuntimeError``. Returns True when the executor
104
+ was actually abandoned.
105
+ """
106
+ global _executor
107
+ with _executor_lock:
108
+ if _executor is None or _active_calls > 1:
109
+ return False
110
+ executor, _executor = _executor, None
111
+ executor.shutdown(wait=False)
112
+ return True
113
+
114
+
115
+ def _enter_call() -> None:
116
+ global _active_calls
117
+ with _executor_lock:
118
+ _active_calls += 1
119
+
120
+
121
+ def _exit_call() -> None:
122
+ global _active_calls
123
+ with _executor_lock:
124
+ _active_calls -= 1
125
+
126
+
127
+ async def run_on_uia_thread(fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T:
128
+ """Run ``fn(*args, **kwargs)`` on the shared UIA thread — no timeout.
129
+
130
+ For interactive flows (e.g. waiting for a human to press CTRL during
131
+ dev capture) that legitimately take longer than any sane timeout.
132
+ Uses the same apartment-bound thread as :func:`run_blocking`, so
133
+ everything it touches shares the process-wide COM apartment.
134
+ """
135
+ loop = asyncio.get_running_loop()
136
+ _enter_call()
137
+ try:
138
+ future = loop.run_in_executor(_get_executor(), functools.partial(fn, *args, **kwargs))
139
+ return await future
140
+ finally:
141
+ _exit_call()
142
+
143
+
144
+ async def run_blocking(
145
+ fn: Callable[..., Any],
146
+ /,
147
+ *args: Any,
148
+ timeout: float | None = None,
149
+ **kwargs: Any,
150
+ ) -> Any:
151
+ """Run ``fn(*args, **kwargs)`` on the shared UIA worker thread.
152
+
153
+ The thread owns a COM apartment for the whole process lifetime, so
154
+ UIA elements created by one call stay valid for the next. After a
155
+ timeout the thread is abandoned (hung calls are not interruptible)
156
+ and the next call gets a fresh thread — but only when no other call
157
+ is in flight, so a timeout can never cancel a concurrent call.
158
+
159
+ Raises:
160
+ PlatformError: If the call does not finish within *timeout* seconds
161
+ (default: ``R2FLOW_BLOCKING_TIMEOUT`` env var, else 30 s).
162
+ """
163
+ limit = timeout if timeout is not None else _default_timeout()
164
+ loop = asyncio.get_running_loop()
165
+ _enter_call()
166
+ try:
167
+ executor = _get_executor()
168
+ try:
169
+ future = loop.run_in_executor(executor, functools.partial(fn, *args, **kwargs))
170
+ except RuntimeError:
171
+ # The executor was abandoned between _get_executor and submit
172
+ # (only possible if a concurrent call timed out). Retry once
173
+ # against the fresh executor.
174
+ executor = _get_executor()
175
+ future = loop.run_in_executor(executor, functools.partial(fn, *args, **kwargs))
176
+ return await asyncio.wait_for(future, limit)
177
+ except TimeoutError as exc:
178
+ _abandon_executor()
179
+ name = getattr(fn, "__qualname__", None) or repr(fn)
180
+ raise PlatformError(
181
+ f"blocking call {name} timed out after {limit:g}s "
182
+ "(tune R2FLOW_BLOCKING_TIMEOUT if this is expected)",
183
+ ) from exc
184
+ finally:
185
+ _exit_call()