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 +22 -0
- profy/auth.py +30 -0
- profy/client.py +263 -0
- profy/contrib/__init__.py +1 -0
- profy/contrib/sqlalchemy.py +186 -0
- profy/exceptions.py +39 -0
- profy/py.typed +0 -0
- profy_sdk-0.1.0.dist-info/METADATA +168 -0
- profy_sdk-0.1.0.dist-info/RECORD +10 -0
- profy_sdk-0.1.0.dist-info/WHEEL +4 -0
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,,
|