smithy-engine 0.6.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.
Files changed (51) hide show
  1. smithy/__init__.py +75 -0
  2. smithy/core/__init__.py +28 -0
  3. smithy/core/blocking.py +53 -0
  4. smithy/core/config.py +171 -0
  5. smithy/core/errors.py +105 -0
  6. smithy/core/events.py +67 -0
  7. smithy/core/http_queue.py +298 -0
  8. smithy/core/logging.py +73 -0
  9. smithy/core/queue.py +503 -0
  10. smithy/core/registry.py +66 -0
  11. smithy/core/retry.py +79 -0
  12. smithy/core/schema.py +92 -0
  13. smithy/core/tool.py +114 -0
  14. smithy/core/transactions.py +523 -0
  15. smithy/facade.py +652 -0
  16. smithy/py.typed +0 -0
  17. smithy/windows/__init__.py +0 -0
  18. smithy/windows/element.py +84 -0
  19. smithy/windows/selector.py +320 -0
  20. smithy/windows/selector_rank.py +280 -0
  21. smithy/windows/tools/__init__.py +72 -0
  22. smithy/windows/tools/_resolve.py +139 -0
  23. smithy/windows/tools/click.py +147 -0
  24. smithy/windows/tools/clipboard.py +82 -0
  25. smithy/windows/tools/delay.py +55 -0
  26. smithy/windows/tools/drag.py +82 -0
  27. smithy/windows/tools/exists.py +59 -0
  28. smithy/windows/tools/get_element.py +59 -0
  29. smithy/windows/tools/get_text.py +76 -0
  30. smithy/windows/tools/highlight.py +122 -0
  31. smithy/windows/tools/hover.py +62 -0
  32. smithy/windows/tools/input_text.py +77 -0
  33. smithy/windows/tools/keyboard.py +270 -0
  34. smithy/windows/tools/list_elements.py +92 -0
  35. smithy/windows/tools/process.py +239 -0
  36. smithy/windows/tools/screenshot.py +201 -0
  37. smithy/windows/tools/scroll.py +100 -0
  38. smithy/windows/tools/select.py +67 -0
  39. smithy/windows/tools/selector_capture/__init__.py +28 -0
  40. smithy/windows/tools/selector_capture/__main__.py +5 -0
  41. smithy/windows/tools/selector_capture/capture.py +387 -0
  42. smithy/windows/tools/selector_capture/cli.py +149 -0
  43. smithy/windows/tools/selector_capture/emit.py +151 -0
  44. smithy/windows/tools/selector_capture/generate.py +274 -0
  45. smithy/windows/tools/selector_capture/recorder.py +739 -0
  46. smithy/windows/tools/set_text.py +110 -0
  47. smithy/windows/tools/wait.py +150 -0
  48. smithy/windows/tools/window.py +147 -0
  49. smithy_engine-0.6.0.dist-info/METADATA +372 -0
  50. smithy_engine-0.6.0.dist-info/RECORD +51 -0
  51. smithy_engine-0.6.0.dist-info/WHEEL +4 -0
smithy/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Smithy — Free Python RPA engine for creating automation bots."""
2
+
3
+ from smithy.core.config import Config, load_config
4
+ from smithy.core.errors import (
5
+ BusinessError,
6
+ Cancelled,
7
+ ConfigError,
8
+ ElementNotFound,
9
+ InfrastructureError,
10
+ InvalidInput,
11
+ PlatformError,
12
+ ToolError,
13
+ )
14
+ from smithy.core.http_queue import HttpQueue, HttpQueueError
15
+ from smithy.core.logging import JsonlEventLogger
16
+ from smithy.core.queue import (
17
+ ClaimedItem,
18
+ InMemoryQueue,
19
+ LeaseRenewable,
20
+ Queue,
21
+ QueueInfo,
22
+ QueueItem,
23
+ SqliteQueue,
24
+ )
25
+ from smithy.core.retry import RetryTool
26
+ from smithy.core.schema import validate_against_schema
27
+ from smithy.core.tool import AbstractTool, Tool, tool
28
+ from smithy.core.transactions import (
29
+ ItemOutcome,
30
+ TransactionContextMiddleware,
31
+ TransactionReport,
32
+ current_transaction_id,
33
+ run_transactions,
34
+ run_transactions_async,
35
+ )
36
+ from smithy.facade import ClickResult, ProcessHandle, Smithy
37
+
38
+ __version__ = "0.6.0"
39
+
40
+ __all__ = [
41
+ "AbstractTool",
42
+ "BusinessError",
43
+ "Cancelled",
44
+ "ClaimedItem",
45
+ "ClickResult",
46
+ "Config",
47
+ "ConfigError",
48
+ "ElementNotFound",
49
+ "HttpQueue",
50
+ "HttpQueueError",
51
+ "InMemoryQueue",
52
+ "InvalidInput",
53
+ "ItemOutcome",
54
+ "JsonlEventLogger",
55
+ "LeaseRenewable",
56
+ "PlatformError",
57
+ "ProcessHandle",
58
+ "Queue",
59
+ "QueueInfo",
60
+ "QueueItem",
61
+ "RetryTool",
62
+ "Smithy",
63
+ "SqliteQueue",
64
+ "InfrastructureError",
65
+ "Tool",
66
+ "ToolError",
67
+ "TransactionContextMiddleware",
68
+ "TransactionReport",
69
+ "current_transaction_id",
70
+ "load_config",
71
+ "run_transactions",
72
+ "run_transactions_async",
73
+ "tool",
74
+ "validate_against_schema",
75
+ ]
@@ -0,0 +1,28 @@
1
+ """Core traits, types, and error definitions."""
2
+
3
+ from smithy.core.errors import (
4
+ BusinessError,
5
+ Cancelled,
6
+ ConfigError,
7
+ ElementNotFound,
8
+ InfrastructureError,
9
+ InvalidInput,
10
+ PlatformError,
11
+ ToolError,
12
+ )
13
+ from smithy.core.registry import ToolRegistry
14
+ from smithy.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,53 @@
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 ``SMITHY_BLOCKING_TIMEOUT``
6
+ environment variable. A timeout raises :class:`PlatformError` instead of
7
+ leaving the bot blocked forever on a starved thread pool.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import functools
14
+ import os
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from smithy.core.errors import PlatformError
19
+
20
+
21
+ def _default_timeout() -> float:
22
+ raw = os.environ.get("SMITHY_BLOCKING_TIMEOUT", "30")
23
+ try:
24
+ value = float(raw)
25
+ except ValueError:
26
+ return 30.0
27
+ return value if value > 0 else 30.0
28
+
29
+
30
+ async def run_blocking(
31
+ fn: Callable[..., Any],
32
+ /,
33
+ *args: Any,
34
+ timeout: float | None = None,
35
+ **kwargs: Any,
36
+ ) -> Any:
37
+ """Run ``fn(*args, **kwargs)`` in the default executor with a timeout.
38
+
39
+ Raises:
40
+ PlatformError: If the call does not finish within *timeout* seconds
41
+ (default: ``SMITHY_BLOCKING_TIMEOUT`` env var, else 30 s).
42
+ """
43
+ limit = timeout if timeout is not None else _default_timeout()
44
+ loop = asyncio.get_running_loop()
45
+ future = loop.run_in_executor(None, functools.partial(fn, *args, **kwargs))
46
+ try:
47
+ return await asyncio.wait_for(future, limit)
48
+ except TimeoutError as exc:
49
+ name = getattr(fn, "__qualname__", None) or repr(fn)
50
+ raise PlatformError(
51
+ f"blocking call {name} timed out after {limit:g}s "
52
+ "(tune SMITHY_BLOCKING_TIMEOUT if this is expected)",
53
+ ) from exc
smithy/core/config.py ADDED
@@ -0,0 +1,171 @@
1
+ """TOML robot config — load once in Init, fail fast, frozen afterwards.
2
+
3
+ The file replaces the legacy two-column Excel sheet: same idea (one config
4
+ per robot, values differ per environment), but diffable in git, typed, and
5
+ validated up front. Secrets never live here — only *references* to
6
+ orchestrator assets (names, GUIDs); values are fetched at runtime.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from smithy.core.errors import ConfigError
17
+
18
+ _MISSING = object()
19
+
20
+
21
+ class Config:
22
+ """Immutable attribute-style view over a loaded TOML document.
23
+
24
+ Nested tables become nested :class:`Config`, lists become tuples —
25
+ nothing inside can be reassigned after loading. Access by attribute
26
+ (``config.paths.workdir``) or by item (``config[\"paths\"][\"workdir\"]``).
27
+ """
28
+
29
+ __slots__ = ("_data",)
30
+
31
+ def __init__(self, data: dict[str, Any]) -> None:
32
+ object.__setattr__(self, "_data", data)
33
+
34
+ def __setattr__(self, name: str, value: Any) -> None:
35
+ raise AttributeError(f"Config is frozen; cannot set {name!r}")
36
+
37
+ def __getattr__(self, name: str) -> Any:
38
+ try:
39
+ return self._data[name]
40
+ except KeyError:
41
+ raise AttributeError(f"Unknown config key: {name!r}") from None
42
+
43
+ def __getitem__(self, key: str) -> Any:
44
+ return self._data[key]
45
+
46
+ def __contains__(self, key: object) -> bool:
47
+ return key in self._data
48
+
49
+ def __repr__(self) -> str:
50
+ return f"Config({self._data!r})"
51
+
52
+ def to_dict(self) -> dict[str, Any]:
53
+ """Plain deep copy of the document (logging, debugging)."""
54
+ return {key: _thaw(value) for key, value in self._data.items()}
55
+
56
+
57
+ def _freeze(value: Any) -> Any:
58
+ if isinstance(value, dict):
59
+ return Config({key: _freeze(item) for key, item in value.items()})
60
+ if isinstance(value, list):
61
+ return tuple(_freeze(item) for item in value)
62
+ return value
63
+
64
+
65
+ def _thaw(value: Any) -> Any:
66
+ if isinstance(value, Config):
67
+ return value.to_dict()
68
+ if isinstance(value, tuple):
69
+ return [_thaw(item) for item in value]
70
+ return value
71
+
72
+
73
+ def _lookup(config: Config, dotted: str) -> Any:
74
+ current: Any = config
75
+ for part in dotted.split("."):
76
+ if isinstance(current, Config) and part in current:
77
+ current = current[part]
78
+ else:
79
+ return _MISSING
80
+ return current
81
+
82
+
83
+ def _parse_env_value(raw: str) -> Any:
84
+ """Interpret an env value with TOML scalar syntax; fall back to string.
85
+
86
+ ``"8080"`` becomes ``8080``, ``"true"`` becomes ``True``,
87
+ ``"C:\\temp"`` (not valid TOML) stays a plain string.
88
+ """
89
+ try:
90
+ return tomllib.loads(f"value = {raw}")["value"]
91
+ except ValueError:
92
+ return raw
93
+
94
+
95
+ def _deep_set(document: dict[str, Any], parts: list[str], value: Any) -> None:
96
+ """Set a nested key, creating intermediate tables as needed."""
97
+ current = document
98
+ for part in parts[:-1]:
99
+ child = current.get(part)
100
+ if not isinstance(child, dict):
101
+ child = {}
102
+ current[part] = child
103
+ current = child
104
+ current[parts[-1]] = value
105
+
106
+
107
+ def _apply_env_overlay(document: dict[str, Any], prefix: str) -> None:
108
+ """Overlay ``<prefix>*`` env vars onto the TOML document (in place).
109
+
110
+ ``SMITHY_ROBOT__QUEUE`` sets ``robot.queue``; double underscore nests,
111
+ single underscores stay literal (``SMITHY_ROBOT_NAME`` → ``robot_name``).
112
+ Values are typed with TOML scalar syntax (ints, bools, quoted strings).
113
+ """
114
+ for name, raw in os.environ.items():
115
+ if not name.startswith(prefix):
116
+ continue
117
+ rest = name[len(prefix) :].lower()
118
+ if not rest:
119
+ continue
120
+ _deep_set(document, rest.split("__"), _parse_env_value(raw))
121
+
122
+
123
+ def load_config(
124
+ path: str | Path,
125
+ *,
126
+ required: tuple[str, ...] | list[str] = (),
127
+ must_exist: tuple[str, ...] | list[str] = (),
128
+ env_prefix: str | None = "SMITHY_",
129
+ ) -> Config:
130
+ """Load *path* as TOML and validate it.
131
+
132
+ Raises one :class:`ConfigError` listing *every* problem at once —
133
+ the robot fails in Init, never mid-run. *required* are dotted keys
134
+ that must be present (``\"robot.queue\"``); *must_exist* are dotted
135
+ keys whose values must be existing filesystem paths.
136
+
137
+ When *env_prefix* is set (default ``\"SMITHY_\"``), matching env vars
138
+ override file values — per-environment tweaks without editing TOML.
139
+ ``SMITHY_ROBOT__QUEUE`` sets ``robot.queue`` (``__`` nests, values are
140
+ TOML-typed). Checks run *after* the overlay, so env can satisfy
141
+ *required*. Pass ``env_prefix=None`` to disable.
142
+ """
143
+ file = Path(path)
144
+ try:
145
+ raw = file.read_bytes()
146
+ except OSError as exc:
147
+ raise ConfigError(f"Cannot read config file: {file}", input_value=str(file)) from exc
148
+ try:
149
+ document = tomllib.loads(raw.decode("utf-8"))
150
+ except ValueError as exc:
151
+ raise ConfigError(f"Invalid TOML in {file}: {exc}", input_value=str(file)) from exc
152
+ if env_prefix:
153
+ _apply_env_overlay(document, env_prefix)
154
+ frozen = _freeze(document)
155
+ assert isinstance(frozen, Config)
156
+ problems: list[str] = []
157
+ for key in required:
158
+ if _lookup(frozen, key) is _MISSING:
159
+ problems.append(f"missing required key: {key!r}")
160
+ for key in must_exist:
161
+ value = _lookup(frozen, key)
162
+ if value is _MISSING:
163
+ problems.append(f"missing path key: {key!r}")
164
+ elif not isinstance(value, (str, Path)) or not Path(value).exists():
165
+ problems.append(f"path does not exist: {key!r} = {value!r}")
166
+ if problems:
167
+ raise ConfigError(
168
+ f"Invalid config {file}:\n" + "\n".join(f" - {item}" for item in problems),
169
+ input_value=str(file),
170
+ )
171
+ return frozen
smithy/core/errors.py ADDED
@@ -0,0 +1,105 @@
1
+ """Error types for tool execution and the agent framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ToolError(Exception):
9
+ """Structured error for tool execution.
10
+
11
+ Subclasses:
12
+ InvalidInput — invalid or missing input parameters.
13
+ ElementNotFound — UI element not found or inaccessible.
14
+ Cancelled — operation cancelled by user or authority.
15
+ PlatformError — platform or UIA error with underlying cause.
16
+ BusinessError — transaction data is invalid, retry is pointless.
17
+ InfrastructureError — infrastructure failure, retry may help.
18
+ """
19
+
20
+ def __init__(self, message: str) -> None:
21
+ super().__init__(message)
22
+
23
+
24
+ class InvalidInput(ToolError):
25
+ """Invalid or missing input parameters."""
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ param: str | None = None,
32
+ input_value: Any = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.param = param
36
+ self.input_value = input_value
37
+
38
+ def __repr__(self) -> str:
39
+ return (
40
+ f"{type(self).__name__}({str(self)!r}, "
41
+ f"param={self.param!r}, input_value={self.input_value!r})"
42
+ )
43
+
44
+
45
+ class ElementNotFound(ToolError):
46
+ """UI element not found or inaccessible."""
47
+
48
+ def __init__(
49
+ self,
50
+ message: str = "Element not found",
51
+ *,
52
+ selector: Any = None,
53
+ ) -> None:
54
+ super().__init__(message)
55
+ self.selector = selector
56
+
57
+ def __repr__(self) -> str:
58
+ return f"{type(self).__name__}({str(self)!r}, selector={self.selector!r})"
59
+
60
+
61
+ class Cancelled(ToolError):
62
+ """Operation cancelled by user or authority."""
63
+
64
+ def __init__(self) -> None:
65
+ super().__init__("Operation cancelled")
66
+
67
+
68
+ class PlatformError(ToolError):
69
+ """Platform or UIA error with underlying cause."""
70
+
71
+ def __init__(
72
+ self,
73
+ message: str,
74
+ *,
75
+ source: BaseException | None = None,
76
+ input_value: Any = None,
77
+ ) -> None:
78
+ super().__init__(message)
79
+ self.source = source
80
+ self.input_value = input_value
81
+
82
+
83
+ class BusinessError(ToolError):
84
+ """Transaction data is invalid — retrying the same payload is pointless.
85
+
86
+ Raised by ``process_fn`` inside the transaction runner to mark the
87
+ item as ``business_failed`` (terminal, no requeue).
88
+ """
89
+
90
+
91
+ class InfrastructureError(ToolError):
92
+ """Infrastructure failure — retrying may help.
93
+
94
+ Raised by ``process_fn`` (or produced by the runner from unexpected
95
+ exceptions) to mark the item as ``system_failed`` (requeued until
96
+ the queue's ``max_attempts`` budget is exhausted).
97
+ """
98
+
99
+
100
+ class ConfigError(InvalidInput):
101
+ """Robot config is missing, unreadable, or fails validation.
102
+
103
+ Raised once with every problem listed — the robot must not start
104
+ with a half-valid config.
105
+ """
smithy/core/events.py ADDED
@@ -0,0 +1,67 @@
1
+ """Middleware event system for tool observability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from typing import Any, Protocol, runtime_checkable
8
+
9
+
10
+ @dataclass
11
+ class ToolEvent:
12
+ """Event emitted after each tool execution.
13
+
14
+ Attributes:
15
+ tool_name: Fully-qualified tool name (e.g. ``"windows.click"``).
16
+ config: The config dict passed to the tool.
17
+ result: Tool return value (``None`` on error).
18
+ error: Exception if the tool raised, else ``None``.
19
+ duration_ms: Wall-clock execution time in milliseconds.
20
+ timestamp: UTC timestamp of event creation.
21
+ metadata: Arbitrary data — middleware can attach session IDs,
22
+ tracing info, etc. Merged from ``__meta__`` in tool results.
23
+ """
24
+
25
+ tool_name: str
26
+ config: dict[str, Any]
27
+ result: Any = None
28
+ error: Exception | None = None
29
+ duration_ms: float = 0.0
30
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @runtime_checkable
35
+ class Middleware(Protocol):
36
+ """Protocol for event middleware.
37
+
38
+ A middleware receives a :class:`ToolEvent`, may transform it, and
39
+ returns it for the next middleware. Return ``None`` to stop
40
+ propagation.
41
+ """
42
+
43
+ async def __call__(self, event: ToolEvent) -> ToolEvent | None: ...
44
+
45
+
46
+ class EventBus:
47
+ """Ordered middleware pipeline for tool events."""
48
+
49
+ def __init__(self) -> None:
50
+ self._middlewares: list[Middleware] = []
51
+
52
+ def add_middleware(self, middleware: Middleware) -> None:
53
+ """Append a middleware to the pipeline."""
54
+ self._middlewares.append(middleware)
55
+
56
+ async def emit(self, event: ToolEvent) -> ToolEvent | None:
57
+ """Run *event* through the middleware pipeline.
58
+
59
+ Returns the final (possibly transformed) event, or ``None`` if
60
+ a middleware stopped propagation.
61
+ """
62
+ current: ToolEvent | None = event
63
+ for mw in self._middlewares:
64
+ if current is None:
65
+ return None
66
+ current = await mw(current)
67
+ return current