skalaio 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.
skala/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ from skala._client import Skala, DEFAULT_BASE_URL
2
+ from skala._errors import (
3
+ SkalaApiError,
4
+ SkalaConfigError,
5
+ SkalaError,
6
+ SkalaNetworkError,
7
+ SkalaTimeoutError,
8
+ )
9
+ from skala._types import (
10
+ OutcomeRequest,
11
+ OutcomeResponse,
12
+ ScoreFallbackResponse,
13
+ ScoreRequest,
14
+ ScoreResponse,
15
+ SkalaDecision,
16
+ SkalaEventType,
17
+ SkalaOptions,
18
+ SkalaOutcome,
19
+ )
20
+
21
+ __all__ = [
22
+ "Skala",
23
+ "DEFAULT_BASE_URL",
24
+ "SkalaApiError",
25
+ "SkalaConfigError",
26
+ "SkalaError",
27
+ "SkalaNetworkError",
28
+ "SkalaTimeoutError",
29
+ "OutcomeRequest",
30
+ "OutcomeResponse",
31
+ "ScoreFallbackResponse",
32
+ "ScoreRequest",
33
+ "ScoreResponse",
34
+ "SkalaDecision",
35
+ "SkalaEventType",
36
+ "SkalaOptions",
37
+ "SkalaOutcome",
38
+ ]
skala/_client.py ADDED
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import time
5
+ import uuid
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from skala._errors import (
11
+ SkalaApiError,
12
+ SkalaConfigError,
13
+ SkalaNetworkError,
14
+ SkalaTimeoutError,
15
+ )
16
+ from skala._types import (
17
+ OutcomeRequest,
18
+ OutcomeResponse,
19
+ ScoreFallbackResponse,
20
+ ScoreRequest,
21
+ ScoreResponse,
22
+ SkalaOptions,
23
+ )
24
+
25
+ DEFAULT_BASE_URL = "https://apiskala.varityweb.com"
26
+ _DEFAULT_RETRIES = 2
27
+ _DEFAULT_TIMEOUT_MS = 5_000
28
+
29
+
30
+ def _sha256_hex(value: str) -> str:
31
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
32
+
33
+
34
+ class Skala:
35
+ def __init__(self, options: SkalaOptions) -> None:
36
+ if not options.api_key or not options.api_key.strip():
37
+ raise SkalaConfigError("Skala apiKey is required")
38
+ if options.retries is not None and options.retries < 0:
39
+ raise SkalaConfigError(
40
+ "Skala retries must be greater than or equal to 0"
41
+ )
42
+ if options.timeout_ms is not None and options.timeout_ms <= 0:
43
+ raise SkalaConfigError("Skala timeoutMs must be greater than 0")
44
+
45
+ self._api_key = options.api_key
46
+ self._base_url = (options.base_url or DEFAULT_BASE_URL).rstrip("/")
47
+ self._retries = (
48
+ options.retries if options.retries is not None else _DEFAULT_RETRIES
49
+ )
50
+ self._timeout = (
51
+ (options.timeout_ms or _DEFAULT_TIMEOUT_MS) / 1000
52
+ )
53
+
54
+ def _post_json(self, path: str, payload: dict[str, Any]) -> Any:
55
+ request_id = str(uuid.uuid4())
56
+ attempt = 0
57
+
58
+ while True:
59
+ try:
60
+ response = httpx.post(
61
+ f"{self._base_url}{path}",
62
+ json=payload,
63
+ headers={
64
+ "Authorization": f"Bearer {self._api_key}",
65
+ "Accept": "application/json",
66
+ "Content-Type": "application/json",
67
+ "X-Request-ID": request_id,
68
+ },
69
+ timeout=self._timeout,
70
+ )
71
+ except httpx.TimeoutException as exc:
72
+ raise SkalaTimeoutError(
73
+ int(self._timeout * 1000), request_id=request_id, cause=exc
74
+ ) from exc
75
+ except Exception as exc:
76
+ raise SkalaNetworkError(
77
+ request_id=request_id, cause=exc
78
+ ) from exc
79
+
80
+ if 500 <= response.status_code < 600 and attempt < self._retries:
81
+ attempt += 1
82
+ time.sleep(0.05 * attempt)
83
+ continue
84
+
85
+ if response.status_code < 200 or response.status_code >= 300:
86
+ body: Any = response.text
87
+ if body:
88
+ try:
89
+ body = response.json()
90
+ except Exception:
91
+ pass
92
+ raise SkalaApiError(
93
+ response.status_code, body, request_id=request_id
94
+ )
95
+
96
+ return response.json()
97
+
98
+ def score(self, payload: ScoreRequest) -> ScoreResponse | ScoreFallbackResponse:
99
+ started_at = time.monotonic()
100
+
101
+ try:
102
+ data = payload.to_dict()
103
+ email = data.pop("email")
104
+ data["email_hash"] = _sha256_hex(email.strip().lower())
105
+
106
+ email_domain = None
107
+ if email and "@" in email:
108
+ email_domain = email.split("@")[-1].strip().lower()
109
+
110
+ if email_domain:
111
+ if "metadata" not in data or data["metadata"] is None:
112
+ data["metadata"] = {}
113
+ data["metadata"]["email_domain"] = email_domain
114
+
115
+ result = self._post_json("/v1/score", data)
116
+ return ScoreResponse.from_dict(result)
117
+ except SkalaTimeoutError as exc:
118
+ elapsed_ms = int((time.monotonic() - started_at) * 1000)
119
+ return ScoreFallbackResponse(
120
+ request_id=exc.request_id or str(uuid.uuid4()),
121
+ risk_score=0,
122
+ decision="allow",
123
+ reason_codes=["SDK_TIMEOUT_FALLBACK"],
124
+ latency_ms=max(0, round(elapsed_ms)),
125
+ fallback=True,
126
+ )
127
+ except SkalaNetworkError as exc:
128
+ elapsed_ms = int((time.monotonic() - started_at) * 1000)
129
+ return ScoreFallbackResponse(
130
+ request_id=exc.request_id or str(uuid.uuid4()),
131
+ risk_score=0,
132
+ decision="allow",
133
+ reason_codes=["SDK_NETWORK_FALLBACK"],
134
+ latency_ms=max(0, round(elapsed_ms)),
135
+ fallback=True,
136
+ )
137
+
138
+ def outcome(self, payload: OutcomeRequest) -> OutcomeResponse:
139
+ result = self._post_json("/v1/outcome", payload.to_dict())
140
+ return OutcomeResponse.from_dict(result)
skala/_errors.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SkalaError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ request_id: str | None = None,
12
+ cause: BaseException | None = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.request_id = request_id
16
+ if cause is not None:
17
+ self.__cause__ = cause
18
+
19
+
20
+ class SkalaConfigError(SkalaError):
21
+ pass
22
+
23
+
24
+ class SkalaTimeoutError(SkalaError):
25
+ def __init__(
26
+ self,
27
+ timeout_ms: int,
28
+ request_id: str | None = None,
29
+ cause: BaseException | None = None,
30
+ ) -> None:
31
+ super().__init__(
32
+ f"Skala request timed out after {timeout_ms}ms",
33
+ request_id=request_id,
34
+ cause=cause,
35
+ )
36
+ self.timeout_ms = timeout_ms
37
+
38
+
39
+ class SkalaNetworkError(SkalaError):
40
+ def __init__(
41
+ self,
42
+ request_id: str | None = None,
43
+ cause: BaseException | None = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ "Skala request failed because the API is unreachable",
47
+ request_id=request_id,
48
+ cause=cause,
49
+ )
50
+
51
+
52
+ class SkalaApiError(SkalaError):
53
+ def __init__(
54
+ self,
55
+ status: int,
56
+ body: Any,
57
+ request_id: str | None = None,
58
+ ) -> None:
59
+ super().__init__(
60
+ f"Skala request failed with status {status}",
61
+ request_id=request_id,
62
+ )
63
+ self.status = status
64
+ self.body = body
skala/_types.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Literal
5
+
6
+ SkalaEventType = Literal[
7
+ "signup", "login", "trial_start", "checkout", "api_key_create"
8
+ ]
9
+ SkalaDecision = Literal["allow", "step_up", "block"]
10
+ SkalaOutcome = Literal["confirmed_fraud", "false_positive", "converted"]
11
+
12
+
13
+ @dataclass
14
+ class ScoreRequest:
15
+ event_type: SkalaEventType
16
+ ip: str
17
+ email: str
18
+ device_id: str | None = None
19
+ user_agent: str | None = None
20
+ form_fill_ms: int | None = None
21
+ metadata: dict[str, Any] | None = None
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ d: dict[str, Any] = {
25
+ "event_type": self.event_type,
26
+ "ip": self.ip,
27
+ "email": self.email,
28
+ }
29
+ if self.device_id is not None:
30
+ d["device_id"] = self.device_id
31
+ if self.user_agent is not None:
32
+ d["user_agent"] = self.user_agent
33
+ if self.form_fill_ms is not None:
34
+ d["form_fill_ms"] = self.form_fill_ms
35
+ if self.metadata is not None:
36
+ d["metadata"] = self.metadata
37
+ return d
38
+
39
+
40
+ @dataclass
41
+ class ScoreResponse:
42
+ request_id: str
43
+ risk_score: float
44
+ decision: SkalaDecision
45
+ reason_codes: list[str]
46
+ latency_ms: int
47
+ signal_breakdown: dict[str, float] = field(default_factory=dict)
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, Any]) -> ScoreResponse:
51
+ return cls(
52
+ request_id=data["request_id"],
53
+ risk_score=data["risk_score"],
54
+ decision=data["decision"],
55
+ reason_codes=data["reason_codes"],
56
+ latency_ms=data["latency_ms"],
57
+ signal_breakdown=data.get("signal_breakdown") or {},
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class ScoreFallbackResponse(ScoreResponse):
63
+ fallback: bool = True
64
+
65
+
66
+ @dataclass
67
+ class OutcomeRequest:
68
+ request_id: str
69
+ outcome: SkalaOutcome
70
+
71
+ def to_dict(self) -> dict[str, str]:
72
+ return {"request_id": self.request_id, "outcome": self.outcome}
73
+
74
+
75
+ @dataclass
76
+ class OutcomeResponse:
77
+ ok: bool
78
+ identifiers_updated: int
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: dict[str, Any]) -> OutcomeResponse:
82
+ return cls(ok=data["ok"], identifiers_updated=data["identifiers_updated"])
83
+
84
+
85
+ @dataclass
86
+ class SkalaOptions:
87
+ api_key: str
88
+ base_url: str | None = None
89
+ retries: int | None = None
90
+ timeout_ms: int | None = None
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: skalaio
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Skala fraud detection API
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: httpx>=0.27
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Skala Python SDK
11
+
12
+ Python SDK for the Skala fraud detection API.
13
+
14
+ Requires Python 3.11+.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install skalaio
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ from skala import Skala, SkalaOptions, ScoreRequest
26
+
27
+ skala = Skala(SkalaOptions(api_key="sk_live_..."))
28
+
29
+ result = skala.score(ScoreRequest(
30
+ event_type="signup",
31
+ ip=req.ip,
32
+ email=body.email,
33
+ user_agent=req.headers["user-agent"],
34
+ ))
35
+
36
+ if result.decision == "block":
37
+ return {"error": "Request blocked"}, 403
38
+
39
+ if result.decision == "step_up":
40
+ return {"requires_verification": True}, 200
41
+ ```
42
+
43
+ ## Report an Outcome
44
+
45
+ Feed back fraud signals to improve future scoring:
46
+
47
+ ```python
48
+ from skala import OutcomeRequest
49
+
50
+ skala.outcome(OutcomeRequest(
51
+ request_id=result.request_id,
52
+ outcome="confirmed_fraud",
53
+ ))
54
+ ```
55
+
56
+ ## Timeout Fallback
57
+
58
+ `score()` auto-allows when the API is unreachable (timeout or network failure), so scoring does not block user traffic.
59
+
60
+ HTTP API responses (for example 4xx/5xx) still raise errors.
61
+
62
+ ```python
63
+ result = skala.score(...)
64
+
65
+ if hasattr(result, "fallback") and result.fallback:
66
+ # request was auto-allowed by SDK fallback
67
+ # result.reason_codes is SDK_TIMEOUT_FALLBACK or SDK_NETWORK_FALLBACK
68
+ ```
69
+
70
+ ## Error Handling
71
+
72
+ Use structured SDK errors for predictable handling:
73
+
74
+ ```python
75
+ from skala import (
76
+ SkalaApiError,
77
+ SkalaNetworkError,
78
+ SkalaTimeoutError,
79
+ )
80
+
81
+ try:
82
+ skala.outcome(OutcomeRequest(request_id="req_123", outcome="confirmed_fraud"))
83
+ except SkalaTimeoutError:
84
+ # request timed out
85
+ except SkalaNetworkError:
86
+ # API unreachable (DNS/TLS/connectivity)
87
+ except SkalaApiError as e:
88
+ # API returned non-2xx
89
+ print(e.status, e.body, e.request_id)
90
+ ```
91
+
92
+ ## Configuration
93
+
94
+ ```python
95
+ from skala import Skala, SkalaOptions
96
+
97
+ skala = Skala(SkalaOptions(
98
+ api_key="sk_live_...",
99
+ base_url="https://apiskala.varityweb.com", # default
100
+ timeout_ms=5000, # default
101
+ retries=2, # default, only retries 5xx
102
+ ))
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,7 @@
1
+ skala/__init__.py,sha256=_eoMkrKFicliDegSOu18kz_Jax0i0gl8AnJTV_0l69Q,755
2
+ skala/_client.py,sha256=dPFufJHtcaje1CUwVbfpp9OvMxmeOcyHrsyFZVF1aVE,4838
3
+ skala/_errors.py,sha256=6i19YPazwhvfSSF6hID4coGo4ab4YmafHNJpS4LhwV0,1500
4
+ skala/_types.py,sha256=WZZhTJMPtWzinjNQOu4_s_3P3YNq4HRiKSAWDd-swwA,2392
5
+ skalaio-0.2.0.dist-info/METADATA,sha256=TNMiSb9Ia7CutrCkZfl-klvjgSN5Dyur5th-8yu95qo,2254
6
+ skalaio-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ skalaio-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any