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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ subscription_sdk