profy-sdk 0.1.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.
profy/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Profy App SDK — OAuth + Events API client for Profy App developers."""
2
+
3
+ from .auth import TokenData
4
+ from .client import ProfyApp, ProfyAppSync
5
+ from .exceptions import (
6
+ AuthExpired,
7
+ InsufficientBalance,
8
+ InvalidEvent,
9
+ ProfyApiError,
10
+ ProfyError,
11
+ )
12
+
13
+ __all__ = [
14
+ "ProfyApp",
15
+ "ProfyAppSync",
16
+ "TokenData",
17
+ "ProfyError",
18
+ "ProfyApiError",
19
+ "AuthExpired",
20
+ "InsufficientBalance",
21
+ "InvalidEvent",
22
+ ]
profy/auth.py ADDED
@@ -0,0 +1,30 @@
1
+ """Token data model and refresh utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class TokenData:
11
+ """Holds an OAuth token pair with metadata."""
12
+
13
+ access_token: str
14
+ refresh_token: str
15
+ user_id: str
16
+ expires_at: float # unix timestamp
17
+
18
+ @property
19
+ def is_expired(self) -> bool:
20
+ return time.time() >= self.expires_at - 30 # 30s safety buffer
21
+
22
+ @classmethod
23
+ def from_response(cls, data: dict) -> "TokenData":
24
+ expires_in = int(data.get("expires_in", 3600))
25
+ return cls(
26
+ access_token=data["access_token"],
27
+ refresh_token=data["refresh_token"],
28
+ user_id=data["user_id"],
29
+ expires_at=time.time() + expires_in,
30
+ )
profy/client.py ADDED
@@ -0,0 +1,263 @@
1
+ """Profy App SDK — async client for OAuth + Events API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Callable
6
+
7
+ import httpx
8
+
9
+ from .auth import TokenData
10
+ from .exceptions import (
11
+ AuthExpired,
12
+ InsufficientBalance,
13
+ InvalidEvent,
14
+ ProfyApiError,
15
+ )
16
+
17
+ DEFAULT_BASE_URL = "https://profy.cn"
18
+
19
+
20
+ class ProfyApp:
21
+ """Async Profy App client.
22
+
23
+ Handles OAuth token exchange, automatic refresh, and event reporting.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ client_id: str,
29
+ client_secret: str,
30
+ *,
31
+ base_url: str = DEFAULT_BASE_URL,
32
+ on_token_refresh: Callable[[TokenData], None] | None = None,
33
+ ):
34
+ self.client_id = client_id
35
+ self.client_secret = client_secret
36
+ self.base_url = base_url.rstrip("/")
37
+ self.on_token_refresh = on_token_refresh
38
+ self._http = httpx.AsyncClient(
39
+ base_url=self.base_url,
40
+ timeout=30.0,
41
+ headers={"User-Agent": "profy-sdk-python/0.1.0"},
42
+ )
43
+
44
+ async def close(self) -> None:
45
+ await self._http.aclose()
46
+
47
+ async def __aenter__(self) -> "ProfyApp":
48
+ return self
49
+
50
+ async def __aexit__(self, *_: object) -> None:
51
+ await self.close()
52
+
53
+ async def exchange_code(self, code: str, redirect_uri: str) -> TokenData:
54
+ """Exchange an authorization code for access + refresh tokens."""
55
+ resp = await self._http.post(
56
+ "/oauth/token",
57
+ json={
58
+ "grant_type": "authorization_code",
59
+ "code": code,
60
+ "client_id": self.client_id,
61
+ "client_secret": self.client_secret,
62
+ "redirect_uri": redirect_uri,
63
+ },
64
+ )
65
+ if resp.status_code != 200:
66
+ raise ProfyApiError(resp.status_code, resp.json())
67
+ data = resp.json()
68
+ if "data" in data:
69
+ data = data["data"]
70
+ return TokenData.from_response(data)
71
+
72
+ async def refresh_token(self, refresh_token: str) -> TokenData:
73
+ """Use a refresh token to obtain a new access token."""
74
+ resp = await self._http.post(
75
+ "/oauth/token",
76
+ json={
77
+ "grant_type": "refresh_token",
78
+ "refresh_token": refresh_token,
79
+ "client_id": self.client_id,
80
+ "client_secret": self.client_secret,
81
+ },
82
+ )
83
+ if resp.status_code != 200:
84
+ raise AuthExpired()
85
+ data = resp.json()
86
+ if "data" in data:
87
+ data = data["data"]
88
+ token = TokenData.from_response(data)
89
+ if self.on_token_refresh:
90
+ self.on_token_refresh(token)
91
+ return token
92
+
93
+ async def report_event(
94
+ self,
95
+ event_name: str,
96
+ *,
97
+ token: TokenData,
98
+ idempotency_key: str | None = None,
99
+ metadata: dict[str, str] | None = None,
100
+ ) -> dict:
101
+ """Report a billing event. Auto-refreshes token if expired.
102
+
103
+ Args:
104
+ event_name: The meter event name defined in your profy.json.
105
+ token: User's OAuth token (auto-refreshes if expired).
106
+ idempotency_key: Dedup key (auto-generated from event+timestamp if omitted).
107
+ metadata: Optional key-value pairs attached to this event.
108
+ """
109
+ import uuid
110
+
111
+ active_token = token
112
+ if active_token.is_expired:
113
+ active_token = await self.refresh_token(token.refresh_token)
114
+
115
+ idem_key = idempotency_key or f"{event_name}:{uuid.uuid4().hex[:16]}"
116
+ payload: dict = {"event": event_name, "idempotency_key": idem_key}
117
+ if metadata:
118
+ payload["metadata"] = metadata
119
+
120
+ resp = await self._http.post(
121
+ "/openapi/v1/events",
122
+ headers={"Authorization": f"Bearer {active_token.access_token}"},
123
+ json=payload,
124
+ )
125
+
126
+ if resp.status_code == 401:
127
+ try:
128
+ active_token = await self.refresh_token(token.refresh_token)
129
+ except AuthExpired:
130
+ raise
131
+ resp = await self._http.post(
132
+ "/openapi/v1/events",
133
+ headers={"Authorization": f"Bearer {active_token.access_token}"},
134
+ json=payload,
135
+ )
136
+
137
+ if resp.status_code == 402:
138
+ raise InsufficientBalance()
139
+ if resp.status_code == 400:
140
+ raise InvalidEvent(resp.json().get("message", "Invalid event"))
141
+ if resp.status_code == 429:
142
+ raise ProfyApiError(429, resp.json())
143
+ if resp.status_code >= 400:
144
+ raise ProfyApiError(resp.status_code, resp.json())
145
+
146
+ return resp.json()
147
+
148
+
149
+ class ProfyAppSync:
150
+ """Synchronous wrapper around ProfyApp for non-async contexts."""
151
+
152
+ def __init__(
153
+ self,
154
+ client_id: str,
155
+ client_secret: str,
156
+ *,
157
+ base_url: str = DEFAULT_BASE_URL,
158
+ on_token_refresh: Callable[[TokenData], None] | None = None,
159
+ ):
160
+ self.client_id = client_id
161
+ self.client_secret = client_secret
162
+ self.base_url = base_url.rstrip("/")
163
+ self.on_token_refresh = on_token_refresh
164
+ self._http = httpx.Client(
165
+ base_url=self.base_url,
166
+ timeout=30.0,
167
+ headers={"User-Agent": "profy-sdk-python/0.1.0"},
168
+ )
169
+
170
+ def close(self) -> None:
171
+ self._http.close()
172
+
173
+ def __enter__(self) -> "ProfyAppSync":
174
+ return self
175
+
176
+ def __exit__(self, *_: object) -> None:
177
+ self.close()
178
+
179
+ def exchange_code(self, code: str, redirect_uri: str) -> TokenData:
180
+ resp = self._http.post(
181
+ "/oauth/token",
182
+ json={
183
+ "grant_type": "authorization_code",
184
+ "code": code,
185
+ "client_id": self.client_id,
186
+ "client_secret": self.client_secret,
187
+ "redirect_uri": redirect_uri,
188
+ },
189
+ )
190
+ if resp.status_code != 200:
191
+ raise ProfyApiError(resp.status_code, resp.json())
192
+ data = resp.json()
193
+ if "data" in data:
194
+ data = data["data"]
195
+ return TokenData.from_response(data)
196
+
197
+ def refresh_token(self, refresh_token: str) -> TokenData:
198
+ resp = self._http.post(
199
+ "/oauth/token",
200
+ json={
201
+ "grant_type": "refresh_token",
202
+ "refresh_token": refresh_token,
203
+ "client_id": self.client_id,
204
+ "client_secret": self.client_secret,
205
+ },
206
+ )
207
+ if resp.status_code != 200:
208
+ raise AuthExpired()
209
+ data = resp.json()
210
+ if "data" in data:
211
+ data = data["data"]
212
+ token = TokenData.from_response(data)
213
+ if self.on_token_refresh:
214
+ self.on_token_refresh(token)
215
+ return token
216
+
217
+ def report_event(
218
+ self,
219
+ event_name: str,
220
+ *,
221
+ token: TokenData,
222
+ idempotency_key: str | None = None,
223
+ metadata: dict[str, str] | None = None,
224
+ ) -> dict:
225
+ """Report a billing event (sync version)."""
226
+ import uuid
227
+
228
+ active_token = token
229
+ if active_token.is_expired:
230
+ active_token = self.refresh_token(token.refresh_token)
231
+
232
+ idem_key = idempotency_key or f"{event_name}:{uuid.uuid4().hex[:16]}"
233
+ payload: dict = {"event": event_name, "idempotency_key": idem_key}
234
+ if metadata:
235
+ payload["metadata"] = metadata
236
+
237
+ resp = self._http.post(
238
+ "/openapi/v1/events",
239
+ headers={"Authorization": f"Bearer {active_token.access_token}"},
240
+ json=payload,
241
+ )
242
+
243
+ if resp.status_code == 401:
244
+ try:
245
+ active_token = self.refresh_token(token.refresh_token)
246
+ except AuthExpired:
247
+ raise
248
+ resp = self._http.post(
249
+ "/openapi/v1/events",
250
+ headers={"Authorization": f"Bearer {active_token.access_token}"},
251
+ json=payload,
252
+ )
253
+
254
+ if resp.status_code == 402:
255
+ raise InsufficientBalance()
256
+ if resp.status_code == 400:
257
+ raise InvalidEvent(resp.json().get("message", "Invalid event"))
258
+ if resp.status_code == 429:
259
+ raise ProfyApiError(429, resp.json())
260
+ if resp.status_code >= 400:
261
+ raise ProfyApiError(resp.status_code, resp.json())
262
+
263
+ return resp.json()
@@ -0,0 +1 @@
1
+ """Optional contrib modules — storage backends for Profy token persistence."""
@@ -0,0 +1,186 @@
1
+ """Optional SQLAlchemy token storage for Profy SDK.
2
+
3
+ Usage:
4
+ from sqlalchemy.orm import DeclarativeBase
5
+ from profy.contrib.sqlalchemy import create_token_model, SQLAlchemyTokenStore
6
+
7
+ class Base(DeclarativeBase):
8
+ pass
9
+
10
+ ProfyOAuthToken = create_token_model(Base)
11
+ # Base.metadata.create_all(engine) will auto-create the table.
12
+
13
+ # For automatic token persistence + refresh:
14
+ store = SQLAlchemyTokenStore(session_factory)
15
+ app = ProfyApp(client_id=..., client_secret=..., on_token_refresh=store.on_refresh)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime, timedelta
21
+ from typing import TYPE_CHECKING, Any, Callable
22
+
23
+ from ..auth import TokenData
24
+
25
+ if TYPE_CHECKING:
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+ from sqlalchemy.orm import DeclarativeBase, Session
28
+
29
+
30
+ def create_token_model(Base: type[Any], table_name: str = "profy_oauth_token"):
31
+ """Dynamically create a ProfyOAuthToken ORM model bound to the given Base.
32
+
33
+ The table is created when you call Base.metadata.create_all().
34
+ No migration tool needed for new databases.
35
+
36
+ Args:
37
+ Base: Your SQLAlchemy DeclarativeBase class.
38
+ table_name: Override table name if needed.
39
+
40
+ Returns:
41
+ The ORM model class (also registered on Base).
42
+ """
43
+ from sqlalchemy import Column, DateTime, Integer, String, Text
44
+
45
+ class ProfyOAuthToken(Base): # type: ignore[valid-type]
46
+ __tablename__ = table_name
47
+
48
+ id = Column(Integer, primary_key=True, index=True)
49
+ profy_user_id = Column(String(64), unique=True, nullable=False, index=True)
50
+ access_token = Column(Text, nullable=False)
51
+ refresh_token = Column(Text, nullable=False)
52
+ expires_at = Column(DateTime, nullable=False)
53
+ scope = Column(String(255), nullable=True)
54
+ created_at = Column(DateTime, default=datetime.utcnow)
55
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
56
+
57
+ def to_token_data(self) -> TokenData:
58
+ return TokenData(
59
+ access_token=self.access_token,
60
+ refresh_token=self.refresh_token,
61
+ user_id=self.profy_user_id,
62
+ expires_at=self.expires_at.timestamp() if self.expires_at else 0,
63
+ )
64
+
65
+ return ProfyOAuthToken
66
+
67
+
68
+ class SQLAlchemyTokenStore:
69
+ """Async token store backed by SQLAlchemy.
70
+
71
+ Provides save/load/on_refresh for use with ProfyApp.
72
+
73
+ Args:
74
+ session_factory: Callable that returns an AsyncSession (e.g. async_sessionmaker()).
75
+ model: The ORM model class created by create_token_model().
76
+ """
77
+
78
+ def __init__(self, session_factory: Callable[[], Any], model: type[Any]):
79
+ self._session_factory = session_factory
80
+ self._model = model
81
+
82
+ async def save(self, user_id: str, token: TokenData, scope: str | None = None) -> None:
83
+ """Upsert token for a Profy user."""
84
+ from sqlalchemy import select
85
+
86
+ async with self._session_factory() as session:
87
+ result = await session.execute(
88
+ select(self._model).where(self._model.profy_user_id == user_id)
89
+ )
90
+ record = result.scalar_one_or_none()
91
+ expires_at = datetime.utcfromtimestamp(token.expires_at)
92
+
93
+ if record:
94
+ record.access_token = token.access_token
95
+ record.refresh_token = token.refresh_token
96
+ record.expires_at = expires_at
97
+ record.scope = scope
98
+ else:
99
+ record = self._model(
100
+ profy_user_id=user_id,
101
+ access_token=token.access_token,
102
+ refresh_token=token.refresh_token,
103
+ expires_at=expires_at,
104
+ scope=scope,
105
+ )
106
+ session.add(record)
107
+
108
+ await session.commit()
109
+
110
+ async def load(self, user_id: str) -> TokenData | None:
111
+ """Load token for a Profy user. Returns None if not found."""
112
+ from sqlalchemy import select
113
+
114
+ async with self._session_factory() as session:
115
+ result = await session.execute(
116
+ select(self._model).where(self._model.profy_user_id == user_id)
117
+ )
118
+ record = result.scalar_one_or_none()
119
+ if not record:
120
+ return None
121
+ return record.to_token_data()
122
+
123
+ def on_refresh(self, token: TokenData) -> None:
124
+ """Callback for ProfyApp.on_token_refresh — persists refreshed tokens.
125
+
126
+ Since on_token_refresh is sync, we fire-and-forget via a background task.
127
+ For guaranteed persistence, call save() directly in your async code.
128
+ """
129
+ import asyncio
130
+
131
+ try:
132
+ loop = asyncio.get_running_loop()
133
+ loop.create_task(self.save(token.user_id, token))
134
+ except RuntimeError:
135
+ pass
136
+
137
+
138
+ class SQLAlchemyTokenStoreSync:
139
+ """Sync token store for use with ProfyAppSync."""
140
+
141
+ def __init__(self, session_factory: Callable[[], Any], model: type[Any]):
142
+ self._session_factory = session_factory
143
+ self._model = model
144
+
145
+ def save(self, user_id: str, token: TokenData, scope: str | None = None) -> None:
146
+ from sqlalchemy import select
147
+
148
+ with self._session_factory() as session:
149
+ result = session.execute(
150
+ select(self._model).where(self._model.profy_user_id == user_id)
151
+ )
152
+ record = result.scalar_one_or_none()
153
+ expires_at = datetime.utcfromtimestamp(token.expires_at)
154
+
155
+ if record:
156
+ record.access_token = token.access_token
157
+ record.refresh_token = token.refresh_token
158
+ record.expires_at = expires_at
159
+ record.scope = scope
160
+ else:
161
+ record = self._model(
162
+ profy_user_id=user_id,
163
+ access_token=token.access_token,
164
+ refresh_token=token.refresh_token,
165
+ expires_at=expires_at,
166
+ scope=scope,
167
+ )
168
+ session.add(record)
169
+
170
+ session.commit()
171
+
172
+ def load(self, user_id: str) -> TokenData | None:
173
+ from sqlalchemy import select
174
+
175
+ with self._session_factory() as session:
176
+ result = session.execute(
177
+ select(self._model).where(self._model.profy_user_id == user_id)
178
+ )
179
+ record = result.scalar_one_or_none()
180
+ if not record:
181
+ return None
182
+ return record.to_token_data()
183
+
184
+ def on_refresh(self, token: TokenData) -> None:
185
+ """Sync callback for ProfyAppSync.on_token_refresh."""
186
+ self.save(token.user_id, token)
profy/exceptions.py ADDED
@@ -0,0 +1,39 @@
1
+ """Profy SDK exception hierarchy."""
2
+
3
+
4
+ class ProfyError(Exception):
5
+ """Base exception for all Profy SDK errors."""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+
11
+
12
+ class AuthExpired(ProfyError):
13
+ """Access token expired and refresh also failed."""
14
+
15
+ def __init__(self, message: str = "Authentication expired, re-authorize required"):
16
+ super().__init__(message, status_code=401)
17
+
18
+
19
+ class InsufficientBalance(ProfyError):
20
+ """User does not have enough credits for this event."""
21
+
22
+ def __init__(self, message: str = "Insufficient balance"):
23
+ super().__init__(message, status_code=402)
24
+
25
+
26
+ class InvalidEvent(ProfyError):
27
+ """The event_name is not configured for this app."""
28
+
29
+ def __init__(self, message: str = "Invalid event name"):
30
+ super().__init__(message, status_code=400)
31
+
32
+
33
+ class ProfyApiError(ProfyError):
34
+ """Generic API error with status code and response body."""
35
+
36
+ def __init__(self, status_code: int, body: dict | None = None):
37
+ self.body = body or {}
38
+ msg = self.body.get("message", f"API error {status_code}")
39
+ super().__init__(msg, status_code=status_code)
profy/py.typed ADDED
File without changes
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.4
2
+ Name: profy-sdk
3
+ Version: 0.1.0
4
+ Summary: Profy App SDK — OAuth token management and event reporting
5
+ Project-URL: Homepage, https://profy.cn
6
+ Project-URL: Documentation, https://profy.cn/zh/documentation/sdk-guide
7
+ Project-URL: Repository, https://github.com/profy-ai/profy
8
+ Project-URL: Issues, https://github.com/profy-ai/profy/issues
9
+ License: MIT
10
+ Keywords: billing,events-api,oauth,profy,saas,sdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: httpx>=0.25.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Requires-Dist: respx>=0.21; extra == 'dev'
28
+ Provides-Extra: sqlalchemy
29
+ Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # profy-sdk
33
+
34
+ Official Python SDK for the [Profy](https://profy.cn) App platform — OAuth token management and event reporting.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install profy-sdk
40
+
41
+ # With optional SQLAlchemy token storage:
42
+ pip install profy-sdk[sqlalchemy]
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from profy import ProfyApp
49
+
50
+ async with ProfyApp(
51
+ client_id="your-app-id",
52
+ client_secret="your-app-secret",
53
+ ) as profy:
54
+ # Exchange authorization code for tokens
55
+ token = await profy.exchange_code(code, "https://your-app.com/callback")
56
+
57
+ # Report a billing event
58
+ await profy.report_event("generate_report", token=token)
59
+ ```
60
+
61
+ ### Synchronous Usage
62
+
63
+ ```python
64
+ from profy import ProfyAppSync
65
+
66
+ with ProfyAppSync(
67
+ client_id="your-app-id",
68
+ client_secret="your-app-secret",
69
+ ) as profy:
70
+ token = profy.exchange_code(code, redirect_uri)
71
+ profy.report_event("generate_report", token=token)
72
+ ```
73
+
74
+ ## Features
75
+
76
+ - **OAuth2 Authorization Code Flow** — exchange codes, refresh tokens automatically
77
+ - **Events API** — report billing events with auto token refresh and retry on 401
78
+ - **Async + Sync** — `ProfyApp` (async) and `ProfyAppSync` (sync) clients
79
+ - **Type hints** — full type annotations, PEP 561 compatible
80
+ - **Minimal dependencies** — only `httpx`
81
+ - **Optional SQLAlchemy integration** — pre-built token model + store
82
+
83
+ ## Token Persistence (Optional)
84
+
85
+ ### Using SQLAlchemy
86
+
87
+ ```python
88
+ from profy import ProfyApp
89
+ from profy.contrib.sqlalchemy import create_token_model, SQLAlchemyTokenStore
90
+
91
+ # 1. Create the ORM model (auto-creates table with Base.metadata.create_all())
92
+ ProfyOAuthToken = create_token_model(Base)
93
+
94
+ # 2. Create store
95
+ store = SQLAlchemyTokenStore(async_session_factory, ProfyOAuthToken)
96
+
97
+ # 3. Initialize SDK with persistence
98
+ profy = ProfyApp(
99
+ client_id="...",
100
+ client_secret="...",
101
+ on_token_refresh=store.on_refresh,
102
+ )
103
+
104
+ # 4. Save after exchange
105
+ token = await profy.exchange_code(code, redirect_uri)
106
+ await store.save(token.user_id, token, scope="events:write")
107
+
108
+ # 5. Load later
109
+ saved = await store.load(user_id)
110
+ ```
111
+
112
+ ### Custom Storage
113
+
114
+ ```python
115
+ from profy import ProfyApp, TokenData
116
+
117
+ async def persist_token(token: TokenData):
118
+ await redis.set(f"profy:{token.user_id}", token.access_token)
119
+
120
+ profy = ProfyApp(
121
+ client_id="...",
122
+ client_secret="...",
123
+ on_token_refresh=persist_token,
124
+ )
125
+ ```
126
+
127
+ ## API Reference
128
+
129
+ ### `ProfyApp` / `ProfyAppSync`
130
+
131
+ | Method | Description |
132
+ |--------|-------------|
133
+ | `exchange_code(code, redirect_uri)` | Exchange authorization code for tokens |
134
+ | `refresh_token(refresh_token)` | Manually refresh an expired token |
135
+ | `report_event(event_name, *, token, idempotency_key?, metadata?)` | Report a billing event |
136
+
137
+ ### `TokenData`
138
+
139
+ ```python
140
+ @dataclass
141
+ class TokenData:
142
+ access_token: str
143
+ refresh_token: str
144
+ user_id: str
145
+ expires_at: float # unix timestamp
146
+
147
+ @property
148
+ def is_expired(self) -> bool: ...
149
+ ```
150
+
151
+ ### Exceptions
152
+
153
+ | Class | Status | Description |
154
+ |-------|--------|-------------|
155
+ | `AuthExpired` | 401 | Token expired and refresh failed |
156
+ | `InsufficientBalance` | 402 | User has insufficient credits |
157
+ | `InvalidEvent` | 400 | Event name not configured |
158
+ | `ProfyApiError` | varies | Generic API error |
159
+
160
+ ## Documentation
161
+
162
+ - [Integration Quickstart](https://profy.cn/zh/documentation/integration-quickstart)
163
+ - [SDK Guide](https://profy.cn/zh/documentation/sdk-guide)
164
+ - [API Reference](https://profy.cn/zh/api/post-token)
165
+
166
+ ## License
167
+
168
+ MIT
@@ -0,0 +1,10 @@
1
+ profy/__init__.py,sha256=pfaj0CDnxWYVPopT8xDdlhI09DPu1C0rCmTrzJ6C9ZM,444
2
+ profy/auth.py,sha256=RMm0elSq0yVIMdCA1hGMgfryOg_pyhCnpeoYbE2Z3ZM,774
3
+ profy/client.py,sha256=75QRY7Bl7_bIekM7he6rzeEI3lfAcYh44j0teN4-lLs,8353
4
+ profy/exceptions.py,sha256=ZA8IC3A6vmJeiHYc3kXBZdX_C7XkXdM7KgiYC4K4Kn0,1233
5
+ profy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ profy/contrib/__init__.py,sha256=QrU2m7noMzcc9yBWJtxV4RudI_dlwC9KtEjiZQKvRv4,81
7
+ profy/contrib/sqlalchemy.py,sha256=FApd3pDXTXikwlLUt2CRn-ns0hCugD7TGSLbu0yTRe4,6704
8
+ profy_sdk-0.1.0.dist-info/METADATA,sha256=FsY2m--AL-rgK43_8VoTwzR1PUTC1RtDLE5STAvWFZc,4729
9
+ profy_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ profy_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any