profy-sdk 0.1.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,279 @@
1
+ # =============================================================================
2
+ # Profy Monorepo .gitignore
3
+ # =============================================================================
4
+
5
+ # -----------------------------------------------------------------------------
6
+ # Global / 通用
7
+ # -----------------------------------------------------------------------------
8
+
9
+ # OS generated files
10
+ .DS_Store
11
+ .DS_Store?
12
+ ._*
13
+ .Spotlight-V100
14
+ .Trashes
15
+ ehthumbs.db
16
+ Thumbs.db
17
+
18
+ # IDE files
19
+ .idea/
20
+ .vscode/*
21
+ !.vscode/settings.json
22
+ !.vscode/extensions.json
23
+ *.suo
24
+ *.ntvs*
25
+ *.njsproj
26
+ *.sln
27
+ *.sw?
28
+
29
+ # Logs
30
+ logs
31
+ *.log
32
+ npm-debug.log*
33
+ yarn-debug.log*
34
+ yarn-error.log*
35
+ pnpm-debug.log*
36
+ lerna-debug.log*
37
+ pnpm-lock.yaml
38
+
39
+ # Environment files
40
+ !.env*
41
+ *.pem
42
+
43
+ # Core: local / server-only secrets (never commit)
44
+ services/core/.env.local
45
+ services/core/.env.secrets
46
+ services/agent-runtime/.env.secrets
47
+ **/.env.secrets
48
+
49
+
50
+
51
+
52
+ # -----------------------------------------------------------------------------
53
+ # Static assets served from CDN (too large for Git)
54
+ # -----------------------------------------------------------------------------
55
+ apps/web/public/images/
56
+
57
+ # -----------------------------------------------------------------------------
58
+ # Web (Next.js / Frontend)
59
+ # -----------------------------------------------------------------------------
60
+
61
+ # Dependencies
62
+ node_modules/
63
+ .pnpm-store/
64
+
65
+ # Next.js
66
+ .next/
67
+ web/.next/
68
+ out/
69
+ web/out/
70
+
71
+ # Vite
72
+ web/dist/
73
+ web/.vite/
74
+ web/coverage/
75
+ coverage/
76
+ tests/eval/golden/report-*.json
77
+ tests/e2e/playwright-report/
78
+ tests/e2e/test-results/
79
+ *.local
80
+
81
+ # Vercel
82
+ .vercel
83
+
84
+ # TypeScript
85
+ *.tsbuildinfo
86
+
87
+ # Drizzle migrations (auto-generated, should be committed carefully)
88
+ # Uncomment if you want to ignore all migrations:
89
+ # apps/web/drizzle/*.sql
90
+ # apps/web/drizzle/meta/*.json
91
+
92
+ # -----------------------------------------------------------------------------
93
+ # AI-Backend (Python)
94
+ # -----------------------------------------------------------------------------
95
+
96
+ # Byte-compiled / optimized
97
+ __pycache__/
98
+ *.py[cod]
99
+ *$py.class
100
+
101
+ # Virtual environments
102
+ .venv
103
+ venv/
104
+ env.bak/
105
+ venv.bak/
106
+
107
+ # Package manager
108
+ uv.lock
109
+
110
+ # C extensions
111
+ *.so
112
+
113
+ # Distribution / packaging
114
+ .Python
115
+ build/
116
+ develop-eggs/
117
+ dist/
118
+ /downloads/
119
+ eggs/
120
+ .eggs/
121
+ lib64/
122
+ parts/
123
+ sdist/
124
+ var/
125
+ wheels/
126
+ share/python-wheels/
127
+ *.egg-info/
128
+ .installed.cfg
129
+ *.egg
130
+ MANIFEST
131
+
132
+ # PyInstaller
133
+ *.manifest
134
+ *.spec
135
+
136
+ # Installer logs
137
+ pip-log.txt
138
+ pip-delete-this-directory.txt
139
+
140
+ # Unit test / coverage reports
141
+ htmlcov/
142
+ .tox/
143
+ .nox/
144
+ .coverage
145
+ .coverage.*
146
+ .cache
147
+ nosetests.xml
148
+ coverage.xml
149
+ *.cover
150
+ *.py,cover
151
+ .hypothesis/
152
+ .pytest_cache/
153
+ cover/
154
+
155
+ # Translations
156
+ *.mo
157
+ *.pot
158
+
159
+ # Django
160
+ local_settings.py
161
+ db.sqlite3
162
+ db.sqlite3-journal
163
+
164
+ # Flask
165
+ instance/
166
+ .webassets-cache
167
+
168
+ # Scrapy
169
+ .scrapy
170
+
171
+ # Sphinx documentation
172
+ docs/_build/
173
+
174
+ # PyBuilder
175
+ .pybuilder/
176
+ target/
177
+
178
+ # Jupyter / IPython
179
+ .ipynb_checkpoints
180
+ profile_default/
181
+ ipython_config.py
182
+
183
+ # Type checkers
184
+ .mypy_cache/
185
+ .dmypy.json
186
+ dmypy.json
187
+ .pyre/
188
+ .pytype/
189
+
190
+ # Cython debug symbols
191
+ cython_debug/
192
+
193
+ # pdm
194
+ .pdm.toml
195
+ .pdm-python
196
+ .pdm-build/
197
+
198
+ # PEP 582
199
+ __pypackages__/
200
+
201
+ # Celery
202
+ celerybeat-schedule
203
+ celerybeat.pid
204
+
205
+ # SageMath
206
+ *.sage.py
207
+
208
+ # Milvus Lite data
209
+ data/
210
+ *.db
211
+
212
+ # Spyder / Rope
213
+ .spyderproject
214
+ .spyproject
215
+ .ropeproject
216
+
217
+ # mkdocs
218
+ /site
219
+
220
+ # -----------------------------------------------------------------------------
221
+ # Desktop (Electron / Tauri)
222
+ # -----------------------------------------------------------------------------
223
+
224
+ # (详细规则见 apps/desktop/.gitignore)
225
+
226
+ # -----------------------------------------------------------------------------
227
+ # iOS (Xcode / Swift)
228
+ # -----------------------------------------------------------------------------
229
+
230
+ # Xcode - 用户数据 (个人偏好,不应提交)
231
+ xcuserdata/
232
+ *.xcuserstate
233
+
234
+ # Xcode - 构建产物
235
+ DerivedData/
236
+
237
+ # SourceKit-LSP (由 setup.sh 动态生成)
238
+ buildServer.json
239
+
240
+ # -----------------------------------------------------------------------------
241
+ # Chrome Extension (Plasmo)
242
+ # -----------------------------------------------------------------------------
243
+
244
+ .plasmo/
245
+ apps/browser-extension/build/
246
+
247
+ # -----------------------------------------------------------------------------
248
+ # Documentation (Mintlify)
249
+ # -----------------------------------------------------------------------------
250
+ .mintlify/
251
+
252
+ # -----------------------------------------------------------------------------
253
+ # Docker Compose per-service env (generated by deploy/standalone/environments/generate.sh)
254
+ deploy/standalone/environments/*.generated.env
255
+
256
+ # LiteLLM (model config now in DB, no config files needed)
257
+
258
+ # K8s secrets (generated, never commit)
259
+ deploy/k8s/generated/secrets/
260
+ deploy/k8s/kubeconfig.yaml
261
+
262
+ # K8s configmaps (generated but git-tracked — do NOT add to gitignore)
263
+
264
+ # Archives
265
+ # -----------------------------------------------------------------------------
266
+ *.zip
267
+ *.sql.gz
268
+ *.sql.bz2
269
+ *.dump
270
+
271
+ # SSL certificates (never commit private keys)
272
+ deploy/standalone/ssl/
273
+
274
+ # -----------------------------------------------------------------------------
275
+ # External reference repos (cloned for API research, not part of Profy)
276
+ # -----------------------------------------------------------------------------
277
+ lark-openapi-mcp/
278
+ feishu-cli/
279
+ openclaw-lark/
@@ -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,137 @@
1
+ # profy-sdk
2
+
3
+ Official Python SDK for the [Profy](https://profy.cn) App platform — OAuth token management and event reporting.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install profy-sdk
9
+
10
+ # With optional SQLAlchemy token storage:
11
+ pip install profy-sdk[sqlalchemy]
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ from profy import ProfyApp
18
+
19
+ async with ProfyApp(
20
+ client_id="your-app-id",
21
+ client_secret="your-app-secret",
22
+ ) as profy:
23
+ # Exchange authorization code for tokens
24
+ token = await profy.exchange_code(code, "https://your-app.com/callback")
25
+
26
+ # Report a billing event
27
+ await profy.report_event("generate_report", token=token)
28
+ ```
29
+
30
+ ### Synchronous Usage
31
+
32
+ ```python
33
+ from profy import ProfyAppSync
34
+
35
+ with ProfyAppSync(
36
+ client_id="your-app-id",
37
+ client_secret="your-app-secret",
38
+ ) as profy:
39
+ token = profy.exchange_code(code, redirect_uri)
40
+ profy.report_event("generate_report", token=token)
41
+ ```
42
+
43
+ ## Features
44
+
45
+ - **OAuth2 Authorization Code Flow** — exchange codes, refresh tokens automatically
46
+ - **Events API** — report billing events with auto token refresh and retry on 401
47
+ - **Async + Sync** — `ProfyApp` (async) and `ProfyAppSync` (sync) clients
48
+ - **Type hints** — full type annotations, PEP 561 compatible
49
+ - **Minimal dependencies** — only `httpx`
50
+ - **Optional SQLAlchemy integration** — pre-built token model + store
51
+
52
+ ## Token Persistence (Optional)
53
+
54
+ ### Using SQLAlchemy
55
+
56
+ ```python
57
+ from profy import ProfyApp
58
+ from profy.contrib.sqlalchemy import create_token_model, SQLAlchemyTokenStore
59
+
60
+ # 1. Create the ORM model (auto-creates table with Base.metadata.create_all())
61
+ ProfyOAuthToken = create_token_model(Base)
62
+
63
+ # 2. Create store
64
+ store = SQLAlchemyTokenStore(async_session_factory, ProfyOAuthToken)
65
+
66
+ # 3. Initialize SDK with persistence
67
+ profy = ProfyApp(
68
+ client_id="...",
69
+ client_secret="...",
70
+ on_token_refresh=store.on_refresh,
71
+ )
72
+
73
+ # 4. Save after exchange
74
+ token = await profy.exchange_code(code, redirect_uri)
75
+ await store.save(token.user_id, token, scope="events:write")
76
+
77
+ # 5. Load later
78
+ saved = await store.load(user_id)
79
+ ```
80
+
81
+ ### Custom Storage
82
+
83
+ ```python
84
+ from profy import ProfyApp, TokenData
85
+
86
+ async def persist_token(token: TokenData):
87
+ await redis.set(f"profy:{token.user_id}", token.access_token)
88
+
89
+ profy = ProfyApp(
90
+ client_id="...",
91
+ client_secret="...",
92
+ on_token_refresh=persist_token,
93
+ )
94
+ ```
95
+
96
+ ## API Reference
97
+
98
+ ### `ProfyApp` / `ProfyAppSync`
99
+
100
+ | Method | Description |
101
+ |--------|-------------|
102
+ | `exchange_code(code, redirect_uri)` | Exchange authorization code for tokens |
103
+ | `refresh_token(refresh_token)` | Manually refresh an expired token |
104
+ | `report_event(event_name, *, token, idempotency_key?, metadata?)` | Report a billing event |
105
+
106
+ ### `TokenData`
107
+
108
+ ```python
109
+ @dataclass
110
+ class TokenData:
111
+ access_token: str
112
+ refresh_token: str
113
+ user_id: str
114
+ expires_at: float # unix timestamp
115
+
116
+ @property
117
+ def is_expired(self) -> bool: ...
118
+ ```
119
+
120
+ ### Exceptions
121
+
122
+ | Class | Status | Description |
123
+ |-------|--------|-------------|
124
+ | `AuthExpired` | 401 | Token expired and refresh failed |
125
+ | `InsufficientBalance` | 402 | User has insufficient credits |
126
+ | `InvalidEvent` | 400 | Event name not configured |
127
+ | `ProfyApiError` | varies | Generic API error |
128
+
129
+ ## Documentation
130
+
131
+ - [Integration Quickstart](https://profy.cn/zh/documentation/integration-quickstart)
132
+ - [SDK Guide](https://profy.cn/zh/documentation/sdk-guide)
133
+ - [API Reference](https://profy.cn/zh/api/post-token)
134
+
135
+ ## License
136
+
137
+ MIT
@@ -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
+ ]
@@ -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
+ )
@@ -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)
@@ -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)
File without changes
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "profy-sdk"
3
+ version = "0.1.0"
4
+ description = "Profy App SDK — OAuth token management and event reporting"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ keywords = ["profy", "oauth", "sdk", "events-api", "billing", "saas"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.9",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["httpx>=0.25.0"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://profy.cn"
26
+ Documentation = "https://profy.cn/zh/documentation/sdk-guide"
27
+ Repository = "https://github.com/profy-ai/profy"
28
+ Issues = "https://github.com/profy-ai/profy/issues"
29
+
30
+ [project.optional-dependencies]
31
+ sqlalchemy = ["sqlalchemy>=2.0"]
32
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.21"]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["profy"]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"