flashid-auto 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.
Potentially problematic release.
This version of flashid-auto might be problematic. Click here for more details.
- flashid_auto/__init__.py +1 -0
- flashid_auto/automation/__init__.py +13 -0
- flashid_auto/automation/base.py +128 -0
- flashid_auto/automation/contracts.py +71 -0
- flashid_auto/automation/environment.py +39 -0
- flashid_auto/automation/plan.py +167 -0
- flashid_auto/automation/registry.py +109 -0
- flashid_auto/automation/runner.py +427 -0
- flashid_auto/automation/worker.py +107 -0
- flashid_auto/cli.py +146 -0
- flashid_auto/flashid/__init__.py +25 -0
- flashid_auto/flashid/__main__.py +51 -0
- flashid_auto/flashid/client.py +382 -0
- flashid_auto/flashid/config.py +73 -0
- flashid_auto/flashid/errors.py +74 -0
- flashid_auto/mcp/__init__.py +1 -0
- flashid_auto/mcp/__main__.py +57 -0
- flashid_auto/mcp/server.py +54 -0
- flashid_auto/mcp/service.py +150 -0
- flashid_auto/mcp/tools.py +175 -0
- flashid_auto/modules/__init__.py +1 -0
- flashid_auto/modules/_linkedin.py +29 -0
- flashid_auto/modules/duckduckgo.py +221 -0
- flashid_auto/modules/linkedin_people_search.py +567 -0
- flashid_auto/modules/linkedin_sales_navigator.py +912 -0
- flashid_auto/tocsv.py +109 -0
- flashid_auto-0.2.0.dist-info/METADATA +130 -0
- flashid_auto-0.2.0.dist-info/RECORD +32 -0
- flashid_auto-0.2.0.dist-info/WHEEL +5 -0
- flashid_auto-0.2.0.dist-info/entry_points.txt +5 -0
- flashid_auto-0.2.0.dist-info/licenses/LICENSE +21 -0
- flashid_auto-0.2.0.dist-info/top_level.txt +1 -0
flashid_auto/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FlashID automation building blocks."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared object-oriented module contracts and execution support."""
|
|
2
|
+
|
|
3
|
+
from .base import AutomationModule, ExecutionContext, ModuleExecutionError
|
|
4
|
+
from .registry import ModuleRegistry
|
|
5
|
+
from .runner import AutomationRunner
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AutomationModule",
|
|
9
|
+
"ExecutionContext",
|
|
10
|
+
"ModuleExecutionError",
|
|
11
|
+
"ModuleRegistry",
|
|
12
|
+
"AutomationRunner",
|
|
13
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Every concrete module implements this abstract class."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from jsonschema import Draft202012Validator
|
|
11
|
+
from playwright.async_api import Browser, Page
|
|
12
|
+
|
|
13
|
+
from .contracts import OUTPUT_SCHEMA, AutomationError, ModuleExecutionError, json_snapshot
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModuleArgumentParser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, message: str) -> None:
|
|
18
|
+
raise AutomationError("INVALID_PARAMS", message)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ExecutionContext:
|
|
23
|
+
run_id: str
|
|
24
|
+
step_id: str
|
|
25
|
+
running_browser_id: str
|
|
26
|
+
browser: Browser
|
|
27
|
+
_pages: list[Page] = field(default_factory=list, repr=False)
|
|
28
|
+
|
|
29
|
+
async def new_page(self) -> Page:
|
|
30
|
+
"""Use the attached default context and own a new tab; never replace user tabs."""
|
|
31
|
+
if not self.browser.contexts:
|
|
32
|
+
raise ModuleExecutionError(
|
|
33
|
+
"Attached browser has no existing context", module_code="NO_BROWSER_CONTEXT"
|
|
34
|
+
)
|
|
35
|
+
# A browser can create its target before Playwright returns the Page object.
|
|
36
|
+
# On cancellation, finish adopting that page so cleanup can still close it.
|
|
37
|
+
creation = asyncio.create_task(self.browser.contexts[0].new_page())
|
|
38
|
+
try:
|
|
39
|
+
page = await asyncio.shield(creation)
|
|
40
|
+
except asyncio.CancelledError:
|
|
41
|
+
page = await creation
|
|
42
|
+
self._pages.append(page)
|
|
43
|
+
raise
|
|
44
|
+
self._pages.append(page)
|
|
45
|
+
return page
|
|
46
|
+
|
|
47
|
+
async def cleanup(self) -> bool:
|
|
48
|
+
complete = True
|
|
49
|
+
for page in reversed(self._pages):
|
|
50
|
+
try:
|
|
51
|
+
if not page.is_closed():
|
|
52
|
+
await page.close()
|
|
53
|
+
except Exception:
|
|
54
|
+
complete = False
|
|
55
|
+
self._pages.clear()
|
|
56
|
+
return complete
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AutomationModule(ABC):
|
|
60
|
+
"""Class metadata, CLI declaration, parameter validation and async execution."""
|
|
61
|
+
|
|
62
|
+
module_id: ClassVar[str]
|
|
63
|
+
module_version: ClassVar[str] = "1.0.0"
|
|
64
|
+
params_schema: ClassVar[dict]
|
|
65
|
+
output_schema: ClassVar[dict] = OUTPUT_SCHEMA
|
|
66
|
+
page_policy: ClassVar[str] = "Create and clean up only module-owned tabs"
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def configure_parser(cls, parser: argparse.ArgumentParser) -> None:
|
|
71
|
+
"""Declare module flags. Omit defaults here; declare them in params_schema."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
async def run(self, context: ExecutionContext, params: dict[str, Any]) -> dict:
|
|
75
|
+
"""Return {data, records, artifacts}, containing JSON values only."""
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def target_count(cls, params: dict) -> int:
|
|
79
|
+
"""Estimate work for timeout budgeting; unknown-size operations count as one.
|
|
80
|
+
|
|
81
|
+
Modules with different target semantics can override this method.
|
|
82
|
+
"""
|
|
83
|
+
return params.get("result_count", 1)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def parse_args(cls, argv: list[str]) -> dict:
|
|
87
|
+
parser = ModuleArgumentParser(prog=cls.module_id, argument_default=argparse.SUPPRESS)
|
|
88
|
+
cls.configure_parser(parser)
|
|
89
|
+
return vars(parser.parse_args(argv))
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def validate_params(cls, params: dict, bound_keys: set[str] | None = None) -> dict:
|
|
93
|
+
params = json_snapshot(params, "INVALID_PARAMS")
|
|
94
|
+
if not isinstance(params, dict):
|
|
95
|
+
raise AutomationError("INVALID_PARAMS", "Module parameters must be a JSON object")
|
|
96
|
+
schema = deepcopy(cls.params_schema)
|
|
97
|
+
bound_keys = bound_keys or set()
|
|
98
|
+
properties = schema.get("properties", {})
|
|
99
|
+
if schema.get("additionalProperties") is False and not bound_keys <= properties.keys():
|
|
100
|
+
raise AutomationError("INVALID_PARAMS", "Binding targets an unknown module parameter")
|
|
101
|
+
schema["required"] = [key for key in schema.get("required", []) if key not in bound_keys]
|
|
102
|
+
for key, spec in properties.items():
|
|
103
|
+
if (
|
|
104
|
+
key not in params
|
|
105
|
+
and key not in bound_keys
|
|
106
|
+
and isinstance(spec, dict)
|
|
107
|
+
and "default" in spec
|
|
108
|
+
):
|
|
109
|
+
params[key] = deepcopy(spec["default"])
|
|
110
|
+
errors = list(Draft202012Validator(schema).iter_errors(params))
|
|
111
|
+
if errors:
|
|
112
|
+
raise AutomationError(
|
|
113
|
+
"INVALID_PARAMS",
|
|
114
|
+
f"Invalid parameters for module {cls.module_id}",
|
|
115
|
+
{"paths": [list(e.absolute_path) for e in errors]},
|
|
116
|
+
)
|
|
117
|
+
return json_snapshot(params, "INVALID_PARAMS")
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def validate_output(cls, output: dict) -> dict:
|
|
121
|
+
output = json_snapshot(output)
|
|
122
|
+
for schema in (OUTPUT_SCHEMA, cls.output_schema):
|
|
123
|
+
if not Draft202012Validator(schema).is_valid(output):
|
|
124
|
+
raise AutomationError("INVALID_OUTPUT", "Module output does not match its contract")
|
|
125
|
+
return output
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
__all__ = ["AutomationModule", "ExecutionContext", "ModuleExecutionError"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Strict JSON contracts and errors safe to expose to callers."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AutomationError(Exception):
|
|
8
|
+
def __init__(self, code: str, message: str, details: dict | None = None):
|
|
9
|
+
self.code = code
|
|
10
|
+
self.message = message
|
|
11
|
+
self.details = details or {}
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
|
|
14
|
+
def as_json(self) -> dict:
|
|
15
|
+
return {"code": self.code, "message": self.message, "details": self.details}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModuleExecutionError(AutomationError):
|
|
19
|
+
"""Modules supply a safe message and may preserve valid partial JSON output."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
module_code: str = "MODULE_FAILED",
|
|
26
|
+
partial_output: dict | None = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__("MODULE_FAILED", message, {"module_code": module_code})
|
|
29
|
+
self.partial_output = partial_output
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def json_snapshot(value: Any, code: str = "INVALID_OUTPUT") -> Any:
|
|
33
|
+
"""Validate JSON types and detach objects; reject NaN, tuples and non-string keys."""
|
|
34
|
+
|
|
35
|
+
def check(item):
|
|
36
|
+
if type(item) is dict:
|
|
37
|
+
for key, child in item.items():
|
|
38
|
+
if type(key) is not str:
|
|
39
|
+
raise TypeError
|
|
40
|
+
check(child)
|
|
41
|
+
elif type(item) is list:
|
|
42
|
+
for child in item:
|
|
43
|
+
check(child)
|
|
44
|
+
elif type(item) not in {str, int, float, bool, type(None)}:
|
|
45
|
+
raise TypeError
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
check(value)
|
|
49
|
+
return json.loads(json.dumps(value, ensure_ascii=False, allow_nan=False))
|
|
50
|
+
except (TypeError, ValueError, RecursionError, OverflowError):
|
|
51
|
+
raise AutomationError(code, "Value must contain only valid JSON types") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
OUTPUT_SCHEMA = {
|
|
55
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
56
|
+
"type": "object",
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
"required": ["data", "records", "artifacts"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"data": {"type": "object"},
|
|
61
|
+
"records": {"type": "array", "items": {"type": "object"}},
|
|
62
|
+
"artifacts": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"required": ["ref", "media_type"],
|
|
67
|
+
"properties": {"ref": {"type": "string"}, "media_type": {"type": "string"}},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Match a unique running browser name and use its returned CDP endpoint verbatim."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
from .contracts import AutomationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_environment(rows, profile: str) -> dict:
|
|
9
|
+
if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows):
|
|
10
|
+
raise AutomationError(
|
|
11
|
+
"FLASHID_API_ERROR", "Running-browser response must be an object array"
|
|
12
|
+
)
|
|
13
|
+
matches = [r for r in rows if r.get("name") == profile]
|
|
14
|
+
if not matches:
|
|
15
|
+
raise AutomationError("ENV_NOT_RUNNING", "No running browser matches the profile name")
|
|
16
|
+
if len(matches) != 1:
|
|
17
|
+
raise AutomationError(
|
|
18
|
+
"ENV_AMBIGUOUS", "Multiple running browsers have the same profile name"
|
|
19
|
+
)
|
|
20
|
+
row = matches[0]
|
|
21
|
+
identity = row.get("id")
|
|
22
|
+
if not isinstance(identity, str) or not identity.strip():
|
|
23
|
+
raise AutomationError("FLASHID_API_ERROR", "Running-browser record is missing its ID")
|
|
24
|
+
if sum(r.get("id") == identity for r in rows) != 1:
|
|
25
|
+
raise AutomationError("FLASHID_API_ERROR", "Running-browser ID is duplicated")
|
|
26
|
+
if row.get("status", "running") != "running":
|
|
27
|
+
raise AutomationError("ENV_NOT_RUNNING", "Target browser is not in running state")
|
|
28
|
+
ws = row.get("ws")
|
|
29
|
+
endpoint = ws.get("puppeteer") if isinstance(ws, dict) else None
|
|
30
|
+
try:
|
|
31
|
+
url = urlsplit(endpoint) if isinstance(endpoint, str) else None
|
|
32
|
+
valid = url and url.scheme in {"ws", "wss"} and url.hostname and url.port != 0
|
|
33
|
+
if valid and (url.username is not None or url.password is not None):
|
|
34
|
+
valid = False
|
|
35
|
+
except ValueError:
|
|
36
|
+
valid = False
|
|
37
|
+
if not valid:
|
|
38
|
+
raise AutomationError("CDP_CONNECT_FAILED", "Target browser has no valid CDP WebSocket URL")
|
|
39
|
+
return {"id": identity, "endpoint": endpoint}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Execution plans and JSON Pointer bindings with array wildcard expansion."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .contracts import AutomationError
|
|
8
|
+
from .registry import ModuleRegistry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Step:
|
|
13
|
+
step_id: str
|
|
14
|
+
module_id: str
|
|
15
|
+
params: dict
|
|
16
|
+
bindings: dict = field(default_factory=dict)
|
|
17
|
+
module_version: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pointer_tokens(path: str) -> list[str]:
|
|
21
|
+
if not isinstance(path, str) or (path and not path.startswith("/")):
|
|
22
|
+
raise AutomationError("CHAIN_MAPPING_ERROR", "Binding path must be a JSON Pointer")
|
|
23
|
+
if re.search(r"~(?![01])", path):
|
|
24
|
+
raise AutomationError("CHAIN_MAPPING_ERROR", "Invalid JSON Pointer escape")
|
|
25
|
+
return [part.replace("~1", "/").replace("~0", "~") for part in path.split("/")[1:]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _pointer_value(value, token: str):
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return value[token]
|
|
31
|
+
if isinstance(value, list) and re.fullmatch(r"0|[1-9][0-9]*", token):
|
|
32
|
+
return value[int(token)]
|
|
33
|
+
raise KeyError
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_pointer(output: dict, path: str):
|
|
37
|
+
value = output
|
|
38
|
+
try:
|
|
39
|
+
for token in pointer_tokens(path):
|
|
40
|
+
value = _pointer_value(value, token)
|
|
41
|
+
return deepcopy(value)
|
|
42
|
+
except (KeyError, IndexError, ValueError):
|
|
43
|
+
raise AutomationError("CHAIN_MAPPING_ERROR", "Binding path does not exist") from None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_binding(
|
|
47
|
+
output: dict, path: str, *, allow_item_errors: bool = False
|
|
48
|
+
) -> tuple[list, bool]:
|
|
49
|
+
"""Return ordered matches and whether an array wildcard was expanded.
|
|
50
|
+
|
|
51
|
+
Ordinary pointers (including literal '*' object keys) still select one value.
|
|
52
|
+
Multiple array wildcards flatten matches in depth-first array order.
|
|
53
|
+
"""
|
|
54
|
+
values = [output]
|
|
55
|
+
expanded = False
|
|
56
|
+
try:
|
|
57
|
+
for token in pointer_tokens(path):
|
|
58
|
+
matches = []
|
|
59
|
+
for value in values:
|
|
60
|
+
if isinstance(value, AutomationError):
|
|
61
|
+
matches.append(value)
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
if isinstance(value, list) and token == "*":
|
|
65
|
+
matches.extend(value)
|
|
66
|
+
expanded = True
|
|
67
|
+
else:
|
|
68
|
+
matches.append(_pointer_value(value, token))
|
|
69
|
+
except (KeyError, IndexError, ValueError):
|
|
70
|
+
if not allow_item_errors or not expanded:
|
|
71
|
+
raise
|
|
72
|
+
matches.append(
|
|
73
|
+
AutomationError(
|
|
74
|
+
"CHAIN_MAPPING_ERROR", "Binding path does not exist for this item"
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
values = matches
|
|
78
|
+
return [v if isinstance(v, AutomationError) else deepcopy(v) for v in values], expanded
|
|
79
|
+
except (KeyError, IndexError, ValueError):
|
|
80
|
+
raise AutomationError("CHAIN_MAPPING_ERROR", "Binding path does not exist") from None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def bind_step_params(
|
|
84
|
+
step: Step, outputs: dict, *, allow_item_errors: bool = False
|
|
85
|
+
) -> tuple[list[dict | AutomationError], bool]:
|
|
86
|
+
"""Expand wildcard bindings in lockstep; broadcast ordinary bindings."""
|
|
87
|
+
resolved = {
|
|
88
|
+
target: resolve_binding(
|
|
89
|
+
outputs[binding["step_id"]], binding["path"], allow_item_errors=allow_item_errors
|
|
90
|
+
)
|
|
91
|
+
for target, binding in step.bindings.items()
|
|
92
|
+
}
|
|
93
|
+
counts = {len(values) for values, expanded in resolved.values() if expanded}
|
|
94
|
+
if len(counts) > 1:
|
|
95
|
+
raise AutomationError(
|
|
96
|
+
"CHAIN_MAPPING_ERROR", "Wildcard bindings must match the same number of items"
|
|
97
|
+
)
|
|
98
|
+
params_list = []
|
|
99
|
+
for index in range(next(iter(counts), 1)):
|
|
100
|
+
params = deepcopy(step.params)
|
|
101
|
+
for target, (values, expanded) in resolved.items():
|
|
102
|
+
value = values[index if expanded else 0]
|
|
103
|
+
if isinstance(value, AutomationError):
|
|
104
|
+
params = value
|
|
105
|
+
break
|
|
106
|
+
params[target] = deepcopy(value)
|
|
107
|
+
params_list.append(params)
|
|
108
|
+
return params_list, bool(counts)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def steps_from_json(value: dict) -> list[Step]:
|
|
112
|
+
if (
|
|
113
|
+
not isinstance(value, dict)
|
|
114
|
+
or set(value) != {"steps"}
|
|
115
|
+
or not isinstance(value["steps"], list)
|
|
116
|
+
):
|
|
117
|
+
raise AutomationError("INVALID_REQUEST", "Chain file must contain a steps array only")
|
|
118
|
+
steps = []
|
|
119
|
+
allowed = {"step_id", "module_id", "module_version", "params", "bindings"}
|
|
120
|
+
for item in value["steps"]:
|
|
121
|
+
if (
|
|
122
|
+
not isinstance(item, dict)
|
|
123
|
+
or not set(item) <= allowed
|
|
124
|
+
or not {"step_id", "module_id", "params"} <= item.keys()
|
|
125
|
+
):
|
|
126
|
+
raise AutomationError("INVALID_REQUEST", "Invalid chain step fields")
|
|
127
|
+
steps.append(Step(**item))
|
|
128
|
+
return steps
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def prepare_plan(steps: list[Step], registry: ModuleRegistry) -> list[Step]:
|
|
132
|
+
if not steps:
|
|
133
|
+
raise AutomationError("INVALID_REQUEST", "At least one module step is required")
|
|
134
|
+
prepared = []
|
|
135
|
+
seen = set()
|
|
136
|
+
for step in steps:
|
|
137
|
+
if not isinstance(step.step_id, str) or not step.step_id.strip() or step.step_id in seen:
|
|
138
|
+
raise AutomationError("INVALID_REQUEST", "Step IDs must be non-empty and unique")
|
|
139
|
+
if not isinstance(step.module_id, str) or not step.module_id:
|
|
140
|
+
raise AutomationError("INVALID_REQUEST", "Module ID must be a non-empty string")
|
|
141
|
+
if step.module_version is not None and not isinstance(step.module_version, str):
|
|
142
|
+
raise AutomationError("INVALID_REQUEST", "Module version must be a string")
|
|
143
|
+
if not isinstance(step.params, dict) or not isinstance(step.bindings, dict):
|
|
144
|
+
raise AutomationError("INVALID_REQUEST", "Parameters and bindings must be objects")
|
|
145
|
+
if step.params.keys() & step.bindings.keys():
|
|
146
|
+
raise AutomationError("CHAIN_MAPPING_ERROR", "Fixed parameters conflict with bindings")
|
|
147
|
+
for target, binding in step.bindings.items():
|
|
148
|
+
if (
|
|
149
|
+
not isinstance(target, str)
|
|
150
|
+
or not isinstance(binding, dict)
|
|
151
|
+
or set(binding) != {"step_id", "path"}
|
|
152
|
+
or not isinstance(binding["step_id"], str)
|
|
153
|
+
or binding["step_id"] not in seen
|
|
154
|
+
):
|
|
155
|
+
raise AutomationError(
|
|
156
|
+
"CHAIN_MAPPING_ERROR", "Bindings must reference earlier steps"
|
|
157
|
+
)
|
|
158
|
+
pointer_tokens(binding["path"])
|
|
159
|
+
module = registry.get(step.module_id, step.module_version)
|
|
160
|
+
params = module.validate_params(step.params, set(step.bindings))
|
|
161
|
+
prepared.append(
|
|
162
|
+
Step(
|
|
163
|
+
step.step_id, step.module_id, params, deepcopy(step.bindings), module.module_version
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
seen.add(step.step_id)
|
|
167
|
+
return prepared
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Discover trusted Python modules and resolve classes without module-name branches."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import pkgutil
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
|
|
9
|
+
from jsonschema import Draft202012Validator
|
|
10
|
+
|
|
11
|
+
from .base import AutomationModule
|
|
12
|
+
from .contracts import AutomationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModuleRegistry:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._modules: dict[str, type[AutomationModule]] = {}
|
|
18
|
+
self.rejected: list[dict] = []
|
|
19
|
+
|
|
20
|
+
def register(self, module: type[AutomationModule]) -> None:
|
|
21
|
+
if not inspect.isclass(module) or not issubclass(module, AutomationModule):
|
|
22
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Module must implement AutomationModule")
|
|
23
|
+
if inspect.isabstract(module):
|
|
24
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Cannot register an abstract module")
|
|
25
|
+
if module.__qualname__ != module.__name__:
|
|
26
|
+
raise AutomationError(
|
|
27
|
+
"MODULE_UNAVAILABLE", "Module classes must be defined at top level"
|
|
28
|
+
)
|
|
29
|
+
module_id = getattr(module, "module_id", "")
|
|
30
|
+
if not isinstance(module_id, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]+", module_id):
|
|
31
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Invalid module ID")
|
|
32
|
+
if module_id in self._modules:
|
|
33
|
+
raise AutomationError(
|
|
34
|
+
"MODULE_UNAVAILABLE", "Duplicate module ID", {"module_id": module_id}
|
|
35
|
+
)
|
|
36
|
+
if not isinstance(module.module_version, str) or not module.module_version:
|
|
37
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Module must declare a version")
|
|
38
|
+
try:
|
|
39
|
+
if (
|
|
40
|
+
not isinstance(module.params_schema, dict)
|
|
41
|
+
or module.params_schema.get("type") != "object"
|
|
42
|
+
):
|
|
43
|
+
raise ValueError
|
|
44
|
+
Draft202012Validator.check_schema(module.params_schema)
|
|
45
|
+
Draft202012Validator.check_schema(module.output_schema)
|
|
46
|
+
except Exception:
|
|
47
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Invalid module JSON Schema") from None
|
|
48
|
+
self._modules[module_id] = module
|
|
49
|
+
|
|
50
|
+
def get(self, module_id: str, version: str | None = None) -> type[AutomationModule]:
|
|
51
|
+
module = self._modules.get(module_id)
|
|
52
|
+
if module is None or (version is not None and module.module_version != version):
|
|
53
|
+
raise AutomationError(
|
|
54
|
+
"MODULE_UNAVAILABLE",
|
|
55
|
+
"Module or requested version is unavailable",
|
|
56
|
+
{"module_id": module_id},
|
|
57
|
+
)
|
|
58
|
+
return module
|
|
59
|
+
|
|
60
|
+
def describe(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"modules": [
|
|
63
|
+
{
|
|
64
|
+
"module_id": cls.module_id,
|
|
65
|
+
"module_version": cls.module_version,
|
|
66
|
+
"params_schema": cls.params_schema,
|
|
67
|
+
"output_schema": cls.output_schema,
|
|
68
|
+
"page_policy": cls.page_policy,
|
|
69
|
+
}
|
|
70
|
+
for cls in self._modules.values()
|
|
71
|
+
],
|
|
72
|
+
"rejected": self.rejected,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def discover(cls, package_name: str = "flashid_auto.modules") -> "ModuleRegistry":
|
|
77
|
+
registry = cls()
|
|
78
|
+
package = importlib.import_module(package_name)
|
|
79
|
+
candidates = []
|
|
80
|
+
for info in sorted(
|
|
81
|
+
pkgutil.iter_modules(package.__path__, package_name + "."), key=lambda info: info.name
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
module = importlib.import_module(info.name)
|
|
85
|
+
for _, member in inspect.getmembers(module, inspect.isclass):
|
|
86
|
+
if (
|
|
87
|
+
member.__module__ == info.name
|
|
88
|
+
and issubclass(member, AutomationModule)
|
|
89
|
+
and member is not AutomationModule
|
|
90
|
+
):
|
|
91
|
+
candidates.append((info.name, member))
|
|
92
|
+
except Exception:
|
|
93
|
+
registry.rejected.append({"source": info.name, "reason": "Module load rejected"})
|
|
94
|
+
counts = Counter(
|
|
95
|
+
member.module_id
|
|
96
|
+
for _, member in candidates
|
|
97
|
+
if isinstance(getattr(member, "module_id", None), str)
|
|
98
|
+
)
|
|
99
|
+
for source, member in candidates:
|
|
100
|
+
try:
|
|
101
|
+
if (
|
|
102
|
+
isinstance(getattr(member, "module_id", None), str)
|
|
103
|
+
and counts[member.module_id] > 1
|
|
104
|
+
):
|
|
105
|
+
raise AutomationError("MODULE_UNAVAILABLE", "Duplicate module ID")
|
|
106
|
+
registry.register(member)
|
|
107
|
+
except AutomationError as exc:
|
|
108
|
+
registry.rejected.append({"source": source, "reason": exc.message})
|
|
109
|
+
return registry
|