separan 0.1.0a2__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.
- separan/__init__.py +8 -0
- separan/__main__.py +3 -0
- separan/ast_nodes.py +122 -0
- separan/ast_printer.py +22 -0
- separan/auth.py +175 -0
- separan/browser.py +57 -0
- separan/builtins.py +786 -0
- separan/bytes_ops.py +89 -0
- separan/capabilities.py +77 -0
- separan/cli.py +55 -0
- separan/collection_ops.py +88 -0
- separan/cookie_store.py +97 -0
- separan/cookies.py +146 -0
- separan/database.py +5 -0
- separan/db/__init__.py +5 -0
- separan/db/core.py +269 -0
- separan/db/drivers/__init__.py +1 -0
- separan/db/drivers/_dbapi.py +64 -0
- separan/db/drivers/mysql.py +46 -0
- separan/db/drivers/oracle.py +69 -0
- separan/db/drivers/postgresql.py +44 -0
- separan/db/drivers/sqlite.py +98 -0
- separan/db/drivers/sqlserver.py +97 -0
- separan/db/errors.py +20 -0
- separan/db/registry.py +23 -0
- separan/errors.py +40 -0
- separan/http_client.py +220 -0
- separan/http_server.py +208 -0
- separan/interpreter.py +572 -0
- separan/io_json.py +194 -0
- separan/lexer.py +145 -0
- separan/list_ops.py +197 -0
- separan/lsp.py +382 -0
- separan/lsp_analysis.py +222 -0
- separan/objects.py +52 -0
- separan/parser.py +408 -0
- separan/processes.py +118 -0
- separan/randomness.py +106 -0
- separan/structural.py +303 -0
- separan/structure_insights.py +139 -0
- separan/system_context.py +54 -0
- separan/system_utilities.py +201 -0
- separan/temporal.py +263 -0
- separan/token.py +95 -0
- separan-0.1.0a2.dist-info/METADATA +354 -0
- separan-0.1.0a2.dist-info/RECORD +51 -0
- separan-0.1.0a2.dist-info/WHEEL +5 -0
- separan-0.1.0a2.dist-info/entry_points.txt +4 -0
- separan-0.1.0a2.dist-info/licenses/LICENSE +201 -0
- separan-0.1.0a2.dist-info/licenses/NOTICE +4 -0
- separan-0.1.0a2.dist-info/top_level.txt +1 -0
separan/__init__.py
ADDED
separan/__main__.py
ADDED
separan/ast_nodes.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from .token import SourcePosition
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Node: position: SourcePosition
|
|
9
|
+
@dataclass
|
|
10
|
+
class Expr(Node): pass
|
|
11
|
+
@dataclass
|
|
12
|
+
class LiteralExpr(Expr): value: Any
|
|
13
|
+
@dataclass
|
|
14
|
+
class VariableExpr(Expr): name: str
|
|
15
|
+
@dataclass
|
|
16
|
+
class BinaryExpr(Expr): left: Expr; operator: str; right: Expr
|
|
17
|
+
@dataclass
|
|
18
|
+
class UnaryExpr(Expr): operator: str; operand: Expr
|
|
19
|
+
@dataclass
|
|
20
|
+
class GroupExpr(Expr): expression: Expr
|
|
21
|
+
@dataclass
|
|
22
|
+
class CallExpr(Expr):
|
|
23
|
+
callee: str
|
|
24
|
+
arguments: list[Expr]
|
|
25
|
+
named_arguments: dict[str, Expr] = field(default_factory=dict)
|
|
26
|
+
@dataclass
|
|
27
|
+
class ListExpr(Expr): elements: list[Expr]
|
|
28
|
+
@dataclass
|
|
29
|
+
class IndexExpr(Expr): target: Expr; index: Expr
|
|
30
|
+
@dataclass
|
|
31
|
+
class MemberExpr(Expr): target: Expr; name: str
|
|
32
|
+
@dataclass
|
|
33
|
+
class MemberCallExpr(Expr): target: Expr; name: str; arguments: list[Expr]; named_arguments: dict[str, Expr] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Stmt(Node): pass
|
|
37
|
+
@dataclass
|
|
38
|
+
class Assignment(Stmt): name: str; value: Expr
|
|
39
|
+
@dataclass
|
|
40
|
+
class ConstDeclaration(Stmt): name: str; value: Expr
|
|
41
|
+
@dataclass
|
|
42
|
+
class PrintStmt(Stmt): value: Expr
|
|
43
|
+
@dataclass
|
|
44
|
+
class PrintErrorStmt(Stmt): value: Expr
|
|
45
|
+
@dataclass
|
|
46
|
+
class ReturnStmt(Stmt): value: Expr | None
|
|
47
|
+
@dataclass
|
|
48
|
+
class ExpressionStmt(Stmt): expression: Expr
|
|
49
|
+
@dataclass
|
|
50
|
+
class ObjectField(Node): name: str; value: Expr
|
|
51
|
+
@dataclass
|
|
52
|
+
class ObjectBlock(Stmt): name: str; entries: list[Node]; label_position: SourcePosition
|
|
53
|
+
@dataclass
|
|
54
|
+
class ListBlock(Stmt): name: str; elements: list[Expr]; label_position: SourcePosition
|
|
55
|
+
@dataclass
|
|
56
|
+
class ImportStmt(Stmt): path: str; alias: str
|
|
57
|
+
@dataclass
|
|
58
|
+
class CatchBranch(Node): category: str; body: list[Stmt]
|
|
59
|
+
@dataclass
|
|
60
|
+
class TryStmt(Stmt): label: str; body: list[Stmt]; catches: list[CatchBranch]; finally_body: list[Stmt] | None; label_position: SourcePosition
|
|
61
|
+
@dataclass
|
|
62
|
+
class ThrowStmt(Stmt): value: Expr
|
|
63
|
+
@dataclass
|
|
64
|
+
class ErrorDecl(Stmt): name: str; label_position: SourcePosition
|
|
65
|
+
@dataclass
|
|
66
|
+
class HttpRouteDecl(Stmt): method: str; path: str; label: str; body: list[Stmt]; label_position: SourcePosition
|
|
67
|
+
@dataclass
|
|
68
|
+
class TransactionStmt(Stmt): connection: Expr; label: str; body: list[Stmt]; label_position: SourcePosition
|
|
69
|
+
@dataclass
|
|
70
|
+
class IfBranch:
|
|
71
|
+
condition: Expr
|
|
72
|
+
body: list[Stmt]
|
|
73
|
+
position: SourcePosition
|
|
74
|
+
@dataclass
|
|
75
|
+
class IfStmt(Stmt):
|
|
76
|
+
label: str
|
|
77
|
+
branches: list[IfBranch]
|
|
78
|
+
else_body: list[Stmt] | None
|
|
79
|
+
label_position: SourcePosition
|
|
80
|
+
@dataclass
|
|
81
|
+
class WhileStmt(Stmt):
|
|
82
|
+
label: str
|
|
83
|
+
condition: Expr
|
|
84
|
+
body: list[Stmt]
|
|
85
|
+
label_position: SourcePosition
|
|
86
|
+
@dataclass
|
|
87
|
+
class ForStmt(Stmt):
|
|
88
|
+
label: str
|
|
89
|
+
variable: str
|
|
90
|
+
iterable: Expr
|
|
91
|
+
body: list[Stmt]
|
|
92
|
+
label_position: SourcePosition
|
|
93
|
+
@dataclass
|
|
94
|
+
class FunctionDecl(Stmt):
|
|
95
|
+
name: str
|
|
96
|
+
parameters: list[str]
|
|
97
|
+
body: list[Stmt]
|
|
98
|
+
label_position: SourcePosition
|
|
99
|
+
@dataclass
|
|
100
|
+
class Program(Node): statements: list[Stmt] = field(default_factory=list)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ast_structural_equal(left, right):
|
|
104
|
+
"""Compare AST meaning while deliberately excluding source locations."""
|
|
105
|
+
return _structural_value(left) == _structural_value(right)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _structural_value(value):
|
|
109
|
+
if isinstance(value, SourcePosition):
|
|
110
|
+
return None
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
return tuple(_structural_value(item) for item in value)
|
|
113
|
+
if is_dataclass(value):
|
|
114
|
+
return (
|
|
115
|
+
type(value),
|
|
116
|
+
tuple(
|
|
117
|
+
(item.name, _structural_value(getattr(value, item.name)))
|
|
118
|
+
for item in fields(value)
|
|
119
|
+
if not isinstance(getattr(value, item.name), SourcePosition)
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
return value
|
separan/ast_printer.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import fields, is_dataclass
|
|
2
|
+
from .ast_nodes import Node
|
|
3
|
+
from .token import SourcePosition
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_ast(node, indent=0):
|
|
7
|
+
pad = " " * indent
|
|
8
|
+
if isinstance(node, list): return "\n".join(format_ast(x, indent) for x in node)
|
|
9
|
+
if not is_dataclass(node): return pad + repr(node)
|
|
10
|
+
title = type(node).__name__
|
|
11
|
+
extras = []
|
|
12
|
+
for name in ("name", "label", "variable", "operator"):
|
|
13
|
+
if hasattr(node, name): extras.append(f"{name}={getattr(node, name)}")
|
|
14
|
+
lines = [pad + title + ((" " + " ".join(extras)) if extras else "")]
|
|
15
|
+
for f in fields(node):
|
|
16
|
+
if f.name in {"position", "label_position", "name", "label", "variable", "operator"}: continue
|
|
17
|
+
value = getattr(node, f.name)
|
|
18
|
+
if isinstance(value, Node) or (isinstance(value, list) and value):
|
|
19
|
+
lines.append(pad + f" {f.name}:")
|
|
20
|
+
lines.append(format_ast(value, indent + 2))
|
|
21
|
+
return "\n".join(lines)
|
|
22
|
+
|
separan/auth.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""High-level authentication primitives; no user-defined cryptography surface."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import base64
|
|
5
|
+
import binascii
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
from .errors import error
|
|
13
|
+
from .objects import ObjectValue
|
|
14
|
+
from .randomness import BytesValue
|
|
15
|
+
from .system_utilities import UtilityFunction
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SecretValue:
|
|
20
|
+
value: bytes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class HttpAuthValue:
|
|
25
|
+
kind: str
|
|
26
|
+
name: str
|
|
27
|
+
value: SecretValue
|
|
28
|
+
location: str = "header"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class OAuthTokenValue:
|
|
33
|
+
access_token: SecretValue
|
|
34
|
+
token_type: str
|
|
35
|
+
expires_in: int | None
|
|
36
|
+
scope: str | None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def secret_bytes(value, name, position, runtime):
|
|
40
|
+
if isinstance(value, SecretValue): return value.value
|
|
41
|
+
if isinstance(value, BytesValue): return value.value
|
|
42
|
+
if type(value) is str: return value.encode("utf-8")
|
|
43
|
+
runtime.type_error(position, "secret, bytes, or string", runtime.type_name(value), f"{name} requires secret-compatible input.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _secret_get(arguments, named, position, runtime):
|
|
47
|
+
name = arguments[0]
|
|
48
|
+
if type(name) is not str or not name: runtime.type_error(position, "non-empty string", runtime.type_name(name), "secret_get() requires a secret name.")
|
|
49
|
+
capability = runtime.capabilities; capability.require(capability.read_secrets, "read secrets", position)
|
|
50
|
+
if capability.allowed_secrets is not None and name not in capability.allowed_secrets: raise error("E870", "Permission error", "Secret name is outside the host allowlist.", position, actual=name)
|
|
51
|
+
if runtime.secret_provider is None: raise error("E871", "Secret unavailable", "No host secret provider is configured.", position, actual=name)
|
|
52
|
+
try: value = runtime.secret_provider(name)
|
|
53
|
+
except Exception as exc: raise error("E871", "Secret unavailable", str(exc), position, actual=name)
|
|
54
|
+
if value is None: raise error("E871", "Secret unavailable", "The requested secret does not exist.", position, actual=name)
|
|
55
|
+
if type(value) is str: value = value.encode("utf-8")
|
|
56
|
+
if type(value) is not bytes: raise error("E871", "Secret provider error", "Host secret provider must return string, bytes, or null.", position, actual=name)
|
|
57
|
+
return SecretValue(value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _basic_auth(arguments, named, position, runtime):
|
|
61
|
+
username = arguments[0]
|
|
62
|
+
if type(username) is not str or ":" in username: raise error("E872", "Invalid Basic auth username", "Username must be a string without ':'.", position)
|
|
63
|
+
password = secret_bytes(arguments[1], "basic_auth() password", position, runtime)
|
|
64
|
+
token = base64.b64encode(username.encode("utf-8") + b":" + password)
|
|
65
|
+
return HttpAuthValue("basic", "Authorization", SecretValue(b"Basic " + token))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _bearer_auth(arguments, named, position, runtime):
|
|
69
|
+
token = secret_bytes(arguments[0], "bearer_auth() token", position, runtime)
|
|
70
|
+
if not token or any(byte < 33 or byte > 126 for byte in token): raise error("E872", "Invalid bearer token", "Bearer token must contain visible ASCII bytes only.", position)
|
|
71
|
+
return HttpAuthValue("bearer", "Authorization", SecretValue(b"Bearer " + token))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _api_key_auth(arguments, named, position, runtime):
|
|
75
|
+
name, value = arguments; location = named.get("location", "header")
|
|
76
|
+
if type(name) is not str or not name or any(ord(char) < 33 for char in name): raise error("E872", "Invalid API key name", "API key name must be a safe non-empty string.", position)
|
|
77
|
+
if location not in ("header", "query"): raise error("E872", "Invalid API key location", "API key location must be header or query.", position, actual=repr(location))
|
|
78
|
+
return HttpAuthValue("api_key", name, SecretValue(secret_bytes(value, "api_key_auth() value", position, runtime)), location)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _hmac_sha256(arguments, named, position, runtime):
|
|
82
|
+
key = secret_bytes(arguments[0], "hmac_sha256() key", position, runtime); message = secret_bytes(arguments[1], "hmac_sha256() message", position, runtime)
|
|
83
|
+
return BytesValue(hmac.new(key, message, hashlib.sha256).digest())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json_value(value, position):
|
|
87
|
+
if isinstance(value, ObjectValue): return {key: _json_value(item, position) for key, item in value.fields.items()}
|
|
88
|
+
if type(value) is list: return [_json_value(item, position) for item in value]
|
|
89
|
+
if value is None or type(value) in (str, bool, int, float): return value
|
|
90
|
+
raise error("E875", "Invalid JWT claim", "JWT claims must be JSON-compatible and cannot contain secrets or bytes.", position)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _b64url(value): return base64.urlsafe_b64encode(value).rstrip(b"=")
|
|
94
|
+
def _b64url_decode(value): return base64.urlsafe_b64decode(value + b"=" * ((4 - len(value) % 4) % 4))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _jwt_sign(arguments, named, position, runtime):
|
|
98
|
+
claims, key = arguments; algorithm = named.get("algorithm", "HS256")
|
|
99
|
+
if algorithm != "HS256": raise error("E873", "Unsupported JWT algorithm", "Only explicit HS256 is supported in the preview.", position, actual=repr(algorithm))
|
|
100
|
+
if not isinstance(claims, ObjectValue): runtime.type_error(position, "object claims", runtime.type_name(claims), "jwt_sign() claims must be an object.")
|
|
101
|
+
key_bytes = secret_bytes(key, "jwt_sign() key", position, runtime)
|
|
102
|
+
if len(key_bytes) < 32: raise error("E873", "Weak JWT key", "HS256 keys must contain at least 32 bytes.", position)
|
|
103
|
+
header = _b64url(b'{"alg":"HS256","typ":"JWT"}')
|
|
104
|
+
payload = _b64url(json.dumps(_json_value(claims, position), ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
|
105
|
+
signing = header + b"." + payload; signature = _b64url(hmac.new(key_bytes, signing, hashlib.sha256).digest())
|
|
106
|
+
return signing.decode("ascii") + "." + signature.decode("ascii")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _jwt_verify(arguments, named, position, runtime):
|
|
110
|
+
token, key = arguments; algorithm = named.get("algorithm", "HS256")
|
|
111
|
+
if type(token) is not str or algorithm != "HS256": raise error("E873", "Unsupported or invalid JWT", "JWT must be a string and algorithm must be HS256.", position)
|
|
112
|
+
try:
|
|
113
|
+
header_text, payload_text, signature_text = token.encode("ascii").split(b".")
|
|
114
|
+
header = json.loads(_b64url_decode(header_text)); payload = json.loads(_b64url_decode(payload_text))
|
|
115
|
+
key_bytes = secret_bytes(key, "jwt_verify() key", position, runtime)
|
|
116
|
+
if len(key_bytes) < 32: raise ValueError("weak key")
|
|
117
|
+
expected = _b64url(hmac.new(key_bytes, header_text + b"." + payload_text, hashlib.sha256).digest())
|
|
118
|
+
except Exception: raise error("E874", "JWT verification error", "JWT is malformed.", position)
|
|
119
|
+
if header != {"alg": "HS256", "typ": "JWT"} or not hmac.compare_digest(expected, signature_text): raise error("E874", "JWT verification error", "JWT signature or protected header is invalid.", position)
|
|
120
|
+
if type(payload) is not dict: raise error("E874", "JWT verification error", "JWT claims must be an object.", position)
|
|
121
|
+
now = runtime.current_time().timestamp()
|
|
122
|
+
for claim in ("exp", "nbf"):
|
|
123
|
+
if claim in payload and (type(payload[claim]) not in (int, float) or type(payload[claim]) is bool): raise error("E874", "JWT verification error", f"JWT {claim} claim must be a number.", position)
|
|
124
|
+
if "exp" in payload and now >= payload["exp"]: raise error("E874", "JWT verification error", "JWT has expired.", position)
|
|
125
|
+
if "nbf" in payload and now < payload["nbf"]: raise error("E874", "JWT verification error", "JWT is not active yet.", position)
|
|
126
|
+
from .io_json import _from_json
|
|
127
|
+
return _from_json(payload, position)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _password_hash(arguments, named, position, runtime):
|
|
131
|
+
password = secret_bytes(arguments[0], "password_hash() password", position, runtime); salt = os.urandom(16)
|
|
132
|
+
digest = hashlib.scrypt(password, salt=salt, n=2**14, r=8, p=1, dklen=32)
|
|
133
|
+
return "$separan$scrypt$n=16384,r=8,p=1$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _password_verify(arguments, named, position, runtime):
|
|
137
|
+
password, encoded = arguments
|
|
138
|
+
if type(encoded) is not str: runtime.type_error(position, "password hash string", runtime.type_name(encoded), "password_verify() hash must be a string.")
|
|
139
|
+
try:
|
|
140
|
+
prefix, scheme, params, salt_text, digest_text = encoded.rsplit("$", 4)
|
|
141
|
+
if prefix != "$separan" or scheme != "scrypt" or params != "n=16384,r=8,p=1": return False
|
|
142
|
+
salt = base64.b64decode(salt_text, validate=True); expected = base64.b64decode(digest_text, validate=True)
|
|
143
|
+
actual = hashlib.scrypt(secret_bytes(password, "password_verify() password", position, runtime), salt=salt, n=2**14, r=8, p=1, dklen=32)
|
|
144
|
+
return hmac.compare_digest(actual, expected)
|
|
145
|
+
except (ValueError, binascii.Error): return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _oauth_client_credentials(arguments, named, position, runtime):
|
|
149
|
+
token_url, client_id, client_secret = arguments; scope = named.get("scope")
|
|
150
|
+
if type(token_url) is not str or type(client_id) is not str: runtime.type_error(position, "string token URL and client ID", f"{runtime.type_name(token_url)}, {runtime.type_name(client_id)}", "OAuth token URL and client ID must be strings.")
|
|
151
|
+
if scope is not None and type(scope) is not str: runtime.type_error(position, "string or null scope", runtime.type_name(scope), "OAuth scope must be a string or null.")
|
|
152
|
+
form = [("grant_type", "client_credentials")]
|
|
153
|
+
if scope is not None: form.append(("scope", scope))
|
|
154
|
+
headers = ObjectValue.create({"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"})
|
|
155
|
+
from .http_client import _request
|
|
156
|
+
response = _request([token_url], {"method": "POST", "headers": headers, "body": urlencode(form), "auth": _basic_auth([client_id, client_secret], {}, position, runtime)}, position, runtime)
|
|
157
|
+
if not 200 <= response.status < 300 or response.text is None: raise error("E877", "oauth_error", f"OAuth token endpoint returned unusable status {response.status}.", position, actual=str(response.status))
|
|
158
|
+
try: payload = json.loads(response.text)
|
|
159
|
+
except json.JSONDecodeError: raise error("E877", "oauth_error", "OAuth token response is not valid JSON.", position)
|
|
160
|
+
token, token_type = payload.get("access_token"), payload.get("token_type")
|
|
161
|
+
expires, returned_scope = payload.get("expires_in"), payload.get("scope")
|
|
162
|
+
if type(token) is not str or not token or type(token_type) is not str: raise error("E877", "oauth_error", "OAuth response requires string access_token and token_type.", position)
|
|
163
|
+
if expires is not None and (type(expires) is not int or expires < 0): raise error("E877", "oauth_error", "OAuth expires_in must be a non-negative integer.", position)
|
|
164
|
+
if returned_scope is not None and type(returned_scope) is not str: raise error("E877", "oauth_error", "OAuth scope must be a string.", position)
|
|
165
|
+
return OAuthTokenValue(SecretValue(token.encode("utf-8")), token_type, expires, returned_scope)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
AUTH_BUILTINS = (
|
|
169
|
+
UtilityFunction("secret_get", 1, 1, _secret_get), UtilityFunction("basic_auth", 2, 2, _basic_auth),
|
|
170
|
+
UtilityFunction("bearer_auth", 1, 1, _bearer_auth), UtilityFunction("api_key_auth", 2, 2, _api_key_auth, ("location",)),
|
|
171
|
+
UtilityFunction("hmac_sha256", 2, 2, _hmac_sha256), UtilityFunction("jwt_sign", 2, 2, _jwt_sign, ("algorithm",)),
|
|
172
|
+
UtilityFunction("jwt_verify", 2, 2, _jwt_verify, ("algorithm",)), UtilityFunction("password_hash", 1, 1, _password_hash),
|
|
173
|
+
UtilityFunction("password_verify", 2, 2, _password_verify),
|
|
174
|
+
UtilityFunction("oauth_client_credentials", 3, 3, _oauth_client_credentials, ("scope",)),
|
|
175
|
+
)
|
separan/browser.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Browser automation boundary for future engine adapters.
|
|
2
|
+
|
|
3
|
+
This module deliberately does not use the HTTP client as a fake browser. A
|
|
4
|
+
conforming adapter must drive a real browser engine and expose JavaScript/DOM
|
|
5
|
+
state through a separate value type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SUPPORTED_ENGINES = frozenset({"chromium", "firefox", "webkit"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BrowserAutomationUnavailable(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BrowserProfile:
|
|
21
|
+
engine: str = "chromium"
|
|
22
|
+
screen_width: int = 1280
|
|
23
|
+
screen_height: int = 720
|
|
24
|
+
language: str = "en-US"
|
|
25
|
+
headless: bool = True
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.engine not in SUPPORTED_ENGINES:
|
|
29
|
+
raise ValueError(f"Unsupported browser engine '{self.engine}'.")
|
|
30
|
+
if isinstance(self.screen_width, bool) or not isinstance(self.screen_width, int) or self.screen_width <= 0:
|
|
31
|
+
raise ValueError("screen_width must be a positive integer.")
|
|
32
|
+
if isinstance(self.screen_height, bool) or not isinstance(self.screen_height, int) or self.screen_height <= 0:
|
|
33
|
+
raise ValueError("screen_height must be a positive integer.")
|
|
34
|
+
if not isinstance(self.language, str) or not self.language.strip():
|
|
35
|
+
raise ValueError("language must be a non-empty string.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BrowserPage(Protocol):
|
|
39
|
+
@property
|
|
40
|
+
def url(self) -> str: ...
|
|
41
|
+
def text(self, selector: str) -> str: ...
|
|
42
|
+
def close(self) -> None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BrowserAdapter(Protocol):
|
|
46
|
+
def open(self, url: str, profile: BrowserProfile) -> BrowserPage: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def browser_open(url: str, *, profile: BrowserProfile | None = None,
|
|
50
|
+
adapter: BrowserAdapter | None = None) -> BrowserPage:
|
|
51
|
+
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
52
|
+
raise ValueError("browser_open requires an absolute HTTP or HTTPS URL.")
|
|
53
|
+
if adapter is None:
|
|
54
|
+
raise BrowserAutomationUnavailable(
|
|
55
|
+
"No browser engine adapter is installed. HTTP retrieval remains available through http_get()."
|
|
56
|
+
)
|
|
57
|
+
return adapter.open(url, profile or BrowserProfile())
|