spamkit-api 0.1.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.
spamkit/client.py ADDED
@@ -0,0 +1,174 @@
1
+ """Python client for the Spamkit v1 API. Needs only the base install (httpx + pydantic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from types import TracebackType
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from spamkit.schemas import InfoResponse, ReadyResponse, ScoreResult
14
+
15
+ RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
16
+
17
+
18
+ class SpamkitError(Exception):
19
+ """An error response from the service. ``code`` is one of the stable codes in ``ErrorCode``."""
20
+
21
+ def __init__(self, code: str, message: str, status: int | None = None, request_id: str | None = None) -> None:
22
+ super().__init__(f"{code}: {message}")
23
+ self.code = code
24
+ self.message = message
25
+ self.status = status
26
+ self.request_id = request_id
27
+
28
+
29
+ class SpamkitTransportError(SpamkitError):
30
+ """The request never produced an HTTP response (connection refused, reset, DNS failure)."""
31
+
32
+ def __init__(self, message: str) -> None:
33
+ super().__init__("transport_error", message)
34
+
35
+
36
+ class SpamkitTimeoutError(SpamkitTransportError):
37
+ """The request timed out before a response arrived."""
38
+
39
+ def __init__(self, message: str) -> None:
40
+ super().__init__(message)
41
+ self.code = "timeout"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ItemError:
46
+ code: str
47
+ message: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BatchOutcome:
52
+ """One entry per submitted item, in input order. Exactly one of ``result`` or ``error`` is set."""
53
+
54
+ id: str | None
55
+ result: ScoreResult | None = None
56
+ error: ItemError | None = None
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.result is not None
61
+
62
+
63
+ def _api_error(response: httpx.Response) -> SpamkitError:
64
+ # Never include the response body in the message: it may echo submitted text.
65
+ try:
66
+ body = response.json()["error"]
67
+ return SpamkitError(
68
+ code=str(body["code"]),
69
+ message=str(body["message"]),
70
+ status=response.status_code,
71
+ request_id=body.get("request_id"),
72
+ )
73
+ except (ValueError, KeyError, TypeError):
74
+ return SpamkitError(
75
+ code="unknown_error",
76
+ message=f"Unexpected HTTP {response.status_code} response.",
77
+ status=response.status_code,
78
+ )
79
+
80
+
81
+ class SpamkitClient:
82
+ """Synchronous client. Retries are opt-in; scoring is read-only, so a retry cannot duplicate side effects."""
83
+
84
+ def __init__(
85
+ self,
86
+ base_url: str,
87
+ *,
88
+ timeout: float = 5.0,
89
+ api_token: str | None = None,
90
+ max_retries: int = 0,
91
+ retry_backoff: float = 0.25,
92
+ transport: httpx.BaseTransport | None = None,
93
+ ) -> None:
94
+ if max_retries < 0:
95
+ raise ValueError("max_retries must be zero or greater")
96
+ headers = {"Authorization": f"Bearer {api_token}"} if api_token else {}
97
+ self._http = httpx.Client(
98
+ base_url=base_url.rstrip("/"),
99
+ timeout=timeout,
100
+ headers=headers,
101
+ transport=transport,
102
+ )
103
+ self._max_retries = max_retries
104
+ self._retry_backoff = retry_backoff
105
+
106
+ def __enter__(self) -> SpamkitClient: # noqa: PYI034 - returns the instance for context managers
107
+ return self
108
+
109
+ def __exit__(
110
+ self,
111
+ exc_type: type[BaseException] | None,
112
+ exc: BaseException | None,
113
+ traceback: TracebackType | None,
114
+ ) -> None:
115
+ self.close()
116
+
117
+ def close(self) -> None:
118
+ self._http.close()
119
+
120
+ def score(self, text: str, *, id: str | None = None, context: str | None = None) -> ScoreResult:
121
+ payload = _drop_none({"text": text, "id": id, "context": context})
122
+ response = self._request("POST", "/v1/score", payload)
123
+ return ScoreResult.model_validate(response.json())
124
+
125
+ def score_batch(self, items: Sequence[Mapping[str, Any]]) -> list[BatchOutcome]:
126
+ """Each item is a mapping with ``text`` and optionally ``id`` and ``context``."""
127
+ response = self._request("POST", "/v1/score/batch", {"items": [dict(item) for item in items]})
128
+ outcomes: list[BatchOutcome] = []
129
+ for entry in response.json()["results"]:
130
+ if entry.get("ok"):
131
+ outcomes.append(BatchOutcome(id=entry["result"].get("id"), result=ScoreResult.model_validate(entry["result"])))
132
+ else:
133
+ error = entry["error"]
134
+ outcomes.append(
135
+ BatchOutcome(id=entry.get("id"), error=ItemError(code=error["code"], message=error["message"]))
136
+ )
137
+ return outcomes
138
+
139
+ def health(self) -> bool:
140
+ return self._request("GET", "/healthz").is_success
141
+
142
+ def ready(self) -> ReadyResponse:
143
+ return ReadyResponse.model_validate(self._request("GET", "/readyz").json())
144
+
145
+ def info(self) -> InfoResponse:
146
+ return InfoResponse.model_validate(self._request("GET", "/v1/info").json())
147
+
148
+ def _request(self, method: str, path: str, json_body: Any = None) -> httpx.Response:
149
+ attempts = self._max_retries + 1
150
+ for attempt in range(attempts):
151
+ error: SpamkitError
152
+ try:
153
+ response = self._http.request(method, path, json=json_body)
154
+ except httpx.TimeoutException as exc:
155
+ error = SpamkitTimeoutError(str(type(exc).__name__))
156
+ except httpx.TransportError as exc:
157
+ error = SpamkitTransportError(str(type(exc).__name__))
158
+ else:
159
+ if response.is_success:
160
+ return response
161
+ error = _api_error(response)
162
+ if response.status_code not in RETRYABLE_STATUS:
163
+ raise error
164
+ if attempt == attempts - 1:
165
+ raise error
166
+ time.sleep(self._retry_delay(attempt))
167
+ raise AssertionError("unreachable") # the loop always returns or raises
168
+
169
+ def _retry_delay(self, attempt: int) -> float:
170
+ return self._retry_backoff * (2**attempt)
171
+
172
+
173
+ def _drop_none(values: dict[str, Any]) -> dict[str, Any]:
174
+ return {key: value for key, value in values.items() if value is not None}
spamkit/errors.py ADDED
@@ -0,0 +1,40 @@
1
+ """Stable error codes shared by the server, the Python client, and the TypeScript client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class ErrorCode(str, Enum):
9
+ INVALID_REQUEST = "invalid_request"
10
+ UNAUTHORIZED = "unauthorized"
11
+ INPUT_TOO_LARGE = "input_too_large"
12
+ UNSUPPORTED_INPUT = "unsupported_input"
13
+ RATE_LIMITED = "rate_limited"
14
+ MODEL_NOT_READY = "model_not_ready"
15
+ INTERNAL_ERROR = "internal_error"
16
+
17
+
18
+ HTTP_STATUS: dict[ErrorCode, int] = {
19
+ ErrorCode.INVALID_REQUEST: 400,
20
+ ErrorCode.UNAUTHORIZED: 401,
21
+ ErrorCode.INPUT_TOO_LARGE: 413,
22
+ ErrorCode.UNSUPPORTED_INPUT: 422,
23
+ ErrorCode.RATE_LIMITED: 429,
24
+ ErrorCode.MODEL_NOT_READY: 503,
25
+ ErrorCode.INTERNAL_ERROR: 500,
26
+ }
27
+
28
+
29
+ class ApiError(Exception):
30
+ """An error with a stable code. The message must never contain submitted text."""
31
+
32
+ def __init__(self, code: ErrorCode, message: str, headers: dict[str, str] | None = None) -> None:
33
+ super().__init__(message)
34
+ self.code = code
35
+ self.message = message
36
+ self.headers = headers or {}
37
+
38
+ @property
39
+ def status(self) -> int:
40
+ return HTTP_STATUS[self.code]
spamkit/evaluation.py ADDED
@@ -0,0 +1,180 @@
1
+ """Offline evaluation on a user's own labeled file. Nothing here uploads data or retains text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ DEFAULT_SWEEP = tuple(round(step / 20, 2) for step in range(1, 20)) # 0.05 .. 0.95
13
+
14
+
15
+ class EvaluationInputError(ValueError):
16
+ """Raised for malformed input. Messages name the row, never its text."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Sample:
21
+ row: int
22
+ text: str
23
+ label: str # "spam" or "clean"
24
+ id: str | None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Confusion:
29
+ tp: int
30
+ fp: int
31
+ tn: int
32
+ fn: int
33
+
34
+
35
+ def load_samples(path: Path) -> list[Sample]:
36
+ suffix = path.suffix.lower()
37
+ if suffix == ".csv":
38
+ raw_rows = _read_csv(path)
39
+ elif suffix == ".jsonl":
40
+ raw_rows = _read_jsonl(path)
41
+ else:
42
+ raise EvaluationInputError("Input must be a .csv or .jsonl file.")
43
+ if not raw_rows:
44
+ raise EvaluationInputError("The file contains no data rows.")
45
+ return [_to_sample(row, number) for number, row in raw_rows]
46
+
47
+
48
+ def _read_csv(path: Path) -> list[tuple[int, dict[str, Any]]]:
49
+ with path.open(newline="", encoding="utf-8") as handle:
50
+ reader = csv.DictReader(handle)
51
+ fields = {name.strip() for name in (reader.fieldnames or []) if name}
52
+ missing = {"text", "label"} - fields
53
+ if missing:
54
+ raise EvaluationInputError(f"CSV is missing required column(s): {', '.join(sorted(missing))}.")
55
+ # Row numbers are physical file lines, so they match what an editor shows.
56
+ rows = []
57
+ for row in reader:
58
+ rows.append((reader.line_num, row))
59
+ return rows
60
+
61
+
62
+ def _read_jsonl(path: Path) -> list[tuple[int, dict[str, Any]]]:
63
+ # Line numbers, not record counts: blank lines are skipped but still counted.
64
+ rows: list[tuple[int, dict[str, Any]]] = []
65
+ with path.open(encoding="utf-8") as handle:
66
+ for number, line in enumerate(handle, start=1):
67
+ if not line.strip():
68
+ continue
69
+ try:
70
+ value = json.loads(line)
71
+ except ValueError as exc:
72
+ raise EvaluationInputError(f"row {number}: line is not valid JSON.") from exc
73
+ if not isinstance(value, dict):
74
+ raise EvaluationInputError(f"row {number}: each line must be a JSON object.")
75
+ rows.append((number, value))
76
+ return rows
77
+
78
+
79
+ def _to_sample(row: dict[str, Any], number: int) -> Sample:
80
+ text = row.get("text")
81
+ if not isinstance(text, str) or not text.strip():
82
+ raise EvaluationInputError(f"row {number}: text is empty or missing.")
83
+ label = str(row.get("label", "")).strip().lower()
84
+ # "ham" is accepted because most public spam datasets use it. Internally the class is "clean".
85
+ label = "clean" if label == "ham" else label
86
+ if label not in {"spam", "clean"}:
87
+ raise EvaluationInputError(f"row {number}: label must be 'spam' or 'clean'.")
88
+ raw_id = row.get("id")
89
+ sample_id = str(raw_id).strip() if raw_id not in (None, "") else None
90
+ return Sample(row=number, text=text, label=label, id=sample_id)
91
+
92
+
93
+ def confusion_at(samples: Sequence[Sample], scores: Sequence[float], threshold: float) -> Confusion:
94
+ """Predicted spam when score >= threshold. Review is not modeled here; see the sweep for trade-offs."""
95
+ tp = fp = tn = fn = 0
96
+ for sample, score in zip(samples, scores):
97
+ predicted_spam = score >= threshold
98
+ actual_spam = sample.label == "spam"
99
+ if predicted_spam and actual_spam:
100
+ tp += 1
101
+ elif predicted_spam:
102
+ fp += 1
103
+ elif actual_spam:
104
+ fn += 1
105
+ else:
106
+ tn += 1
107
+ return Confusion(tp=tp, fp=fp, tn=tn, fn=fn)
108
+
109
+
110
+ def _ratio(numerator: int, denominator: int) -> float | None:
111
+ return None if denominator == 0 else numerator / denominator
112
+
113
+
114
+ def metrics_for(matrix: Confusion) -> dict[str, float | None]:
115
+ precision = _ratio(matrix.tp, matrix.tp + matrix.fp)
116
+ recall = _ratio(matrix.tp, matrix.tp + matrix.fn)
117
+ f1 = None
118
+ if precision is not None and recall is not None and precision + recall > 0:
119
+ f1 = 2 * precision * recall / (precision + recall)
120
+ return {
121
+ "precision": precision,
122
+ "recall": recall,
123
+ "specificity": _ratio(matrix.tn, matrix.tn + matrix.fp),
124
+ "false_positive_rate": _ratio(matrix.fp, matrix.fp + matrix.tn),
125
+ "false_negative_rate": _ratio(matrix.fn, matrix.fn + matrix.tp),
126
+ "f1": f1,
127
+ }
128
+
129
+
130
+ def build_report(
131
+ samples: Sequence[Sample],
132
+ scores: Sequence[float],
133
+ *,
134
+ threshold: float,
135
+ sweep: Sequence[float] = DEFAULT_SWEEP,
136
+ model: str,
137
+ model_version: str,
138
+ software_version: str,
139
+ show_text: bool = False,
140
+ ) -> dict[str, Any]:
141
+ if len(samples) != len(scores):
142
+ raise ValueError("samples and scores must have the same length")
143
+
144
+ matrix = confusion_at(samples, scores, threshold)
145
+ spam_count = sum(1 for sample in samples if sample.label == "spam")
146
+ false_positives = []
147
+ false_negatives = []
148
+ for sample, score in zip(samples, scores):
149
+ predicted_spam = score >= threshold
150
+ if predicted_spam and sample.label == "clean":
151
+ false_positives.append(_error_row(sample, score, show_text))
152
+ elif not predicted_spam and sample.label == "spam":
153
+ false_negatives.append(_error_row(sample, score, show_text))
154
+
155
+ return {
156
+ "dataset": {
157
+ "count": len(samples),
158
+ "spam": spam_count,
159
+ "clean": len(samples) - spam_count,
160
+ "note": "Metrics describe only the supplied dataset.",
161
+ },
162
+ "model": {"id": model, "version": model_version},
163
+ "software_version": software_version,
164
+ "threshold": threshold,
165
+ "confusion": {"tp": matrix.tp, "fp": matrix.fp, "tn": matrix.tn, "fn": matrix.fn},
166
+ "metrics": metrics_for(matrix),
167
+ "sweep": [
168
+ {"threshold": point, **metrics_for(confusion_at(samples, scores, point))}
169
+ for point in sweep
170
+ ],
171
+ "false_positives": false_positives,
172
+ "false_negatives": false_negatives,
173
+ }
174
+
175
+
176
+ def _error_row(sample: Sample, score: float, show_text: bool) -> dict[str, Any]:
177
+ entry: dict[str, Any] = {"row": sample.row, "id": sample.id, "score": score}
178
+ if show_text:
179
+ entry["text"] = sample.text
180
+ return entry
@@ -0,0 +1,86 @@
1
+ """Structured logs and metrics. Neither ever receives submitted text or caller IDs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ import time
9
+ from collections import Counter
10
+
11
+ from spamkit.verdict import Verdict
12
+
13
+ logger = logging.getLogger("spamkit")
14
+
15
+ LATENCY_BUCKETS_MS = (5, 10, 25, 50, 100, 250, 500, 1000, 2500)
16
+
17
+
18
+ def log_event(event: str, **fields: object) -> None:
19
+ record = {"event": event, "ts": round(time.time(), 3), **fields}
20
+ logger.info(json.dumps(record, sort_keys=True))
21
+
22
+
23
+ def score_band(score: float) -> str:
24
+ """Coarse bucket for logs. Exact scores are kept out of logs to limit fingerprinting."""
25
+ low = min(int(score * 10), 9)
26
+ return f"{low / 10:.1f}-{(low + 1) / 10:.1f}"
27
+
28
+
29
+ class Metrics:
30
+ """Process-local counters and a latency histogram, rendered in Prometheus text format."""
31
+
32
+ def __init__(self) -> None:
33
+ self._lock = threading.Lock()
34
+ self._requests: Counter[tuple[str, int]] = Counter()
35
+ self._verdicts: Counter[str] = Counter()
36
+ self._errors: Counter[str] = Counter()
37
+ self._latency_bucket_counts = [0] * (len(LATENCY_BUCKETS_MS) + 1)
38
+ self._latency_sum_ms = 0.0
39
+ self._latency_count = 0
40
+
41
+ def record_request(self, route: str, status: int) -> None:
42
+ with self._lock:
43
+ self._requests[(route, status)] += 1
44
+
45
+ def record_verdict(self, verdict: Verdict) -> None:
46
+ with self._lock:
47
+ self._verdicts[verdict.value] += 1
48
+
49
+ def record_error(self, code: str) -> None:
50
+ with self._lock:
51
+ self._errors[code] += 1
52
+
53
+ def record_latency(self, milliseconds: float) -> None:
54
+ index = next(
55
+ (i for i, bound in enumerate(LATENCY_BUCKETS_MS) if milliseconds <= bound),
56
+ len(LATENCY_BUCKETS_MS),
57
+ )
58
+ with self._lock:
59
+ self._latency_bucket_counts[index] += 1
60
+ self._latency_sum_ms += milliseconds
61
+ self._latency_count += 1
62
+
63
+ def render(self) -> str:
64
+ lines: list[str] = []
65
+ with self._lock:
66
+ lines.append("# TYPE spamkit_requests_total counter")
67
+ for (route, status), count in sorted(self._requests.items()):
68
+ lines.append(f'spamkit_requests_total{{route="{route}",status="{status}"}} {count}')
69
+ lines.append("# TYPE spamkit_verdicts_total counter")
70
+ for verdict in Verdict:
71
+ lines.append(
72
+ f'spamkit_verdicts_total{{verdict="{verdict.value}"}} {self._verdicts[verdict.value]}'
73
+ )
74
+ lines.append("# TYPE spamkit_errors_total counter")
75
+ for code, count in sorted(self._errors.items()):
76
+ lines.append(f'spamkit_errors_total{{code="{code}"}} {count}')
77
+ lines.append("# TYPE spamkit_score_latency_ms histogram")
78
+ cumulative = 0
79
+ for bound, count in zip(LATENCY_BUCKETS_MS, self._latency_bucket_counts):
80
+ cumulative += count
81
+ lines.append(f'spamkit_score_latency_ms_bucket{{le="{bound}"}} {cumulative}')
82
+ cumulative += self._latency_bucket_counts[-1]
83
+ lines.append(f'spamkit_score_latency_ms_bucket{{le="+Inf"}} {cumulative}')
84
+ lines.append(f"spamkit_score_latency_ms_sum {self._latency_sum_ms:.3f}")
85
+ lines.append(f"spamkit_score_latency_ms_count {self._latency_count}")
86
+ return "\n".join(lines) + "\n"
spamkit/schemas.py ADDED
@@ -0,0 +1,121 @@
1
+ """Wire types for the v1 API, plus request validation that never echoes submitted text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from spamkit.errors import ApiError, ErrorCode
11
+ from spamkit.verdict import Verdict
12
+
13
+ API_VERSION = "v1"
14
+ MAX_ID_CHARS = 256
15
+ MAX_CONTEXT_CHARS = 64
16
+
17
+
18
+ class ScoreResult(BaseModel):
19
+ id: str | None
20
+ score: float
21
+ verdict: Verdict
22
+ model: str
23
+ model_version: str
24
+ latency_ms: int
25
+ truncated: bool
26
+
27
+
28
+ class ErrorBody(BaseModel):
29
+ code: ErrorCode
30
+ message: str
31
+ request_id: str
32
+
33
+
34
+ class ErrorResponse(BaseModel):
35
+ error: ErrorBody
36
+
37
+
38
+ class BatchItemOk(BaseModel):
39
+ ok: Literal[True] = True
40
+ result: ScoreResult
41
+
42
+
43
+ class BatchItemError(BaseModel):
44
+ ok: Literal[False] = False
45
+ id: str | None
46
+ error: ErrorBody
47
+
48
+
49
+ class BatchResponse(BaseModel):
50
+ results: list[BatchItemOk | BatchItemError]
51
+
52
+
53
+ class HealthResponse(BaseModel):
54
+ status: Literal["ok"]
55
+
56
+
57
+ class ReadyResponse(BaseModel):
58
+ status: Literal["ready"]
59
+ model: str
60
+ model_version: str
61
+
62
+
63
+ class InfoResponse(BaseModel):
64
+ api_version: str
65
+ model: str
66
+ model_version: str | None
67
+ max_tokens: int
68
+ max_text_bytes: int
69
+ max_batch_items: int
70
+ thresholds: dict[str, float]
71
+ threshold_config_version: str
72
+ service_version: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class ScoreInput:
77
+ text: str
78
+ id: str | None
79
+ context: str | None
80
+
81
+
82
+ def require_utf8(value: str, field: str) -> str:
83
+ """JSON allows lone surrogates such as \\ud800. They cannot be encoded as UTF-8, so reject them."""
84
+ try:
85
+ value.encode("utf-8")
86
+ except UnicodeEncodeError as exc:
87
+ raise ApiError(ErrorCode.UNSUPPORTED_INPUT, f"{field} must be valid UTF-8 without lone surrogates.") from exc
88
+ return value
89
+
90
+
91
+ def _optional_short_string(value: Any, field: str, limit: int) -> str | None:
92
+ if value is None:
93
+ return None
94
+ if not isinstance(value, str):
95
+ raise ApiError(ErrorCode.INVALID_REQUEST, f"{field} must be a string when provided.")
96
+ if len(value) > limit:
97
+ raise ApiError(ErrorCode.INVALID_REQUEST, f"{field} must be at most {limit} characters.")
98
+ return require_utf8(value, field)
99
+
100
+
101
+ def parse_score_input(payload: Any, max_text_bytes: int) -> ScoreInput:
102
+ if not isinstance(payload, dict):
103
+ raise ApiError(ErrorCode.INVALID_REQUEST, "Request body must be a JSON object.")
104
+
105
+ text = payload.get("text")
106
+ if not isinstance(text, str) or not text.strip():
107
+ raise ApiError(ErrorCode.INVALID_REQUEST, "text is required and must be a non-empty string.")
108
+ require_utf8(text, "text")
109
+ if len(text.encode("utf-8")) > max_text_bytes:
110
+ raise ApiError(
111
+ ErrorCode.INPUT_TOO_LARGE,
112
+ f"text must be at most {max_text_bytes} bytes of UTF-8.",
113
+ )
114
+ if "\x00" in text:
115
+ raise ApiError(ErrorCode.UNSUPPORTED_INPUT, "text must not contain NUL characters.")
116
+
117
+ return ScoreInput(
118
+ text=text,
119
+ id=_optional_short_string(payload.get("id"), "id", MAX_ID_CHARS),
120
+ context=_optional_short_string(payload.get("context"), "context", MAX_CONTEXT_CHARS),
121
+ )
spamkit/settings.py ADDED
@@ -0,0 +1,67 @@
1
+ """Runtime configuration, read from ``SPAMKIT_*`` environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from spamkit.verdict import Thresholds
11
+
12
+ DEFAULT_MODEL_DIR = Path("models/spamton")
13
+
14
+
15
+ def _int(env: Mapping[str, str], name: str, default: int) -> int:
16
+ raw = env.get(name)
17
+ if raw is None or raw == "":
18
+ return default
19
+ try:
20
+ value = int(raw)
21
+ except ValueError as exc:
22
+ raise ValueError(f"{name} must be an integer") from exc
23
+ if value <= 0:
24
+ raise ValueError(f"{name} must be greater than zero")
25
+ return value
26
+
27
+
28
+ def _float(env: Mapping[str, str], name: str, default: float) -> float:
29
+ raw = env.get(name)
30
+ if raw is None or raw == "":
31
+ return default
32
+ try:
33
+ return float(raw)
34
+ except ValueError as exc:
35
+ raise ValueError(f"{name} must be a number") from exc
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Settings:
40
+ model_dir: Path = DEFAULT_MODEL_DIR
41
+ host: str = "127.0.0.1"
42
+ port: int = 8080
43
+ thresholds: Thresholds = field(default_factory=Thresholds)
44
+ max_text_bytes: int = 16_384
45
+ max_body_bytes: int = 1_048_576
46
+ max_batch_items: int = 100
47
+ max_inflight: int = 32
48
+ api_token: str | None = None
49
+
50
+ @classmethod
51
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
52
+ env = os.environ if env is None else env
53
+ token = env.get("SPAMKIT_API_TOKEN") or None
54
+ return cls(
55
+ model_dir=Path(env.get("SPAMKIT_MODEL_DIR") or DEFAULT_MODEL_DIR),
56
+ host=env.get("SPAMKIT_HOST") or "127.0.0.1",
57
+ port=_int(env, "SPAMKIT_PORT", 8080),
58
+ thresholds=Thresholds(
59
+ clean=_float(env, "SPAMKIT_CLEAN_THRESHOLD", 0.0),
60
+ spam=_float(env, "SPAMKIT_SPAM_THRESHOLD", 1.0),
61
+ ),
62
+ max_text_bytes=_int(env, "SPAMKIT_MAX_TEXT_BYTES", 16_384),
63
+ max_body_bytes=_int(env, "SPAMKIT_MAX_BODY_BYTES", 1_048_576),
64
+ max_batch_items=_int(env, "SPAMKIT_MAX_BATCH_ITEMS", 100),
65
+ max_inflight=_int(env, "SPAMKIT_MAX_INFLIGHT", 32),
66
+ api_token=token,
67
+ )