pace-python 1.0.1__cp38-abi3-win_amd64.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.
pace_sdk/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ # pace_sdk/__init__.py
2
+
3
+ # 1. Export the Core Engine
4
+ from .core import Pace
5
+
6
+ # 2. Export the Types
7
+ from .types import PaceConfig, ProtectionMode, Algorithm
8
+
9
+ # 3. Export the Framework Helpers
10
+ from .helpers import PaceFastAPI, pace_django
11
+
12
+ # Define exactly what gets imported when someone uses `from pace_sdk import *`
13
+ __all__ = [
14
+ "Pace",
15
+ "PaceConfig",
16
+ "ProtectionMode",
17
+ "Algorithm",
18
+ "PaceFastAPI",
19
+ "pace_django"
20
+ ]
pace_sdk/client.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from .pace_native import RustEngine
4
+ from .logger import log_decision
5
+ from .types import CheckResult, PaceConfig, CanonicalDecision, TrafficDecision, DecisionReason, Algorithm
6
+
7
+ class Pace:
8
+ def __init__(self, config: PaceConfig):
9
+ self.config = config
10
+ self.log_mode = config.debug
11
+ if os.environ.get("PACE_DEBUG") == "true":
12
+ print("[Pace] ⚡ Rust engine loaded")
13
+
14
+ debug_str = config.debug if isinstance(config.debug, str) else ("compact" if config.debug else None)
15
+
16
+ self._native_engine = RustEngine(
17
+ config.algorithm.value,
18
+ config.mode.value,
19
+ float(config.capacity),
20
+ float(config.refill_rate),
21
+ config.api_key,
22
+ debug_str,
23
+ config.backend_url
24
+ )
25
+
26
+ def check(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
27
+ # Pass everything to Rust. No Python math, no locks, no state dicts!
28
+ res = self._native_engine.check(ip, route, key)
29
+
30
+ dec_data = res.get("decision", {})
31
+ decision = CanonicalDecision(
32
+ decision=TrafficDecision(dec_data.get("decision", "allow")),
33
+ reason=DecisionReason(dec_data.get("reason", "within_limit")),
34
+ algorithm=Algorithm(dec_data.get("algorithm", "token_bucket")),
35
+ route=dec_data.get("route", route),
36
+ ip=ip,
37
+ key=key or ip,
38
+ remaining=dec_data.get("remaining"),
39
+ latency_ms=dec_data.get("latency_ms", 0),
40
+ mode=self.config.mode
41
+ )
42
+
43
+ result = CheckResult(
44
+ allowed=res.get("allowed", True),
45
+ would_block=res.get("would_block", False),
46
+ reason=res.get("reason"),
47
+ decision=decision
48
+ )
49
+
50
+ log_decision(self.log_mode, decision)
51
+ return result
52
+
53
+ def check_detailed(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
54
+ return self.check(ip, route, key)
55
+
56
+ def check_with_key(self, key: str, ip: str, route: str = "/") -> CheckResult:
57
+ return self.check(ip, route, key)
pace_sdk/core.py ADDED
@@ -0,0 +1,62 @@
1
+ # sdk-python/pace_sdk/core.py
2
+ from __future__ import annotations
3
+ import os
4
+ import threading
5
+ from typing import Optional
6
+ from .pace_native import RustEngine
7
+ from .logger import log_decision
8
+ from .types import CheckResult, PaceConfig, CanonicalDecision, TrafficDecision, DecisionReason, Algorithm
9
+
10
+ class Pace:
11
+ def __init__(self, config: PaceConfig):
12
+ self.config = config
13
+ self._lock = threading.Lock()
14
+ self.log_mode = config.debug
15
+ if os.environ.get("PACE_DEBUG") == "true":
16
+ print("[Pace] ⚡ Rust engine loaded")
17
+
18
+ debug_str = config.debug if isinstance(config.debug, str) else ("compact" if config.debug else None)
19
+
20
+ self._native_engine = RustEngine(
21
+ config.algorithm.value,
22
+ config.mode.value,
23
+ float(config.capacity),
24
+ float(config.refill_rate),
25
+ config.api_key,
26
+ debug_str,
27
+ config.backend_url
28
+ )
29
+
30
+ def check(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
31
+ with self._lock:
32
+ # Pass everything to Rust. No Python math, no locks, no state dicts!
33
+ res = self._native_engine.check(ip, route, key)
34
+
35
+ dec_data = res.get("decision", {})
36
+ decision = CanonicalDecision(
37
+ decision=TrafficDecision(dec_data.get("decision", "allow")),
38
+ reason=DecisionReason(dec_data.get("reason", "within_limit")),
39
+ algorithm=Algorithm(dec_data.get("algorithm", "token_bucket")),
40
+ route=dec_data.get("route", route),
41
+ ip=ip,
42
+ key=key or ip,
43
+ remaining=dec_data.get("remaining"),
44
+ latency_ms=dec_data.get("latency_ms", 0),
45
+ mode=self.config.mode
46
+ )
47
+
48
+ result = CheckResult(
49
+ allowed=res.get("allowed", True),
50
+ would_block=res.get("would_block", False),
51
+ reason=res.get("reason"),
52
+ decision=decision
53
+ )
54
+
55
+ log_decision(self.log_mode, decision)
56
+ return result
57
+
58
+ def check_detailed(self, ip: str, route: str = "/", key: Optional[str] = None) -> CheckResult:
59
+ return self.check(ip, route, key)
60
+
61
+ def check_with_key(self, key: str, ip: str, route: str = "/") -> CheckResult:
62
+ return self.check(ip, route, key)
pace_sdk/helpers.py ADDED
@@ -0,0 +1,32 @@
1
+ # pace_sdk/helpers.py
2
+ from typing import Callable
3
+ from functools import wraps
4
+
5
+ class PaceFastAPI:
6
+ def __init__(self, pace_engine):
7
+ self.pace = pace_engine
8
+
9
+ def limit(self, route: str = "/"):
10
+ from fastapi import Request, HTTPException
11
+ def _dependency(request: Request):
12
+ client_ip = request.client.host if request.client else "127.0.0.1"
13
+ result = self.pace.check(ip=client_ip, route=route)
14
+ if not result.allowed:
15
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
16
+ return _dependency
17
+
18
+ def pace_django(pace_engine, route: str = "/"):
19
+ def decorator(view_func: Callable) -> Callable:
20
+ @wraps(view_func)
21
+ def _wrapped_view(request, *args, **kwargs):
22
+ from django.http import JsonResponse
23
+
24
+ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
25
+ client_ip = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR', '127.0.0.1')
26
+
27
+ result = pace_engine.check(ip=client_ip, route=route)
28
+ if not result.allowed:
29
+ return JsonResponse({"error": "Rate limit exceeded"}, status=429)
30
+ return view_func(request, *args, **kwargs)
31
+ return _wrapped_view
32
+ return decorator
pace_sdk/logger.py ADDED
@@ -0,0 +1,64 @@
1
+ from typing import Union
2
+ from .types import CanonicalDecision, TrafficDecision
3
+
4
+ # Global Color Definitions
5
+ RED = "\033[91m"
6
+ GREEN = "\033[92m"
7
+ YELLOW = "\033[93m"
8
+ RESET = "\033[0m"
9
+
10
+ def should_ignore_route(route: str) -> bool:
11
+ return route in {"/favicon.ico", "/health", "/api/health", "/healthz", "/ping", "/api/ping"} or route.startswith("/_next")
12
+
13
+ def _format_duration_ms(ms: int) -> str:
14
+ if ms <= 0: return "0ms"
15
+ if ms % 3_600_000 == 0: return f"{ms // 3_600_000}h"
16
+ if ms % 60_000 == 0: return f"{ms // 60_000}m"
17
+ if ms % 1000 == 0: return f"{ms // 1000}s"
18
+ return f"{ms}ms"
19
+
20
+ def log_decision(debug_mode: Union[bool, str], decision: CanonicalDecision) -> None:
21
+ if not debug_mode or should_ignore_route(decision.route):
22
+ return
23
+
24
+ # 1. COMPACT MODE
25
+ if debug_mode == "compact" or debug_mode is True:
26
+ if decision.decision == TrafficDecision.ALLOW:
27
+ headline = f"{GREEN}ALLOW{RESET}"
28
+ else:
29
+ headline = f"{RED}BLOCK{RESET}"
30
+
31
+ parts = [f"[Pace] {headline}", f"route={decision.route}"]
32
+ if decision.remaining is not None: parts.append(f"remaining={decision.remaining}")
33
+ if decision.latency_ms is not None: parts.append(f"latency={decision.latency_ms}ms")
34
+ print(" ".join(parts))
35
+ return
36
+
37
+ # 2. PRETTY MODE
38
+ if debug_mode == "pretty":
39
+ if decision.decision == TrafficDecision.ALLOW:
40
+ print(f"{GREEN}[Pace] 🟢 ALLOWED{RESET}")
41
+ print(f"│ Route: {decision.route}")
42
+ if decision.remaining is not None:
43
+ print(f"│ Remaining: {decision.remaining}")
44
+ if decision.latency_ms is not None:
45
+ print(f"│ Latency: {decision.latency_ms}ms")
46
+ print(f"└────────────────────────────────────────")
47
+ else:
48
+ headline = "🔴 BLOCKED" if decision.decision == TrafficDecision.BLOCK else "🟡 WOULD BLOCK"
49
+ color = RED if decision.decision == TrafficDecision.BLOCK else YELLOW
50
+
51
+ print(f"{color}[Pace] {headline}{RESET}")
52
+ print(f"│ Route: {decision.route}")
53
+ print(f"│ IP: {decision.ip or 'unknown'}")
54
+ print(f"│ Algorithm: {decision.algorithm.value}")
55
+ print(f"│ Reason: {decision.reason.value}")
56
+ if decision.mode:
57
+ print(f"│ Mode: {decision.mode.value}")
58
+ if decision.remaining is not None:
59
+ print(f"│ Remaining: {decision.remaining}")
60
+ if decision.refill_ms:
61
+ print(f"│ Refill: {_format_duration_ms(decision.refill_ms)}")
62
+ if decision.latency_ms is not None:
63
+ print(f"│ Latency: {decision.latency_ms}ms")
64
+ print(f"└────────────────────────────────────────")
pace_sdk/middleware.py ADDED
@@ -0,0 +1,49 @@
1
+ from functools import wraps
2
+
3
+ from .client import Pace
4
+ from .types import PaceConfig
5
+
6
+
7
+ def flask_middleware(pace: Pace):
8
+ """Flask decorator"""
9
+
10
+ def decorator(f):
11
+ @wraps(f)
12
+ def wrapper(*args, **kwargs):
13
+ from flask import jsonify, request
14
+
15
+ ip = request.headers.get("X-Forwarded-For", request.remote_addr)
16
+ identity = request.headers.get(pace.config.identity_header) if pace.config.identity_header else None
17
+ result = pace.check_with_key(identity or "", ip, request.path)
18
+ if not result.allowed:
19
+ return jsonify({"message": "Rate limit exceeded"}), 429
20
+ return f(*args, **kwargs)
21
+
22
+ return wrapper
23
+
24
+ return decorator
25
+
26
+
27
+ class FastAPIMiddleware:
28
+ """FastAPI middleware"""
29
+
30
+ def __init__(self, app, config: PaceConfig):
31
+ self.app = app
32
+ self.pace = Pace(config)
33
+
34
+ async def __call__(self, scope, receive, send):
35
+ if scope["type"] == "http":
36
+ from starlette.requests import Request
37
+ from starlette.responses import JSONResponse
38
+
39
+ request = Request(scope, receive)
40
+ ip = request.headers.get(
41
+ "x-forwarded-for", request.client.host if request.client else "unknown"
42
+ )
43
+ identity = request.headers.get(self.pace.config.identity_header) if self.pace.config.identity_header else None
44
+ result = self.pace.check_with_key(identity or "", ip, request.url.path)
45
+ if not result.allowed:
46
+ response = JSONResponse({"message": "Rate limit exceeded"}, status_code=429)
47
+ await response(scope, receive, send)
48
+ return
49
+ await self.app(scope, receive, send)
Binary file
pace_sdk/telemetry.py ADDED
@@ -0,0 +1,72 @@
1
+ import threading
2
+ import time
3
+ from dataclasses import asdict, is_dataclass
4
+ from enum import Enum
5
+ from typing import List
6
+
7
+ import requests
8
+
9
+ from .types import CanonicalTelemetryEvent
10
+
11
+
12
+ class TelemetryQueue:
13
+ def __init__(self, api_key: str, backend_url: str, enabled: bool):
14
+ self.api_key = api_key
15
+ self.backend_url = backend_url
16
+ self.enabled = enabled
17
+ self.queue: List[CanonicalTelemetryEvent] = []
18
+ self.lock = threading.Lock()
19
+ self.MAX_SIZE = 10000
20
+ self.BATCH_SIZE = 500
21
+
22
+ if enabled:
23
+ t = threading.Thread(target=self._flush_loop, daemon=True)
24
+ t.start()
25
+
26
+ def push(self, event: CanonicalTelemetryEvent):
27
+ if not self.enabled:
28
+ return
29
+ with self.lock:
30
+ if len(self.queue) >= self.MAX_SIZE:
31
+ self.queue.pop(0)
32
+ self.queue.append(event)
33
+
34
+ def _flush_loop(self):
35
+ while True:
36
+ time.sleep(2)
37
+ self._flush()
38
+
39
+ def _flush(self):
40
+ with self.lock:
41
+ if not self.queue:
42
+ return
43
+ batch = self.queue[: self.BATCH_SIZE]
44
+ self.queue = self.queue[self.BATCH_SIZE :]
45
+
46
+ def encode(value):
47
+ if isinstance(value, Enum):
48
+ return value.value
49
+ if is_dataclass(value):
50
+ return {
51
+ key: encode(inner)
52
+ for key, inner in asdict(value).items()
53
+ if inner is not None
54
+ }
55
+ if isinstance(value, list):
56
+ return [encode(item) for item in value]
57
+ if isinstance(value, dict):
58
+ return {
59
+ key: encode(inner)
60
+ for key, inner in value.items()
61
+ if inner is not None
62
+ }
63
+ return value
64
+
65
+ try:
66
+ requests.post(
67
+ f"{self.backend_url}/api/ingest/request",
68
+ json={"events": [encode(event) for event in batch]},
69
+ timeout=5,
70
+ )
71
+ except Exception:
72
+ pass
pace_sdk/types.py ADDED
@@ -0,0 +1,97 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional, Union
3
+ from enum import Enum
4
+
5
+ class ProtectionMode(str, Enum):
6
+ ACTIVE = "active"
7
+ SHADOW = "shadow"
8
+ DISABLED = "disabled"
9
+
10
+ class Algorithm(str, Enum):
11
+ TOKEN_BUCKET = "token_bucket"
12
+ SLIDING_WINDOW = "sliding_window"
13
+ FIXED_WINDOW = "fixed_window"
14
+ LEAKY_BUCKET = "leaky_bucket"
15
+
16
+ class TrafficDecision(str, Enum):
17
+ ALLOW = "allow"
18
+ BLOCK = "block"
19
+ WOULD_BLOCK = "would_block"
20
+
21
+ class DecisionReason(str, Enum):
22
+ WITHIN_LIMIT = "within_limit"
23
+ LIMIT_EXCEEDED = "limit_exceeded"
24
+ TOKEN_EXHAUSTED = "token_exhausted"
25
+
26
+ # REMOVED: LogMode Enum is no longer needed!
27
+
28
+ @dataclass
29
+ class CanonicalDecision:
30
+ decision: TrafficDecision
31
+ reason: DecisionReason
32
+ algorithm: Algorithm
33
+ route: str
34
+ key: Optional[str] = None
35
+ remaining: Optional[int] = None
36
+ reset_ms: Optional[int] = None
37
+ latency_ms: Optional[int] = None
38
+ mode: ProtectionMode = ProtectionMode.ACTIVE
39
+ timestamp: int = 0
40
+ ip: Optional[str] = None
41
+ window: Optional[str] = None
42
+ limit: Optional[int] = None
43
+ capacity: Optional[int] = None
44
+ refill_rate: Optional[float] = None
45
+ refill_ms: Optional[int] = None
46
+
47
+ @dataclass
48
+ class CanonicalTelemetryRequest:
49
+ route: str
50
+ ip: str
51
+ method: Optional[str] = None
52
+ status_code: Optional[int] = None
53
+ latency_ms: Optional[int] = None
54
+ user_agent: Optional[str] = None
55
+ key: Optional[str] = None
56
+ mode: ProtectionMode = ProtectionMode.ACTIVE
57
+
58
+ @dataclass
59
+ class CanonicalTelemetryEvent:
60
+ event_type: str
61
+ timestamp: int
62
+ decision: CanonicalDecision
63
+ request: CanonicalTelemetryRequest
64
+ api_key: Optional[str] = None
65
+ sdk_version: Optional[str] = None
66
+
67
+ @dataclass
68
+ class Rules:
69
+ requests_per_minute: Optional[int] = None
70
+ requests_per_second: Optional[int] = None
71
+ burst_limit: Optional[int] = None
72
+
73
+ @dataclass
74
+ class Thresholds:
75
+ burst: int = 20
76
+ block_duration_ms: int = 60000
77
+
78
+ @dataclass
79
+ class PaceConfig:
80
+ api_key: Optional[str] = None
81
+ mode: ProtectionMode = ProtectionMode.ACTIVE
82
+ algorithm: Algorithm = Algorithm.TOKEN_BUCKET
83
+ capacity: int = 100
84
+ refill_rate: int = 10
85
+ # Unified debug setting: False, True, "compact", or "pretty"
86
+ debug: Union[bool, str] = False
87
+ identity_header: Optional[str] = None
88
+ backend_url: str = "http://localhost:4000"
89
+ rules: Rules = field(default_factory=Rules)
90
+ thresholds: Thresholds = field(default_factory=Thresholds)
91
+
92
+ @dataclass
93
+ class CheckResult:
94
+ allowed: bool
95
+ would_block: bool
96
+ reason: Optional[str] = None
97
+ decision: Optional[CanonicalDecision] = None