subscription-sdk 1.0.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.
- subscription_sdk/__init__.py +24 -0
- subscription_sdk/adapters/fastapi_adapter.py +105 -0
- subscription_sdk/adapters/flask_adapter.py +84 -0
- subscription_sdk/client.py +198 -0
- subscription_sdk/config_manager.py +16 -0
- subscription_sdk/poller.py +74 -0
- subscription_sdk/route_matcher.py +63 -0
- subscription_sdk/types.py +58 -0
- subscription_sdk-1.0.0.dist-info/METADATA +139 -0
- subscription_sdk-1.0.0.dist-info/RECORD +12 -0
- subscription_sdk-1.0.0.dist-info/WHEEL +5 -0
- subscription_sdk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .types import Mapping, SdkConfig, EntitlementCheckResponse
|
|
2
|
+
from .config_manager import ConfigManager
|
|
3
|
+
from .poller import Poller
|
|
4
|
+
from .route_matcher import RouteMatcher
|
|
5
|
+
from .client import SubscriptionClient
|
|
6
|
+
|
|
7
|
+
# Export flask and fastapi adapters dynamically to avoid hard dependencies if users don't have those libraries installed
|
|
8
|
+
def get_flask_adapter():
|
|
9
|
+
from .adapters.flask_adapter import subscription_flask
|
|
10
|
+
return subscription_flask
|
|
11
|
+
|
|
12
|
+
def get_fastapi_middleware():
|
|
13
|
+
from .adapters.fastapi_adapter import SubscriptionMiddleware
|
|
14
|
+
return SubscriptionMiddleware
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'Mapping',
|
|
18
|
+
'SdkConfig',
|
|
19
|
+
'EntitlementCheckResponse',
|
|
20
|
+
'ConfigManager',
|
|
21
|
+
'Poller',
|
|
22
|
+
'RouteMatcher',
|
|
23
|
+
'SubscriptionClient',
|
|
24
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
from typing import Callable, Optional, Union, Awaitable
|
|
4
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
from starlette.responses import Response, JSONResponse
|
|
7
|
+
from ..client import SubscriptionClient
|
|
8
|
+
|
|
9
|
+
class SubscriptionMiddleware(BaseHTTPMiddleware):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
app,
|
|
13
|
+
client: Optional[SubscriptionClient] = None,
|
|
14
|
+
get_org_id: Optional[Union[Callable[[Request], str], Callable[[Request], Awaitable[str]]]] = None,
|
|
15
|
+
api_key: Optional[str] = None,
|
|
16
|
+
base_url: Optional[str] = None
|
|
17
|
+
):
|
|
18
|
+
super().__init__(app)
|
|
19
|
+
if not get_org_id:
|
|
20
|
+
raise ValueError("get_org_id callback is required")
|
|
21
|
+
|
|
22
|
+
self.get_org_id = get_org_id
|
|
23
|
+
|
|
24
|
+
if client is None:
|
|
25
|
+
key = api_key or os.environ.get("SUBSCRIPTION_API_KEY")
|
|
26
|
+
url = base_url or os.environ.get("SUBSCRIPTION_BASE_URL")
|
|
27
|
+
if not key or not url:
|
|
28
|
+
raise ValueError("Subscription API Key and Base URL are required when client is not provided")
|
|
29
|
+
self.client = SubscriptionClient(key, url)
|
|
30
|
+
self.client.start()
|
|
31
|
+
else:
|
|
32
|
+
self.client = client
|
|
33
|
+
|
|
34
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
35
|
+
path = request.url.path
|
|
36
|
+
method = request.method
|
|
37
|
+
|
|
38
|
+
mapping = self.client.match_route(method, path)
|
|
39
|
+
if not mapping:
|
|
40
|
+
return await call_next(request)
|
|
41
|
+
|
|
42
|
+
# Resolve org identifier
|
|
43
|
+
if inspect.iscoroutinefunction(self.get_org_id):
|
|
44
|
+
org_id = await self.get_org_id(request)
|
|
45
|
+
else:
|
|
46
|
+
org_id = self.get_org_id(request)
|
|
47
|
+
|
|
48
|
+
if not org_id:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=400,
|
|
51
|
+
content={
|
|
52
|
+
"title": "Organization Identity Missing",
|
|
53
|
+
"status": 400,
|
|
54
|
+
"detail": "No organization external identifier resolved for the matching route."
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
check_result = self.client.check_entitlement(org_id, mapping.id, 1.0)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return JSONResponse(
|
|
62
|
+
status_code=500,
|
|
63
|
+
content={
|
|
64
|
+
"title": "Entitlement Resolution Error",
|
|
65
|
+
"status": 500,
|
|
66
|
+
"detail": str(e)
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not check_result.allowed:
|
|
71
|
+
detail = check_result.detail or f"Subscription limit reached or feature not enabled: {check_result.featureCode}"
|
|
72
|
+
code = check_result.code or "ENTITLEMENT_DENIED"
|
|
73
|
+
return JSONResponse(
|
|
74
|
+
status_code=403,
|
|
75
|
+
content={
|
|
76
|
+
"title": "Entitlement Denied",
|
|
77
|
+
"status": 403,
|
|
78
|
+
"detail": detail,
|
|
79
|
+
"code": code
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Attach reservation context to request state
|
|
84
|
+
request.state.subscription_reservation_id = check_result.reservationId
|
|
85
|
+
|
|
86
|
+
success = False
|
|
87
|
+
try:
|
|
88
|
+
response = await call_next(request)
|
|
89
|
+
if response.status_code < 400:
|
|
90
|
+
success = True
|
|
91
|
+
return response
|
|
92
|
+
finally:
|
|
93
|
+
reservation_id = getattr(request.state, 'subscription_reservation_id', None)
|
|
94
|
+
if reservation_id:
|
|
95
|
+
try:
|
|
96
|
+
if success:
|
|
97
|
+
self.client.commit_reservation(reservation_id)
|
|
98
|
+
else:
|
|
99
|
+
self.client.cancel_reservation(reservation_id)
|
|
100
|
+
except Exception as e:
|
|
101
|
+
import sys
|
|
102
|
+
print(
|
|
103
|
+
f"[SubscriptionSDK] Failed to {'commit' if success else 'cancel'} reservation {reservation_id}: {e}",
|
|
104
|
+
file=sys.stderr
|
|
105
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Callable, Optional
|
|
3
|
+
from flask import request, jsonify, g
|
|
4
|
+
from ..client import SubscriptionClient
|
|
5
|
+
|
|
6
|
+
def subscription_flask(
|
|
7
|
+
app,
|
|
8
|
+
client: Optional[SubscriptionClient] = None,
|
|
9
|
+
get_org_id: Optional[Callable[[], Optional[str]]] = None,
|
|
10
|
+
api_key: Optional[str] = None,
|
|
11
|
+
base_url: Optional[str] = None
|
|
12
|
+
) -> SubscriptionClient:
|
|
13
|
+
|
|
14
|
+
if not get_org_id:
|
|
15
|
+
raise ValueError("get_org_id callback is required")
|
|
16
|
+
|
|
17
|
+
if client is None:
|
|
18
|
+
key = api_key or os.environ.get("SUBSCRIPTION_API_KEY")
|
|
19
|
+
url = base_url or os.environ.get("SUBSCRIPTION_BASE_URL")
|
|
20
|
+
if not key or not url:
|
|
21
|
+
raise ValueError("Subscription API Key and Base URL are required when client is not provided")
|
|
22
|
+
client = SubscriptionClient(key, url)
|
|
23
|
+
client.start()
|
|
24
|
+
|
|
25
|
+
@app.before_request
|
|
26
|
+
def before_req():
|
|
27
|
+
path = request.path
|
|
28
|
+
method = request.method
|
|
29
|
+
|
|
30
|
+
mapping = client.match_route(method, path)
|
|
31
|
+
if not mapping:
|
|
32
|
+
return None # Bypass
|
|
33
|
+
|
|
34
|
+
org_id = get_org_id()
|
|
35
|
+
if not org_id:
|
|
36
|
+
return jsonify({
|
|
37
|
+
"title": "Organization Identity Missing",
|
|
38
|
+
"status": 400,
|
|
39
|
+
"detail": "No organization external identifier resolved for the matching route."
|
|
40
|
+
}), 400
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
check_result = client.check_entitlement(org_id, mapping.id, 1.0)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return jsonify({
|
|
46
|
+
"title": "Entitlement Resolution Error",
|
|
47
|
+
"status": 500,
|
|
48
|
+
"detail": str(e)
|
|
49
|
+
}), 500
|
|
50
|
+
|
|
51
|
+
if not check_result.allowed:
|
|
52
|
+
detail = check_result.detail or f"Subscription limit reached or feature not enabled: {check_result.featureCode}"
|
|
53
|
+
code = check_result.code or "ENTITLEMENT_DENIED"
|
|
54
|
+
return jsonify({
|
|
55
|
+
"title": "Entitlement Denied",
|
|
56
|
+
"status": 403,
|
|
57
|
+
"detail": detail,
|
|
58
|
+
"code": code
|
|
59
|
+
}), 403
|
|
60
|
+
|
|
61
|
+
# Attach reservation context to request state
|
|
62
|
+
g.subscription_reservation_id = check_result.reservationId
|
|
63
|
+
request.subscription_reservation_id = check_result.reservationId
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
@app.after_request
|
|
67
|
+
def after_req(response):
|
|
68
|
+
reservation_id = getattr(g, 'subscription_reservation_id', None)
|
|
69
|
+
if reservation_id:
|
|
70
|
+
success = response.status_code < 400
|
|
71
|
+
try:
|
|
72
|
+
if success:
|
|
73
|
+
client.commit_reservation(reservation_id)
|
|
74
|
+
else:
|
|
75
|
+
client.cancel_reservation(reservation_id)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
import sys
|
|
78
|
+
print(
|
|
79
|
+
f"[SubscriptionSDK] Failed to {'commit' if success else 'cancel'} reservation {reservation_id}: {e}",
|
|
80
|
+
file=sys.stderr
|
|
81
|
+
)
|
|
82
|
+
return response
|
|
83
|
+
|
|
84
|
+
return client
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import platform
|
|
3
|
+
import socket
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
from typing import Optional, Callable, Any
|
|
7
|
+
from .types import SdkConfig, EntitlementCheckResponse, Mapping
|
|
8
|
+
from .config_manager import ConfigManager
|
|
9
|
+
from .poller import Poller
|
|
10
|
+
from .route_matcher import RouteMatcher
|
|
11
|
+
|
|
12
|
+
class SubscriptionClient:
|
|
13
|
+
def __init__(self, api_key: str, base_url: str, poll_interval_sec: float = 60.0, poll_jitter: float = 0.30):
|
|
14
|
+
self.api_key = api_key
|
|
15
|
+
self.base_url = base_url.rstrip('/')
|
|
16
|
+
self.config_manager = ConfigManager()
|
|
17
|
+
self.poller = Poller(self.sync_config, poll_interval_sec, poll_jitter)
|
|
18
|
+
self.current_etag: Optional[str] = None
|
|
19
|
+
self.current_revision: int = 0
|
|
20
|
+
|
|
21
|
+
def start(self) -> None:
|
|
22
|
+
# Initial fetch must succeed or fail during startup.
|
|
23
|
+
# If it fails on first startup, it raises an exception.
|
|
24
|
+
self.sync_config()
|
|
25
|
+
self.poller.start()
|
|
26
|
+
|
|
27
|
+
def stop(self) -> None:
|
|
28
|
+
self.poller.stop()
|
|
29
|
+
|
|
30
|
+
def get_config(self) -> Optional[SdkConfig]:
|
|
31
|
+
return self.config_manager.get_config()
|
|
32
|
+
|
|
33
|
+
def sync_config(self) -> None:
|
|
34
|
+
url = f"{self.base_url}/sdk/v1/config"
|
|
35
|
+
|
|
36
|
+
hostname = "unknown-python-host"
|
|
37
|
+
try:
|
|
38
|
+
hostname = socket.gethostname()
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
headers = {
|
|
43
|
+
'Authorization': f"Bearer {self.api_key}",
|
|
44
|
+
'Accept': 'application/json',
|
|
45
|
+
'X-SDK-Language': 'Python',
|
|
46
|
+
'X-SDK-Version': '1.0.0',
|
|
47
|
+
'X-SDK-Runtime': f"Python {platform.python_version()}",
|
|
48
|
+
'X-SDK-Hostname': hostname
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if self.current_etag:
|
|
52
|
+
headers['If-None-Match'] = self.current_etag
|
|
53
|
+
|
|
54
|
+
req = urllib.request.Request(url, headers=headers, method='GET')
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
58
|
+
status = response.status
|
|
59
|
+
|
|
60
|
+
if status == 304:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if status != 200:
|
|
64
|
+
raise RuntimeError(f"Sync config failed with status {status}")
|
|
65
|
+
|
|
66
|
+
body_bytes = response.read()
|
|
67
|
+
body_json = json.loads(body_bytes.decode('utf-8'))
|
|
68
|
+
|
|
69
|
+
# Extract headers (case-insensitive key check)
|
|
70
|
+
resp_headers = response.info()
|
|
71
|
+
etag = resp_headers.get('ETag')
|
|
72
|
+
if etag:
|
|
73
|
+
self.current_etag = etag
|
|
74
|
+
|
|
75
|
+
rev_header = resp_headers.get('X-Config-Revision')
|
|
76
|
+
if rev_header:
|
|
77
|
+
try:
|
|
78
|
+
self.current_revision = int(rev_header)
|
|
79
|
+
except ValueError:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
config = SdkConfig.from_dict(body_json)
|
|
83
|
+
self.config_manager.update_config(config)
|
|
84
|
+
|
|
85
|
+
except urllib.error.HTTPError as e:
|
|
86
|
+
if e.code == 304:
|
|
87
|
+
return # Unchanged
|
|
88
|
+
raise RuntimeError(f"Sync config failed with status {e.code}: {e.read().decode('utf-8', errors='ignore')}") from e
|
|
89
|
+
except Exception as e:
|
|
90
|
+
raise RuntimeError(f"Sync config encountered an error: {e}") from e
|
|
91
|
+
|
|
92
|
+
def match_route(self, method: str, path: str) -> Optional[Mapping]:
|
|
93
|
+
config = self.config_manager.get_config()
|
|
94
|
+
if not config or not config.mappings:
|
|
95
|
+
return None
|
|
96
|
+
return RouteMatcher.match(method, path, config.mappings)
|
|
97
|
+
|
|
98
|
+
def check_entitlement(
|
|
99
|
+
self,
|
|
100
|
+
organization_external_id: str,
|
|
101
|
+
resource_mapping_id: str,
|
|
102
|
+
quantity: float,
|
|
103
|
+
idempotency_key: Optional[str] = None
|
|
104
|
+
) -> EntitlementCheckResponse:
|
|
105
|
+
url = f"{self.base_url}/sdk/v1/entitlements/check"
|
|
106
|
+
|
|
107
|
+
body = {
|
|
108
|
+
'organizationExternalId': organization_external_id,
|
|
109
|
+
'resourceMappingId': resource_mapping_id,
|
|
110
|
+
'quantity': quantity
|
|
111
|
+
}
|
|
112
|
+
if idempotency_key:
|
|
113
|
+
body['idempotencyKey'] = idempotency_key
|
|
114
|
+
|
|
115
|
+
data = json.dumps(body).encode('utf-8')
|
|
116
|
+
headers = {
|
|
117
|
+
'Authorization': f"Bearer {self.api_key}",
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
'Accept': 'application/json'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
126
|
+
if response.status != 200:
|
|
127
|
+
raise RuntimeError(f"Entitlement check failed: status {response.status}")
|
|
128
|
+
body_bytes = response.read()
|
|
129
|
+
body_json = json.loads(body_bytes.decode('utf-8'))
|
|
130
|
+
return EntitlementCheckResponse.from_dict(body_json)
|
|
131
|
+
except urllib.error.HTTPError as e:
|
|
132
|
+
err_text = e.read().decode('utf-8', errors='ignore')
|
|
133
|
+
raise RuntimeError(f"Entitlement check failed with status {e.code}: {err_text}") from e
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise RuntimeError(f"Entitlement check encountered an error: {e}") from e
|
|
136
|
+
|
|
137
|
+
def commit_reservation(self, reservation_id: str) -> None:
|
|
138
|
+
url = f"{self.base_url}/sdk/v1/reservations/{reservation_id}/commit"
|
|
139
|
+
headers = {
|
|
140
|
+
'Authorization': f"Bearer {self.api_key}"
|
|
141
|
+
}
|
|
142
|
+
req = urllib.request.Request(url, headers=headers, method='POST')
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
146
|
+
if response.status not in (200, 204, 244):
|
|
147
|
+
raise RuntimeError(f"Commit reservation failed: status {response.status}")
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
raise RuntimeError(f"Commit reservation failed with status {e.code}") from e
|
|
150
|
+
except Exception as e:
|
|
151
|
+
raise RuntimeError(f"Commit reservation encountered an error: {e}") from e
|
|
152
|
+
|
|
153
|
+
def cancel_reservation(self, reservation_id: str) -> None:
|
|
154
|
+
url = f"{self.base_url}/sdk/v1/reservations/{reservation_id}/cancel"
|
|
155
|
+
headers = {
|
|
156
|
+
'Authorization': f"Bearer {self.api_key}"
|
|
157
|
+
}
|
|
158
|
+
req = urllib.request.Request(url, headers=headers, method='POST')
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
162
|
+
if response.status not in (200, 204, 244):
|
|
163
|
+
raise RuntimeError(f"Cancel reservation failed: status {response.status}")
|
|
164
|
+
except urllib.error.HTTPError as e:
|
|
165
|
+
raise RuntimeError(f"Cancel reservation failed with status {e.code}") from e
|
|
166
|
+
except Exception as e:
|
|
167
|
+
raise RuntimeError(f"Cancel reservation encountered an error: {e}") from e
|
|
168
|
+
|
|
169
|
+
def execute(
|
|
170
|
+
self,
|
|
171
|
+
organization_id: str,
|
|
172
|
+
resource_mapping_id: str,
|
|
173
|
+
callback: Callable[[], Any],
|
|
174
|
+
quantity: float = 1.0,
|
|
175
|
+
idempotency_key: Optional[str] = None
|
|
176
|
+
) -> Any:
|
|
177
|
+
check = self.check_entitlement(
|
|
178
|
+
organization_external_id=organization_id,
|
|
179
|
+
resource_mapping_id=resource_mapping_id,
|
|
180
|
+
quantity=quantity,
|
|
181
|
+
idempotency_key=idempotency_key
|
|
182
|
+
)
|
|
183
|
+
if not check.allowed:
|
|
184
|
+
raise PermissionError(check.detail or f"Entitlement check denied for feature: {check.featureCode}")
|
|
185
|
+
|
|
186
|
+
reservation_id = check.reservationId
|
|
187
|
+
try:
|
|
188
|
+
result = callback()
|
|
189
|
+
if reservation_id:
|
|
190
|
+
self.commit_reservation(reservation_id)
|
|
191
|
+
return result
|
|
192
|
+
except Exception as e:
|
|
193
|
+
if reservation_id:
|
|
194
|
+
try:
|
|
195
|
+
self.cancel_reservation(reservation_id)
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
raise e
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from .types import SdkConfig
|
|
4
|
+
|
|
5
|
+
class ConfigManager:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._current_config: Optional[SdkConfig] = None
|
|
8
|
+
self._lock = threading.RLock()
|
|
9
|
+
|
|
10
|
+
def get_config(self) -> Optional[SdkConfig]:
|
|
11
|
+
with self._lock:
|
|
12
|
+
return self._current_config
|
|
13
|
+
|
|
14
|
+
def update_config(self, new_config: SdkConfig) -> None:
|
|
15
|
+
with self._lock:
|
|
16
|
+
self._current_config = new_config
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from typing import Callable, Optional
|
|
5
|
+
|
|
6
|
+
class Poller:
|
|
7
|
+
def __init__(self, sync_fn: Callable[[], None], poll_interval_sec: float = 60.0, poll_jitter: float = 0.30):
|
|
8
|
+
self._sync_fn = sync_fn
|
|
9
|
+
self._base_interval_sec = poll_interval_sec
|
|
10
|
+
self._jitter_percent = poll_jitter
|
|
11
|
+
self._is_running = False
|
|
12
|
+
self._consecutive_failures = 0
|
|
13
|
+
self._thread: Optional[threading.Thread] = None
|
|
14
|
+
self._stop_event = threading.Event()
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def start(self) -> None:
|
|
18
|
+
with self._lock:
|
|
19
|
+
if self._is_running:
|
|
20
|
+
return
|
|
21
|
+
self._is_running = True
|
|
22
|
+
self._consecutive_failures = 0
|
|
23
|
+
self._stop_event.clear()
|
|
24
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
25
|
+
self._thread.start()
|
|
26
|
+
|
|
27
|
+
def stop(self) -> None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if not self._is_running:
|
|
30
|
+
return
|
|
31
|
+
self._is_running = False
|
|
32
|
+
self._stop_event.set()
|
|
33
|
+
# We don't join blockingly for a long time to keep stop() fast
|
|
34
|
+
self._thread = None
|
|
35
|
+
|
|
36
|
+
def _run(self) -> None:
|
|
37
|
+
# Randomized initial delay: 0 to 5 seconds to prevent thundering herd
|
|
38
|
+
initial_delay = random.uniform(0.0, 5.0)
|
|
39
|
+
if self._stop_event.wait(initial_delay):
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
while self._is_running and not self._stop_event.is_set():
|
|
43
|
+
try:
|
|
44
|
+
self._sync_fn()
|
|
45
|
+
self._consecutive_failures = 0
|
|
46
|
+
except Exception as e:
|
|
47
|
+
self._consecutive_failures += 1
|
|
48
|
+
# Log error
|
|
49
|
+
import sys
|
|
50
|
+
print(
|
|
51
|
+
f"[SubscriptionSDK] Config poll failed (consecutive: {self._consecutive_failures}): {e}",
|
|
52
|
+
file=sys.stderr
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if not self._is_running or self._stop_event.is_set():
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
delay = self._calculate_next_delay()
|
|
59
|
+
if self._stop_event.wait(delay):
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
def _calculate_next_delay(self) -> float:
|
|
63
|
+
if self._consecutive_failures > 0:
|
|
64
|
+
# Exponential backoff with full jitter: min(max_backoff, base * 2 ^ failures)
|
|
65
|
+
base_backoff = 2.0 # 2 seconds
|
|
66
|
+
max_backoff = 60.0 # 60 seconds
|
|
67
|
+
exp_val = min(max_backoff, base_backoff * (2.0 ** self._consecutive_failures))
|
|
68
|
+
# Full jitter: random between 0 and exp_val
|
|
69
|
+
return random.uniform(0.0, exp_val)
|
|
70
|
+
|
|
71
|
+
# Standard Jitter: baseInterval +/- (baseInterval * jitterPercent * random)
|
|
72
|
+
jitter_sign = -1.0 if random.random() < 0.5 else 1.0
|
|
73
|
+
jitter_amount = self._base_interval_sec * self._jitter_percent * random.random()
|
|
74
|
+
return self._base_interval_sec + (jitter_sign * jitter_amount)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from .types import Mapping
|
|
4
|
+
|
|
5
|
+
class RouteMatcher:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def match(method: str, path: str, mappings: List[Mapping]) -> Optional[Mapping]:
|
|
8
|
+
uppercase_method = method.upper()
|
|
9
|
+
|
|
10
|
+
# Sort by priority (ascending, lower priority number takes precedence)
|
|
11
|
+
sorted_mappings = sorted(mappings, key=lambda m: m.priority)
|
|
12
|
+
|
|
13
|
+
for m in sorted_mappings:
|
|
14
|
+
if m.method.upper() != uppercase_method:
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
if RouteMatcher._is_match(path, m):
|
|
18
|
+
return m
|
|
19
|
+
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _is_match(path: str, mapping: Mapping) -> bool:
|
|
24
|
+
pattern = mapping.path
|
|
25
|
+
|
|
26
|
+
if mapping.matchType == 'exact':
|
|
27
|
+
return path == pattern
|
|
28
|
+
|
|
29
|
+
if mapping.matchType == 'wildcard':
|
|
30
|
+
# Escape regex special characters like Node SDK
|
|
31
|
+
# Node pattern: [-\/\\^$*+?.()|[\]{}]
|
|
32
|
+
special_chars = r'[-\/\\^$*+?.()|[\]{}]'
|
|
33
|
+
escaped = re.sub(f'({special_chars})', r'\\\1', pattern)
|
|
34
|
+
|
|
35
|
+
# Convert double star wildcard
|
|
36
|
+
escaped = escaped.replace(r'\*\*', '.*')
|
|
37
|
+
# Convert single star wildcard
|
|
38
|
+
escaped = escaped.replace(r'\*', '[^/]*')
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
regex = re.compile(f'^{escaped}$')
|
|
42
|
+
return bool(regex.match(path))
|
|
43
|
+
except re.error:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if mapping.matchType == 'template':
|
|
47
|
+
# e.g. /employees/:id -> matches /employees/123
|
|
48
|
+
segments = pattern.split('/')
|
|
49
|
+
path_segments = path.split('/')
|
|
50
|
+
|
|
51
|
+
if len(segments) != len(path_segments):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
for seg, path_seg in zip(segments, path_segments):
|
|
55
|
+
if seg.startswith(':'):
|
|
56
|
+
if path_seg == '':
|
|
57
|
+
return False
|
|
58
|
+
continue
|
|
59
|
+
if seg != path_seg:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
return False
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Mapping:
|
|
6
|
+
id: str
|
|
7
|
+
method: str
|
|
8
|
+
path: str
|
|
9
|
+
matchType: str # 'exact' | 'template' | 'wildcard'
|
|
10
|
+
priority: int
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_dict(cls, data: dict) -> 'Mapping':
|
|
14
|
+
return cls(
|
|
15
|
+
id=data.get('id', ''),
|
|
16
|
+
method=data.get('method', ''),
|
|
17
|
+
path=data.get('path', ''),
|
|
18
|
+
matchType=data.get('matchType', 'exact'),
|
|
19
|
+
priority=data.get('priority', 0)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SdkConfig:
|
|
24
|
+
protocolVersion: str
|
|
25
|
+
revision: int
|
|
26
|
+
checksum: str
|
|
27
|
+
mappings: List[Mapping] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_dict(cls, data: dict) -> 'SdkConfig':
|
|
31
|
+
mappings_data = data.get('mappings', [])
|
|
32
|
+
mappings = [Mapping.from_dict(m) for m in mappings_data]
|
|
33
|
+
return cls(
|
|
34
|
+
protocolVersion=data.get('protocolVersion', 'v1'),
|
|
35
|
+
revision=data.get('revision', 0),
|
|
36
|
+
checksum=data.get('checksum', ''),
|
|
37
|
+
mappings=mappings
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EntitlementCheckResponse:
|
|
42
|
+
allowed: bool
|
|
43
|
+
reservationId: Optional[str]
|
|
44
|
+
featureCode: str
|
|
45
|
+
remaining: float
|
|
46
|
+
code: Optional[str] = None
|
|
47
|
+
detail: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, data: dict) -> 'EntitlementCheckResponse':
|
|
51
|
+
return cls(
|
|
52
|
+
allowed=data.get('allowed', False),
|
|
53
|
+
reservationId=data.get('reservationId'),
|
|
54
|
+
featureCode=data.get('featureCode', ''),
|
|
55
|
+
remaining=float(data.get('remaining', 0.0)),
|
|
56
|
+
code=data.get('code'),
|
|
57
|
+
detail=data.get('detail')
|
|
58
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: subscription-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python Client SDK for Subscription Platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: urllib3>=2.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.20.0; extra == "dev"
|
|
15
|
+
Requires-Dist: responses>=0.23.0; extra == "dev"
|
|
16
|
+
Requires-Dist: flask>=2.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: fastapi>=0.80.0; extra == "dev"
|
|
18
|
+
Requires-Dist: httpx>=0.24.0; extra == "dev"
|
|
19
|
+
Requires-Dist: uvicorn>=0.15.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# subscription-sdk-python
|
|
22
|
+
|
|
23
|
+
Python SDK for the Subscription and Entitlements Platform.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install subscription-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Middleware Adapters
|
|
32
|
+
|
|
33
|
+
### Flask
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from flask import Flask, request, jsonify
|
|
37
|
+
from subscription_sdk.adapters.flask_adapter import subscription_flask
|
|
38
|
+
|
|
39
|
+
app = Flask(__name__)
|
|
40
|
+
|
|
41
|
+
# Apply Flask Middleware
|
|
42
|
+
subscription_flask(
|
|
43
|
+
app,
|
|
44
|
+
api_key="sub_live_...",
|
|
45
|
+
base_url="https://api.infosub.io",
|
|
46
|
+
get_org_id=lambda: request.headers.get("X-Org-Id")
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@app.route("/api/v1/employees", methods=["GET"])
|
|
50
|
+
def get_employees():
|
|
51
|
+
return jsonify({"data": []}), 200
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### FastAPI
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from fastapi import FastAPI, Request
|
|
58
|
+
from subscription_sdk.adapters.fastapi_adapter import SubscriptionMiddleware
|
|
59
|
+
|
|
60
|
+
app = FastAPI()
|
|
61
|
+
|
|
62
|
+
# Apply FastAPI Middleware
|
|
63
|
+
app.add_middleware(
|
|
64
|
+
SubscriptionMiddleware,
|
|
65
|
+
api_key="sub_live_...",
|
|
66
|
+
base_url="https://api.infosub.io",
|
|
67
|
+
get_org_id=lambda req: req.headers.get("x-org-id")
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@app.get("/api/v1/employees")
|
|
71
|
+
def get_employees(request: Request):
|
|
72
|
+
return {"data": []}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Manual Client Usage
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from subscription_sdk import SubscriptionClient
|
|
79
|
+
|
|
80
|
+
client = SubscriptionClient(api_key="sub_live_...", base_url="https://api.infosub.io")
|
|
81
|
+
client.start() # Starts background poller thread
|
|
82
|
+
|
|
83
|
+
mapping = client.match_route("GET", "/api/v1/employees")
|
|
84
|
+
if mapping:
|
|
85
|
+
check = client.check_entitlement(
|
|
86
|
+
organization_external_id="tenant-acme",
|
|
87
|
+
resource_mapping_id=mapping.id,
|
|
88
|
+
quantity=1.0
|
|
89
|
+
)
|
|
90
|
+
if check.allowed:
|
|
91
|
+
try:
|
|
92
|
+
# Execute business logic...
|
|
93
|
+
|
|
94
|
+
client.commit_reservation(check.reservationId)
|
|
95
|
+
except Exception:
|
|
96
|
+
client.cancel_reservation(check.reservationId)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Alternatively, you can simplify the manual transaction pattern using `.execute(...)`, which handles the check-reserve-commit-cancel flow automatically.
|
|
100
|
+
|
|
101
|
+
**Basic Example:**
|
|
102
|
+
```python
|
|
103
|
+
# The execute method takes a callback function to run
|
|
104
|
+
result = client.execute(
|
|
105
|
+
organization_id="tenant-acme",
|
|
106
|
+
resource_mapping_id=mapping.id,
|
|
107
|
+
callback=lambda: save_employee(employee),
|
|
108
|
+
quantity=1.0
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Flask / FastAPI Route Handler Example:**
|
|
113
|
+
```python
|
|
114
|
+
@app.route("/employees-manual", methods=["POST"])
|
|
115
|
+
def create_employee_manual():
|
|
116
|
+
org_id = request.headers.get("X-Org-Id") or request.args.get("orgId")
|
|
117
|
+
mapping = client.match_route("POST", "/employees")
|
|
118
|
+
|
|
119
|
+
if not mapping:
|
|
120
|
+
return jsonify({"error": "Mapping not found"}), 500
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
result = client.execute(
|
|
124
|
+
organization_id=org_id,
|
|
125
|
+
resource_mapping_id=mapping.id,
|
|
126
|
+
quantity=1.0,
|
|
127
|
+
callback=lambda: {
|
|
128
|
+
"status": "success",
|
|
129
|
+
"message": "Employee created successfully via manual execute wrapper."
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
return jsonify(result), 200
|
|
133
|
+
except PermissionError as err:
|
|
134
|
+
# Throws PermissionError if entitlement check is denied (check.allowed is False)
|
|
135
|
+
return jsonify({"status": "error", "message": str(err)}), 403
|
|
136
|
+
except Exception as err:
|
|
137
|
+
return jsonify({"status": "error", "message": str(err)}), 500
|
|
138
|
+
```
|
|
139
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
subscription_sdk/__init__.py,sha256=wE_FIArq1yreeVU2voprppCLJFQEj_B11paqMg7kr_8,738
|
|
2
|
+
subscription_sdk/client.py,sha256=cvsyJzL0n3KetWnL0E1ZP9t2UX0KO4N2JmbyxsN8c2M,7666
|
|
3
|
+
subscription_sdk/config_manager.py,sha256=XXt6RjDFRLWp_Ry_2MaW86GEq5pXlC2yLCqBrtwyWCo,463
|
|
4
|
+
subscription_sdk/poller.py,sha256=TPVTzMBQwiMmBs7Bzm5gLBxAN8W9tg7PT9jjS3oNTcU,2863
|
|
5
|
+
subscription_sdk/route_matcher.py,sha256=EW6uAPOQtrFnr8UPldqYTsPczy_lPTg_QxmSQDRMC8Q,2043
|
|
6
|
+
subscription_sdk/types.py,sha256=2aPKp_1E-j3sY4BMljZJ0Wpl4x9Q9qxk5dGBT2hhwjQ,1677
|
|
7
|
+
subscription_sdk/adapters/fastapi_adapter.py,sha256=vA2TCPZAguXD_oKZYyRg9Xz0HgD_mwZJtNpwNzsKvas,3906
|
|
8
|
+
subscription_sdk/adapters/flask_adapter.py,sha256=UrNyUpL2N-cla31UtUlqAIvo0FE0Fajf83T3V_vKQsM,2979
|
|
9
|
+
subscription_sdk-1.0.0.dist-info/METADATA,sha256=7hfuC59RGuQn3NXAB_g4hRZKp-7ycHm65-r7VNg98CY,3887
|
|
10
|
+
subscription_sdk-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
subscription_sdk-1.0.0.dist-info/top_level.txt,sha256=vOf5OfXvrSYcUkdUEKHhTVVqDvYnzMBJTkeHpYkGHPk,17
|
|
12
|
+
subscription_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
subscription_sdk
|