paycascade-sdk 0.1.1__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,26 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BitBerry Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ SCOPE: This license applies only to the PayCascade customer SDK files in this
24
+ directory and the corresponding SDK package. It does not license the PayCascade
25
+ server or any other files in the repository, and it grants no rights to
26
+ BitBerry Labs trademarks. The root LICENSE.md governs all other files.
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ prune tests
3
+ global-exclude *.py[cod]
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: paycascade-sdk
3
+ Version: 0.1.1
4
+ Summary: Customer-side cache and access middleware for the PayCascade hosted billing-state API.
5
+ Author: BitBerry Labs
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ License-File: LICENSE.md
18
+ Requires-Dist: httpx>=0.27.0
19
+ Dynamic: license-file
@@ -0,0 +1,113 @@
1
+ # PayCascade Python SDK
2
+
3
+ The SDK is a customer-side helper for polling the PayCascade state API and
4
+ making access decisions from a local cache. It is publicly installable from
5
+ PyPI under the MIT License and is also included in the self-hosted Dev Kit.
6
+ The SDK is not required to run the Dev Kit server.
7
+
8
+ Install the public package from the default PyPI index:
9
+
10
+ ```sh
11
+ python -m pip install paycascade-sdk
12
+ ```
13
+
14
+ Anyone can install, use, modify, and redistribute the SDK under the MIT
15
+ License. Package downloads are not gated by a hosted subscription. Calling
16
+ PayCascade's hosted API still requires a valid API key and applicable hosted
17
+ service entitlement. In a source checkout or Dev Kit snapshot, install locally
18
+ with:
19
+
20
+ ```sh
21
+ python -m pip install ./sdk
22
+ ```
23
+
24
+ The SDK is distributed under the [MIT License](LICENSE.md); that license
25
+ covers only the SDK and grants no rights to the server or BitBerry Labs
26
+ trademarks. The SDK requires Python 3.11 or newer and `httpx`. It polls the PayCascade
27
+ state API and offers client-side access-decision helpers; it does not receive
28
+ or process Stripe webhooks. Stripe sends customer billing webhooks to the
29
+ PayCascade service. The SDK does not enforce policy in your application
30
+ automatically: your application must call the decision helper and apply the
31
+ result to its own routes and user experience.
32
+
33
+ ## Example
34
+
35
+ Keep the API key on your application server; never ship it to a browser or
36
+ mobile client. Replace the example values with the PayCascade API base URL,
37
+ tenant API key, and PayCascade subscription ID that your application has
38
+ registered.
39
+
40
+ ```python
41
+ import logging
42
+ import os
43
+ from uuid import UUID
44
+
45
+ from paycascade_sdk import (
46
+ FailPolicy,
47
+ InMemoryStateCache,
48
+ PayCascadeClient,
49
+ StatePoller,
50
+ enforce_cached_state,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+ subscription_id = UUID(os.environ["PAYCASCADE_SUBSCRIPTION_ID"])
55
+ client = PayCascadeClient(
56
+ base_url=os.environ["PAYCASCADE_API_URL"],
57
+ api_key=os.environ["PAYCASCADE_API_KEY"],
58
+ )
59
+ cache = InMemoryStateCache()
60
+
61
+
62
+ def report_poll_error(subscription_id: str, error: Exception) -> None:
63
+ logger.warning("PayCascade state refresh failed for %s: %s", subscription_id, error)
64
+
65
+
66
+ poller = StatePoller(
67
+ client=client,
68
+ cache=cache,
69
+ subscription_ids=[subscription_id],
70
+ interval_seconds=300,
71
+ on_error=report_poll_error,
72
+ )
73
+ poller.start()
74
+
75
+ try:
76
+ # In your request handler, use the authenticated user's mapped subscription.
77
+ decision = enforce_cached_state(
78
+ subscription_id=subscription_id,
79
+ cache=cache,
80
+ path="/reports",
81
+ method="GET",
82
+ fail_policy=FailPolicy.FAIL_OPEN,
83
+ )
84
+ if not decision.allowed:
85
+ # Return your application's own payment/recovery response.
86
+ ...
87
+ finally:
88
+ poller.stop()
89
+ client.close()
90
+ ```
91
+
92
+ `StatePoller.start()` refreshes immediately and then polls at the configured
93
+ interval with jitter. Provide `on_error` so refresh failures are observable.
94
+ For multi-process applications, each process has its own in-memory cache;
95
+ polling frequency and API load should be planned accordingly.
96
+
97
+ ## Access-policy behavior
98
+
99
+ - The default `FAIL_OPEN` policy allows access when state is missing or stale.
100
+ `FAIL_CLOSED` blocks access in those cases. Choose deliberately for your
101
+ product and communicate failure behavior to users.
102
+ - Healthy and soft-warning states allow access; the warning state can be used
103
+ to show a recovery notice. Restricted state permits configured read methods
104
+ and blocks mutations. Hard-locked and grace-period-expired states deny
105
+ protected access.
106
+ - Recovery routes such as `/billing`, `/stripe/portal-session`, and `/logout`
107
+ are exempt by default. Customize exemptions to preserve a usable payment
108
+ recovery path.
109
+ - Protect against forged or mismatched subscription IDs by looking up the
110
+ PayCascade subscription ID from your own authenticated account records.
111
+ - The cache is process-local and not a security boundary. Your application is
112
+ responsible for authorization, mapping users to subscriptions, and presenting
113
+ access decisions.
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ """PayCascade customer-side SDK: cache and access middleware."""
3
+
4
+ from paycascade_sdk.auth_intercept import (
5
+ DEFAULT_EXEMPT_ROUTES,
6
+ DEFAULT_MAX_STALENESS_SECONDS,
7
+ DEFAULT_READ_METHODS,
8
+ AccessDecision,
9
+ CachedSubscriptionState,
10
+ EscalationLevel,
11
+ FailPolicy,
12
+ InMemoryStateCache,
13
+ PayCascadeClient,
14
+ StateCache,
15
+ StatePoller,
16
+ calculate_jittered_interval,
17
+ enforce_cached_state,
18
+ initialize_state_cache,
19
+ is_route_exempt,
20
+ refresh_subscription_state,
21
+ )
22
+
23
+ __all__ = [
24
+ "DEFAULT_EXEMPT_ROUTES",
25
+ "DEFAULT_MAX_STALENESS_SECONDS",
26
+ "DEFAULT_READ_METHODS",
27
+ "AccessDecision",
28
+ "CachedSubscriptionState",
29
+ "EscalationLevel",
30
+ "FailPolicy",
31
+ "InMemoryStateCache",
32
+ "PayCascadeClient",
33
+ "StateCache",
34
+ "StatePoller",
35
+ "calculate_jittered_interval",
36
+ "enforce_cached_state",
37
+ "initialize_state_cache",
38
+ "is_route_exempt",
39
+ "refresh_subscription_state",
40
+ ]
@@ -0,0 +1,387 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ """Customer-side SDK cache and access middleware for PayCascade.
3
+
4
+ Provides background polling of the PayCascade hosted GET /api/v1/state endpoint,
5
+ in-memory caching with jitter, and request-level access enforcement policies.
6
+ This module is part of the standalone `paycascade-sdk` package and must never
7
+ import from `app/` or require server-side dependencies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from enum import Enum
15
+ import random
16
+ import threading
17
+ import time
18
+ from typing import Any, Callable, Sequence
19
+ from uuid import UUID
20
+ import httpx
21
+
22
+
23
+ class EscalationLevel(str, Enum):
24
+ HEALTHY = "HEALTHY"
25
+ SOFT_WARNING = "SOFT_WARNING"
26
+ SCOPE_RESTRICTED = "SCOPE_RESTRICTED"
27
+ HARD_LOCKED = "HARD_LOCKED"
28
+ GRACE_PERIOD_EXPIRED = "GRACE_PERIOD_EXPIRED"
29
+
30
+
31
+ class FailPolicy(str, Enum):
32
+ FAIL_OPEN = "FAIL_OPEN"
33
+ FAIL_CLOSED = "FAIL_CLOSED"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CachedSubscriptionState:
38
+ subscription_id: str
39
+ level: EscalationLevel
40
+ failed_at: datetime | None
41
+ retry_count: int
42
+ evaluated_at: datetime
43
+ cached_at: datetime
44
+
45
+ def is_stale(self, max_staleness_seconds: float, now: datetime | None = None) -> bool:
46
+ current_time = now or datetime.now(timezone.utc)
47
+ cached_time = self.cached_at
48
+ if cached_time.tzinfo is None:
49
+ cached_time = cached_time.replace(tzinfo=timezone.utc)
50
+ if current_time.tzinfo is None:
51
+ current_time = current_time.replace(tzinfo=timezone.utc)
52
+ age = (current_time - cached_time).total_seconds()
53
+ return age > max_staleness_seconds
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class AccessDecision:
58
+ allowed: bool
59
+ level: EscalationLevel | None
60
+ reason: str
61
+ warning: bool = False
62
+ is_stale: bool = False
63
+ is_exempt_route: bool = False
64
+
65
+
66
+ DEFAULT_EXEMPT_ROUTES: tuple[str, ...] = (
67
+ "/billing",
68
+ "/stripe/portal-session",
69
+ "/logout",
70
+ )
71
+
72
+ DEFAULT_READ_METHODS: tuple[str, ...] = ("GET", "HEAD", "OPTIONS")
73
+ DEFAULT_MAX_STALENESS_SECONDS: float = 86400.0 # 24 hours
74
+
75
+
76
+ class StateCache:
77
+ """Interface / base class for customer subscription state caching."""
78
+
79
+ def get(self, subscription_id: str) -> CachedSubscriptionState | None:
80
+ raise NotImplementedError
81
+
82
+ def set(self, state: CachedSubscriptionState) -> None:
83
+ raise NotImplementedError
84
+
85
+
86
+ class InMemoryStateCache(StateCache):
87
+ """Thread-safe in-memory cache for subscription states."""
88
+
89
+ def __init__(self) -> None:
90
+ self._lock = threading.RLock()
91
+ self._cache: dict[str, CachedSubscriptionState] = {}
92
+
93
+ def get(self, subscription_id: str) -> CachedSubscriptionState | None:
94
+ with self._lock:
95
+ return self._cache.get(str(subscription_id))
96
+
97
+ def set(self, state: CachedSubscriptionState) -> None:
98
+ with self._lock:
99
+ self._cache[str(state.subscription_id)] = state
100
+
101
+ def clear(self) -> None:
102
+ with self._lock:
103
+ self._cache.clear()
104
+
105
+
106
+ def parse_iso_datetime(value: str | None) -> datetime | None:
107
+ if value is None:
108
+ return None
109
+ # Normalize Z to +00:00 for fromisoformat compatibility
110
+ clean_value = value.replace("Z", "+00:00")
111
+ dt = datetime.fromisoformat(clean_value)
112
+ if dt.tzinfo is None:
113
+ dt = dt.replace(tzinfo=timezone.utc)
114
+ return dt.astimezone(timezone.utc)
115
+
116
+
117
+ class PayCascadeClient:
118
+ """Client for interacting with the PayCascade hosted API contract."""
119
+
120
+ def __init__(
121
+ self,
122
+ base_url: str,
123
+ api_key: str,
124
+ http_client: httpx.Client | None = None,
125
+ timeout: float = 10.0,
126
+ ) -> None:
127
+ self.base_url = base_url.rstrip("/")
128
+ self.api_key = api_key
129
+ self.timeout = timeout
130
+ self._client = http_client or httpx.Client(timeout=timeout)
131
+ self._owns_client = http_client is None
132
+
133
+ def close(self) -> None:
134
+ if self._owns_client:
135
+ self._client.close()
136
+
137
+ def fetch_state(self, subscription_id: str | UUID) -> CachedSubscriptionState:
138
+ url = f"{self.base_url}/api/v1/state"
139
+ headers = {
140
+ "Authorization": f"Bearer {self.api_key}",
141
+ "Accept": "application/json",
142
+ }
143
+ params = {"subscription_id": str(subscription_id)}
144
+
145
+ response = self._client.get(url, headers=headers, params=params)
146
+ response.raise_for_status()
147
+ data = response.json()
148
+
149
+ now = datetime.now(timezone.utc)
150
+ return CachedSubscriptionState(
151
+ subscription_id=str(data["subscription_id"]),
152
+ level=EscalationLevel(data["level"]),
153
+ failed_at=parse_iso_datetime(data.get("failed_at")),
154
+ retry_count=int(data["retry_count"]),
155
+ evaluated_at=parse_iso_datetime(data["evaluated_at"]) or now,
156
+ cached_at=now,
157
+ )
158
+
159
+
160
+ def calculate_jittered_interval(interval_seconds: float, jitter_ratio: float = 0.2) -> float:
161
+ """Compute a randomized interval with +/- jitter_ratio spread."""
162
+ if jitter_ratio <= 0.0:
163
+ return interval_seconds
164
+ jitter_bound = interval_seconds * jitter_ratio
165
+ return random.uniform(interval_seconds - jitter_bound, interval_seconds + jitter_bound)
166
+
167
+
168
+ class StatePoller:
169
+ """Background poller that periodically refreshes subscription state into a cache."""
170
+
171
+ def __init__(
172
+ self,
173
+ client: PayCascadeClient,
174
+ cache: StateCache,
175
+ subscription_ids: Sequence[str | UUID] | Callable[[], Sequence[str | UUID]],
176
+ interval_seconds: float = 300.0,
177
+ jitter_ratio: float = 0.2,
178
+ on_error: Callable[[str, Exception], None] | None = None,
179
+ ) -> None:
180
+ self.client = client
181
+ self.cache = cache
182
+ self.subscription_ids = subscription_ids
183
+ self.interval_seconds = interval_seconds
184
+ self.jitter_ratio = jitter_ratio
185
+ self.on_error = on_error
186
+ self._stop_event = threading.Event()
187
+ self._thread: threading.Thread | None = None
188
+
189
+ def _get_subscription_ids(self) -> Sequence[str | UUID]:
190
+ if callable(self.subscription_ids):
191
+ return self.subscription_ids()
192
+ return self.subscription_ids
193
+
194
+ def refresh_all(self) -> dict[str, CachedSubscriptionState]:
195
+ refreshed: dict[str, CachedSubscriptionState] = {}
196
+ for sub_id in self._get_subscription_ids():
197
+ sub_id_str = str(sub_id)
198
+ try:
199
+ state = self.client.fetch_state(sub_id_str)
200
+ self.cache.set(state)
201
+ refreshed[sub_id_str] = state
202
+ except Exception as exc:
203
+ if self.on_error:
204
+ self.on_error(sub_id_str, exc)
205
+ return refreshed
206
+
207
+ def run_loop(self) -> None:
208
+ while not self._stop_event.is_set():
209
+ self.refresh_all()
210
+ delay = calculate_jittered_interval(self.interval_seconds, self.jitter_ratio)
211
+ if self._stop_event.wait(timeout=max(0.01, delay)):
212
+ break
213
+
214
+ def start(self) -> None:
215
+ if self._thread is not None and self._thread.is_alive():
216
+ return
217
+ self._stop_event.clear()
218
+ self._thread = threading.Thread(target=self.run_loop, daemon=True, name="PayCascadeStatePoller")
219
+ self._thread.start()
220
+
221
+ def stop(self, timeout: float = 5.0) -> None:
222
+ self._stop_event.set()
223
+ if self._thread is not None and self._thread.is_alive():
224
+ self._thread.join(timeout=timeout)
225
+
226
+
227
+ def is_route_exempt(path: str, exempt_routes: Sequence[str] = DEFAULT_EXEMPT_ROUTES) -> bool:
228
+ """Check if request path matches or is a subpath of an exempt recovery route."""
229
+ normalized_path = "/" + path.strip("/") if not path.startswith("/") else path
230
+ for exempt in exempt_routes:
231
+ normalized_exempt = "/" + exempt.strip("/") if not exempt.startswith("/") else exempt
232
+ if normalized_path == normalized_exempt or normalized_path.startswith(f"{normalized_exempt}/"):
233
+ return True
234
+ return False
235
+
236
+
237
+ def enforce_cached_state(
238
+ subscription_id: str | UUID,
239
+ cache: StateCache,
240
+ path: str,
241
+ method: str = "GET",
242
+ exempt_routes: Sequence[str] = DEFAULT_EXEMPT_ROUTES,
243
+ fail_policy: FailPolicy = FailPolicy.FAIL_OPEN,
244
+ max_staleness_seconds: float = DEFAULT_MAX_STALENESS_SECONDS,
245
+ read_methods: Sequence[str] = DEFAULT_READ_METHODS,
246
+ now: datetime | None = None,
247
+ ) -> AccessDecision:
248
+ """Enforce access control policies based strictly on cached state."""
249
+ sub_id_str = str(subscription_id)
250
+
251
+ # 1. Exempt recovery routes always bypass enforcement
252
+ if is_route_exempt(path, exempt_routes):
253
+ cached = cache.get(sub_id_str)
254
+ level = cached.level if cached else None
255
+ return AccessDecision(
256
+ allowed=True,
257
+ level=level,
258
+ reason="Route is exempt from payment access controls",
259
+ warning=False,
260
+ is_stale=False,
261
+ is_exempt_route=True,
262
+ )
263
+
264
+ cached = cache.get(sub_id_str)
265
+
266
+ # 2. Cache miss handling
267
+ if cached is None:
268
+ if fail_policy == FailPolicy.FAIL_OPEN:
269
+ return AccessDecision(
270
+ allowed=True,
271
+ level=None,
272
+ reason="Cache miss: fail-open policy allowed request",
273
+ warning=False,
274
+ is_stale=False,
275
+ is_exempt_route=False,
276
+ )
277
+ return AccessDecision(
278
+ allowed=False,
279
+ level=None,
280
+ reason="Cache miss: fail-closed policy blocked request",
281
+ warning=False,
282
+ is_stale=False,
283
+ is_exempt_route=False,
284
+ )
285
+
286
+ # 3. Staleness evaluation
287
+ is_stale = cached.is_stale(max_staleness_seconds=max_staleness_seconds, now=now)
288
+ if is_stale:
289
+ if fail_policy == FailPolicy.FAIL_OPEN:
290
+ return AccessDecision(
291
+ allowed=True,
292
+ level=cached.level,
293
+ reason=f"Cached state is stale (> {max_staleness_seconds}s): fail-open policy allowed request",
294
+ warning=True,
295
+ is_stale=True,
296
+ is_exempt_route=False,
297
+ )
298
+ return AccessDecision(
299
+ allowed=False,
300
+ level=cached.level,
301
+ reason=f"Cached state is stale (> {max_staleness_seconds}s): fail-closed policy blocked request",
302
+ warning=False,
303
+ is_stale=True,
304
+ is_exempt_route=False,
305
+ )
306
+
307
+ # 4. Active escalation level enforcement
308
+ normalized_method = method.upper()
309
+ level = cached.level
310
+
311
+ if level == EscalationLevel.HEALTHY:
312
+ return AccessDecision(
313
+ allowed=True,
314
+ level=level,
315
+ reason="Subscription is healthy",
316
+ warning=False,
317
+ is_stale=False,
318
+ )
319
+
320
+ if level == EscalationLevel.SOFT_WARNING:
321
+ return AccessDecision(
322
+ allowed=True,
323
+ level=level,
324
+ reason="Payment delinquent (soft warning): access allowed with warning indicator",
325
+ warning=True,
326
+ is_stale=False,
327
+ )
328
+
329
+ if level == EscalationLevel.SCOPE_RESTRICTED:
330
+ if normalized_method in (m.upper() for m in read_methods):
331
+ return AccessDecision(
332
+ allowed=True,
333
+ level=level,
334
+ reason="Payment delinquent (scope restricted): read access allowed",
335
+ warning=True,
336
+ is_stale=False,
337
+ )
338
+ return AccessDecision(
339
+ allowed=False,
340
+ level=level,
341
+ reason="Payment delinquent (scope restricted): mutating write operations are blocked",
342
+ warning=True,
343
+ is_stale=False,
344
+ )
345
+
346
+ if level == EscalationLevel.HARD_LOCKED:
347
+ return AccessDecision(
348
+ allowed=False,
349
+ level=level,
350
+ reason="Payment delinquent (hard locked): protected application access is blocked",
351
+ warning=True,
352
+ is_stale=False,
353
+ )
354
+
355
+ if level == EscalationLevel.GRACE_PERIOD_EXPIRED:
356
+ return AccessDecision(
357
+ allowed=False,
358
+ level=level,
359
+ reason="Grace period expired: protected application access is blocked",
360
+ warning=False,
361
+ is_stale=False,
362
+ )
363
+
364
+ # Fallback safety guard
365
+ return AccessDecision(
366
+ allowed=False,
367
+ level=level,
368
+ reason=f"Unknown escalation level: {level}",
369
+ warning=False,
370
+ is_stale=False,
371
+ )
372
+
373
+
374
+ def initialize_state_cache() -> StateCache:
375
+ """Helper factory to create a default in-memory state cache."""
376
+ return InMemoryStateCache()
377
+
378
+
379
+ def refresh_subscription_state(
380
+ client: PayCascadeClient,
381
+ cache: StateCache,
382
+ subscription_id: str | UUID,
383
+ ) -> CachedSubscriptionState:
384
+ """Synchronously refresh and cache subscription state for a single subscription."""
385
+ state = client.fetch_state(subscription_id)
386
+ cache.set(state)
387
+ return state
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: paycascade-sdk
3
+ Version: 0.1.1
4
+ Summary: Customer-side cache and access middleware for the PayCascade hosted billing-state API.
5
+ Author: BitBerry Labs
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ License-File: LICENSE.md
18
+ Requires-Dist: httpx>=0.27.0
19
+ Dynamic: license-file
@@ -0,0 +1,11 @@
1
+ LICENSE.md
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ paycascade_sdk/__init__.py
6
+ paycascade_sdk/auth_intercept.py
7
+ paycascade_sdk.egg-info/PKG-INFO
8
+ paycascade_sdk.egg-info/SOURCES.txt
9
+ paycascade_sdk.egg-info/dependency_links.txt
10
+ paycascade_sdk.egg-info/requires.txt
11
+ paycascade_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.27.0
@@ -0,0 +1 @@
1
+ paycascade_sdk
@@ -0,0 +1,40 @@
1
+ # Copyright (c) 2026 BitBerry Labs. SPDX-License-Identifier: MIT
2
+ [build-system]
3
+ requires = ["setuptools>=68"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "paycascade-sdk"
8
+ version = "0.1.1"
9
+ description = "Customer-side cache and access middleware for the PayCascade hosted billing-state API."
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "BitBerry Labs" }]
12
+ license = "MIT"
13
+ license-files = ["LICENSE.md"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27.0",
27
+ ]
28
+
29
+ # Deliberately excludes fastapi, sqlalchemy, celery, stripe, cryptography, and
30
+ # every other hosted-service dependency: this package never imports app/ and
31
+ # must be installable in a customer's app without pulling in server internals.
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["paycascade_sdk*"]
35
+
36
+ [tool.pytest.ini_options]
37
+ # Own config so `pytest` run from sdk/ never picks up the hosted service's
38
+ # root pytest.ini and stays scoped to this package's isolated dependency set.
39
+ testpaths = ["tests"]
40
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+