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.

@@ -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