subscription-sdk 1.0.0__tar.gz

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,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,119 @@
1
+ # subscription-sdk-python
2
+
3
+ Python SDK for the Subscription and Entitlements Platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install subscription-sdk
9
+ ```
10
+
11
+ ## Middleware Adapters
12
+
13
+ ### Flask
14
+
15
+ ```python
16
+ from flask import Flask, request, jsonify
17
+ from subscription_sdk.adapters.flask_adapter import subscription_flask
18
+
19
+ app = Flask(__name__)
20
+
21
+ # Apply Flask Middleware
22
+ subscription_flask(
23
+ app,
24
+ api_key="sub_live_...",
25
+ base_url="https://api.infosub.io",
26
+ get_org_id=lambda: request.headers.get("X-Org-Id")
27
+ )
28
+
29
+ @app.route("/api/v1/employees", methods=["GET"])
30
+ def get_employees():
31
+ return jsonify({"data": []}), 200
32
+ ```
33
+
34
+ ### FastAPI
35
+
36
+ ```python
37
+ from fastapi import FastAPI, Request
38
+ from subscription_sdk.adapters.fastapi_adapter import SubscriptionMiddleware
39
+
40
+ app = FastAPI()
41
+
42
+ # Apply FastAPI Middleware
43
+ app.add_middleware(
44
+ SubscriptionMiddleware,
45
+ api_key="sub_live_...",
46
+ base_url="https://api.infosub.io",
47
+ get_org_id=lambda req: req.headers.get("x-org-id")
48
+ )
49
+
50
+ @app.get("/api/v1/employees")
51
+ def get_employees(request: Request):
52
+ return {"data": []}
53
+ ```
54
+
55
+ ## Manual Client Usage
56
+
57
+ ```python
58
+ from subscription_sdk import SubscriptionClient
59
+
60
+ client = SubscriptionClient(api_key="sub_live_...", base_url="https://api.infosub.io")
61
+ client.start() # Starts background poller thread
62
+
63
+ mapping = client.match_route("GET", "/api/v1/employees")
64
+ if mapping:
65
+ check = client.check_entitlement(
66
+ organization_external_id="tenant-acme",
67
+ resource_mapping_id=mapping.id,
68
+ quantity=1.0
69
+ )
70
+ if check.allowed:
71
+ try:
72
+ # Execute business logic...
73
+
74
+ client.commit_reservation(check.reservationId)
75
+ except Exception:
76
+ client.cancel_reservation(check.reservationId)
77
+ ```
78
+
79
+ Alternatively, you can simplify the manual transaction pattern using `.execute(...)`, which handles the check-reserve-commit-cancel flow automatically.
80
+
81
+ **Basic Example:**
82
+ ```python
83
+ # The execute method takes a callback function to run
84
+ result = client.execute(
85
+ organization_id="tenant-acme",
86
+ resource_mapping_id=mapping.id,
87
+ callback=lambda: save_employee(employee),
88
+ quantity=1.0
89
+ )
90
+ ```
91
+
92
+ **Flask / FastAPI Route Handler Example:**
93
+ ```python
94
+ @app.route("/employees-manual", methods=["POST"])
95
+ def create_employee_manual():
96
+ org_id = request.headers.get("X-Org-Id") or request.args.get("orgId")
97
+ mapping = client.match_route("POST", "/employees")
98
+
99
+ if not mapping:
100
+ return jsonify({"error": "Mapping not found"}), 500
101
+
102
+ try:
103
+ result = client.execute(
104
+ organization_id=org_id,
105
+ resource_mapping_id=mapping.id,
106
+ quantity=1.0,
107
+ callback=lambda: {
108
+ "status": "success",
109
+ "message": "Employee created successfully via manual execute wrapper."
110
+ }
111
+ )
112
+ return jsonify(result), 200
113
+ except PermissionError as err:
114
+ # Throws PermissionError if entitlement check is denied (check.allowed is False)
115
+ return jsonify({"status": "error", "message": str(err)}), 403
116
+ except Exception as err:
117
+ return jsonify({"status": "error", "message": str(err)}), 500
118
+ ```
119
+
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "subscription-sdk"
7
+ version = "1.0.0"
8
+ description = "Python Client SDK for Subscription Platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ dependencies = [
18
+ "urllib3>=2.0.0",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.0.0",
24
+ "pytest-asyncio>=0.20.0",
25
+ "responses>=0.23.0",
26
+ "flask>=2.0.0",
27
+ "fastapi>=0.80.0",
28
+ "httpx>=0.24.0",
29
+ "uvicorn>=0.15.0",
30
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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