hatang 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.
- hatang-0.1.0/.gitignore +5 -0
- hatang-0.1.0/CHANGELOG.md +17 -0
- hatang-0.1.0/LICENSE +21 -0
- hatang-0.1.0/PKG-INFO +113 -0
- hatang-0.1.0/README.md +89 -0
- hatang-0.1.0/pyproject.toml +44 -0
- hatang-0.1.0/src/hatang/__init__.py +31 -0
- hatang-0.1.0/src/hatang/_client.py +150 -0
- hatang-0.1.0/src/hatang/_errors.py +24 -0
- hatang-0.1.0/src/hatang/_hatang.py +82 -0
- hatang-0.1.0/src/hatang/_resources.py +262 -0
- hatang-0.1.0/src/hatang/_version.py +1 -0
- hatang-0.1.0/src/hatang/helpers.py +22 -0
- hatang-0.1.0/src/hatang/py.typed +0 -0
- hatang-0.1.0/src/hatang/types.py +189 -0
- hatang-0.1.0/src/hatang/webhooks.py +83 -0
- hatang-0.1.0/tests/test_client.py +126 -0
- hatang-0.1.0/tests/test_helpers.py +71 -0
hatang-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the `hatang` Python package are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- First release: `Hatang` (sync) and `AsyncHatang` (asyncio) clients over httpx, with `HatangError` (`status_code`,
|
|
13
|
+
`name`, `message`, `retryable`).
|
|
14
|
+
- `chat.subscriptions`, `chat.purchases`, `chat.customers`, `chat.conversations`, `chat.products`, `chat.orders`,
|
|
15
|
+
`knowledge`, `emails`, `batch`, `payments`, `events.send`; typed request bodies in `hatang.types`.
|
|
16
|
+
- `identity_hash`, `purchase_token`, `verify_webhook` / `construct_webhook_event`, `verify_ipn` /
|
|
17
|
+
`construct_ipn_event`, checked against values from Ha Tang's servers and the Node SDK.
|
hatang-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Sota 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.
|
hatang-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hatang
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Ha Tang (hatang.dev): support CRM, plans and purchases, AI knowledge, email, payments, webhooks.
|
|
5
|
+
Project-URL: Homepage, https://hatang.dev
|
|
6
|
+
Project-URL: Documentation, https://hatang.dev/docs/guides/sdks/python
|
|
7
|
+
Project-URL: Source, https://github.com/sota-labs/hatang/tree/main/sdks/python
|
|
8
|
+
Project-URL: Changelog, https://github.com/sota-labs/hatang/blob/main/sdks/python/CHANGELOG.md
|
|
9
|
+
Author-email: Sota Labs <hello@hatang.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: chat,crm,email,hatang,in-app purchases,support,vietqr,webhooks
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Communications :: Email
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx<1,>=0.25
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# hatang
|
|
26
|
+
|
|
27
|
+
The official Python SDK for [Ha Tang](https://hatang.dev): support CRM, plans and purchases on the customer's profile,
|
|
28
|
+
the AI assistant's knowledge, email, VietQR payments and webhooks.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hatang
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Python 3.9+. Sync (`Hatang`) and asyncio (`AsyncHatang`) clients, typed request bodies.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from hatang import Hatang
|
|
40
|
+
|
|
41
|
+
hatang = Hatang() # reads HATANG_API_KEY; or Hatang("ht_…")
|
|
42
|
+
|
|
43
|
+
# What a customer pays for, from your backend (web sales included).
|
|
44
|
+
hatang.chat.subscriptions.upsert("web", "sub_123", {
|
|
45
|
+
"status": "active",
|
|
46
|
+
"sku": "PRO-1M", # finds the product in your catalog
|
|
47
|
+
"external_user_id": "user-42", # the id you pass to identify() in the chat
|
|
48
|
+
"current_period_end": "2026-12-01T00:00:00Z",
|
|
49
|
+
"amount": 99000, "currency": "VND",
|
|
50
|
+
})
|
|
51
|
+
hatang.chat.purchases.upsert("web", "order_981", {
|
|
52
|
+
"store_product_id": "credits.300", "sku": "XU-300", "kind": "consumable",
|
|
53
|
+
"external_user_id": "user-42", "amount": 29000, "currency": "VND",
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
asyncio (FastAPI, aiohttp…):
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from hatang import AsyncHatang
|
|
61
|
+
|
|
62
|
+
async with AsyncHatang() as hatang:
|
|
63
|
+
hits = await hatang.knowledge.search("hoàn tiền", limit=3)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Every method returns the API's JSON as a `dict`. A call that does not succeed raises `HatangError` with `status_code`
|
|
67
|
+
(0 when Ha Tang was not reached), `name` (`validation_error`, `not_found`…), `message`, and `retryable` (unreachable,
|
|
68
|
+
429 or 5xx).
|
|
69
|
+
|
|
70
|
+
## What is in it
|
|
71
|
+
|
|
72
|
+
| Namespace | Methods |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `chat.subscriptions` | `upsert`, `list`, `for_customer`, `purchase_token` |
|
|
75
|
+
| `chat.purchases` | `upsert`, `list`, `for_customer` |
|
|
76
|
+
| `chat.customers` | `list`, `get`, `update`, `conversations`, `export_csv` |
|
|
77
|
+
| `chat.conversations` | `list`, `get`, `update`, `messages`, `send`, `send_products` |
|
|
78
|
+
| `chat.products` | `list`, `search`, `get`, `create`, `update`, `remove` |
|
|
79
|
+
| `chat.orders` | `list`, `get`, `create`, `update`, `payment_link` |
|
|
80
|
+
| `knowledge` | `upsert`, `batch`, `sync`, `get`, `list`, `remove`, `search` |
|
|
81
|
+
| `emails`, `batch` | `send`, `get`, `list`, `update`, `cancel`; `batch.send` |
|
|
82
|
+
| `payments` | `create`, `get`, `list`, `cancel` |
|
|
83
|
+
| `events` | `send` |
|
|
84
|
+
|
|
85
|
+
## Helpers
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from hatang import identity_hash, purchase_token, construct_webhook_event, construct_ipn_event
|
|
89
|
+
|
|
90
|
+
# Signed chat identity: send this as `hash` with userId to identify() in the widget or the app SDKs.
|
|
91
|
+
identity_hash("user-42", IDENTITY_SECRET)
|
|
92
|
+
|
|
93
|
+
# StoreKit appAccountToken / Google Play obfuscatedAccountId for one of your users.
|
|
94
|
+
purchase_token("site_…", "user-42")
|
|
95
|
+
|
|
96
|
+
# Webhooks (whsec_… secret) and payment IPN calls: pass the raw body, before JSON parsing.
|
|
97
|
+
event = construct_webhook_event(request.body, request.headers, WEBHOOK_SECRET)
|
|
98
|
+
ipn = construct_ipn_event(request.body, request.headers, SECRET_KEY)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Both raise `HatangSignatureError` when the signature does not match; `verify_webhook` / `verify_ipn` return a bool.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
| Argument | Environment | Default |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `api_key` | `HATANG_API_KEY` | required |
|
|
108
|
+
| `base_url` | `HATANG_BASE_URL` | `https://api.hatang.dev` |
|
|
109
|
+
| `environment` | `HATANG_ENVIRONMENT` | the key's own |
|
|
110
|
+
| `timeout` | | 30 s |
|
|
111
|
+
| `http_client` | | an `httpx.Client` / `httpx.AsyncClient` of your own (proxies, retries, tests) |
|
|
112
|
+
|
|
113
|
+
Docs: [hatang.dev/docs](https://hatang.dev/docs/guides/sdks/python) · License: MIT
|
hatang-0.1.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# hatang
|
|
2
|
+
|
|
3
|
+
The official Python SDK for [Ha Tang](https://hatang.dev): support CRM, plans and purchases on the customer's profile,
|
|
4
|
+
the AI assistant's knowledge, email, VietQR payments and webhooks.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install hatang
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Python 3.9+. Sync (`Hatang`) and asyncio (`AsyncHatang`) clients, typed request bodies.
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from hatang import Hatang
|
|
16
|
+
|
|
17
|
+
hatang = Hatang() # reads HATANG_API_KEY; or Hatang("ht_…")
|
|
18
|
+
|
|
19
|
+
# What a customer pays for, from your backend (web sales included).
|
|
20
|
+
hatang.chat.subscriptions.upsert("web", "sub_123", {
|
|
21
|
+
"status": "active",
|
|
22
|
+
"sku": "PRO-1M", # finds the product in your catalog
|
|
23
|
+
"external_user_id": "user-42", # the id you pass to identify() in the chat
|
|
24
|
+
"current_period_end": "2026-12-01T00:00:00Z",
|
|
25
|
+
"amount": 99000, "currency": "VND",
|
|
26
|
+
})
|
|
27
|
+
hatang.chat.purchases.upsert("web", "order_981", {
|
|
28
|
+
"store_product_id": "credits.300", "sku": "XU-300", "kind": "consumable",
|
|
29
|
+
"external_user_id": "user-42", "amount": 29000, "currency": "VND",
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
asyncio (FastAPI, aiohttp…):
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from hatang import AsyncHatang
|
|
37
|
+
|
|
38
|
+
async with AsyncHatang() as hatang:
|
|
39
|
+
hits = await hatang.knowledge.search("hoàn tiền", limit=3)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every method returns the API's JSON as a `dict`. A call that does not succeed raises `HatangError` with `status_code`
|
|
43
|
+
(0 when Ha Tang was not reached), `name` (`validation_error`, `not_found`…), `message`, and `retryable` (unreachable,
|
|
44
|
+
429 or 5xx).
|
|
45
|
+
|
|
46
|
+
## What is in it
|
|
47
|
+
|
|
48
|
+
| Namespace | Methods |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `chat.subscriptions` | `upsert`, `list`, `for_customer`, `purchase_token` |
|
|
51
|
+
| `chat.purchases` | `upsert`, `list`, `for_customer` |
|
|
52
|
+
| `chat.customers` | `list`, `get`, `update`, `conversations`, `export_csv` |
|
|
53
|
+
| `chat.conversations` | `list`, `get`, `update`, `messages`, `send`, `send_products` |
|
|
54
|
+
| `chat.products` | `list`, `search`, `get`, `create`, `update`, `remove` |
|
|
55
|
+
| `chat.orders` | `list`, `get`, `create`, `update`, `payment_link` |
|
|
56
|
+
| `knowledge` | `upsert`, `batch`, `sync`, `get`, `list`, `remove`, `search` |
|
|
57
|
+
| `emails`, `batch` | `send`, `get`, `list`, `update`, `cancel`; `batch.send` |
|
|
58
|
+
| `payments` | `create`, `get`, `list`, `cancel` |
|
|
59
|
+
| `events` | `send` |
|
|
60
|
+
|
|
61
|
+
## Helpers
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from hatang import identity_hash, purchase_token, construct_webhook_event, construct_ipn_event
|
|
65
|
+
|
|
66
|
+
# Signed chat identity: send this as `hash` with userId to identify() in the widget or the app SDKs.
|
|
67
|
+
identity_hash("user-42", IDENTITY_SECRET)
|
|
68
|
+
|
|
69
|
+
# StoreKit appAccountToken / Google Play obfuscatedAccountId for one of your users.
|
|
70
|
+
purchase_token("site_…", "user-42")
|
|
71
|
+
|
|
72
|
+
# Webhooks (whsec_… secret) and payment IPN calls: pass the raw body, before JSON parsing.
|
|
73
|
+
event = construct_webhook_event(request.body, request.headers, WEBHOOK_SECRET)
|
|
74
|
+
ipn = construct_ipn_event(request.body, request.headers, SECRET_KEY)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Both raise `HatangSignatureError` when the signature does not match; `verify_webhook` / `verify_ipn` return a bool.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
| Argument | Environment | Default |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `api_key` | `HATANG_API_KEY` | required |
|
|
84
|
+
| `base_url` | `HATANG_BASE_URL` | `https://api.hatang.dev` |
|
|
85
|
+
| `environment` | `HATANG_ENVIRONMENT` | the key's own |
|
|
86
|
+
| `timeout` | | 30 s |
|
|
87
|
+
| `http_client` | | an `httpx.Client` / `httpx.AsyncClient` of your own (proxies, retries, tests) |
|
|
88
|
+
|
|
89
|
+
Docs: [hatang.dev/docs](https://hatang.dev/docs/guides/sdks/python) · License: MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hatang"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official Python SDK for Ha Tang (hatang.dev): support CRM, plans and purchases, AI knowledge, email, payments, webhooks."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
authors = [{ name = "Sota Labs", email = "hello@hatang.dev" }]
|
|
9
|
+
keywords = ["hatang", "support", "crm", "chat", "email", "vietqr", "webhooks", "in-app purchases"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
"Topic :: Communications :: Email",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
"Typing :: Typed",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://hatang.dev"
|
|
24
|
+
Documentation = "https://hatang.dev/docs/guides/sdks/python"
|
|
25
|
+
Source = "https://github.com/sota-labs/hatang/tree/main/sdks/python"
|
|
26
|
+
Changelog = "https://github.com/sota-labs/hatang/blob/main/sdks/python/CHANGELOG.md"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10"]
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling>=1.25"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/hatang"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
strict = true
|
|
44
|
+
files = ["src/hatang"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Official Python SDK for Ha Tang (hatang.dev).
|
|
2
|
+
|
|
3
|
+
from hatang import Hatang
|
|
4
|
+
|
|
5
|
+
hatang = Hatang() # HATANG_API_KEY
|
|
6
|
+
hatang.chat.subscriptions.upsert("web", "sub_123", {"status": "active", "external_user_id": "u1"})
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._client import DEFAULT_BASE_URL
|
|
10
|
+
from ._errors import HatangError, HatangSignatureError
|
|
11
|
+
from ._hatang import AsyncHatang, Hatang
|
|
12
|
+
from ._version import VERSION
|
|
13
|
+
from .helpers import identity_hash, purchase_token
|
|
14
|
+
from .webhooks import construct_ipn_event, construct_webhook_event, verify_ipn, verify_webhook
|
|
15
|
+
|
|
16
|
+
__version__ = VERSION
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"DEFAULT_BASE_URL",
|
|
20
|
+
"VERSION",
|
|
21
|
+
"AsyncHatang",
|
|
22
|
+
"Hatang",
|
|
23
|
+
"HatangError",
|
|
24
|
+
"HatangSignatureError",
|
|
25
|
+
"construct_ipn_event",
|
|
26
|
+
"construct_webhook_event",
|
|
27
|
+
"identity_hash",
|
|
28
|
+
"purchase_token",
|
|
29
|
+
"verify_ipn",
|
|
30
|
+
"verify_webhook",
|
|
31
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The HTTP layer shared by ``Hatang`` and ``AsyncHatang``: headers, query strings, errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._errors import HatangError
|
|
13
|
+
from ._version import VERSION
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.hatang.dev"
|
|
16
|
+
DEFAULT_TIMEOUT = 30.0
|
|
17
|
+
|
|
18
|
+
JSON = Dict[str, Any]
|
|
19
|
+
Query = Mapping[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def seg(value: str) -> str:
|
|
23
|
+
"""One path segment: ids may hold ``/`` (Play purchase tokens, your own order ids)."""
|
|
24
|
+
return quote(str(value), safe="")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def query_params(params: Optional[Query]) -> Dict[str, str]:
|
|
28
|
+
"""Query parameters as the API reads them: None dropped, booleans as true/false, lists comma-joined."""
|
|
29
|
+
out: Dict[str, str] = {}
|
|
30
|
+
for key, value in (params or {}).items():
|
|
31
|
+
if value is None:
|
|
32
|
+
continue
|
|
33
|
+
if isinstance(value, bool):
|
|
34
|
+
out[key] = "true" if value else "false"
|
|
35
|
+
elif isinstance(value, (list, tuple)):
|
|
36
|
+
out[key] = ",".join(str(v) for v in value)
|
|
37
|
+
else:
|
|
38
|
+
out[key] = str(value)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Config:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: Optional[str],
|
|
46
|
+
base_url: Optional[str],
|
|
47
|
+
environment: Optional[str],
|
|
48
|
+
user_agent: Optional[str],
|
|
49
|
+
) -> None:
|
|
50
|
+
key = api_key or os.environ.get("HATANG_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise ValueError('Missing API key. Pass it to the constructor: Hatang("ht_123"), or set HATANG_API_KEY')
|
|
53
|
+
self.api_key = key
|
|
54
|
+
self.base_url = (base_url or os.environ.get("HATANG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
55
|
+
self.environment = environment or os.environ.get("HATANG_ENVIRONMENT")
|
|
56
|
+
self.user_agent = user_agent or f"hatang-sdk-python/{VERSION} (python/{platform.python_version()})"
|
|
57
|
+
|
|
58
|
+
def headers(self, has_body: bool, extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
59
|
+
h = {"Authorization": f"Bearer {self.api_key}", "User-Agent": self.user_agent, "Accept": "application/json"}
|
|
60
|
+
if self.environment:
|
|
61
|
+
h["Hatang-Environment"] = self.environment
|
|
62
|
+
if has_body:
|
|
63
|
+
h["Content-Type"] = "application/json"
|
|
64
|
+
h.update(extra or {})
|
|
65
|
+
return h
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse(res: httpx.Response) -> Any:
|
|
69
|
+
try:
|
|
70
|
+
body: Any = res.json() if res.content else None
|
|
71
|
+
except ValueError:
|
|
72
|
+
body = None
|
|
73
|
+
if res.is_success:
|
|
74
|
+
return body if body is not None else {}
|
|
75
|
+
err: Mapping[str, Any] = body if isinstance(body, dict) else {}
|
|
76
|
+
raise HatangError(
|
|
77
|
+
int(err.get("statusCode") or res.status_code),
|
|
78
|
+
str(err.get("name") or "application_error"),
|
|
79
|
+
str(err.get("message") or res.reason_phrase or "Request failed"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unreachable(error: Exception) -> HatangError:
|
|
84
|
+
return HatangError(0, "application_error", f"Unable to reach the API: {error}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
RequestHeaders = Optional[Mapping[str, str]]
|
|
88
|
+
Body = Union[JSON, list, None] # type: ignore[type-arg]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SyncTransport:
|
|
92
|
+
def __init__(self, config: _Config, timeout: float, http_client: Optional[httpx.Client]) -> None:
|
|
93
|
+
self._config = config
|
|
94
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
95
|
+
|
|
96
|
+
def request(self, method: str, path: str, body: Body = None, query: Optional[Query] = None, headers: RequestHeaders = None) -> Any:
|
|
97
|
+
try:
|
|
98
|
+
res = self._http.request(
|
|
99
|
+
method,
|
|
100
|
+
f"{self._config.base_url}{path}",
|
|
101
|
+
params=query_params(query),
|
|
102
|
+
json=body,
|
|
103
|
+
headers=self._config.headers(body is not None, headers),
|
|
104
|
+
)
|
|
105
|
+
except httpx.HTTPError as error:
|
|
106
|
+
raise _unreachable(error) from error
|
|
107
|
+
return _parse(res)
|
|
108
|
+
|
|
109
|
+
def text(self, path: str, query: Optional[Query] = None) -> Any:
|
|
110
|
+
try:
|
|
111
|
+
res = self._http.get(f"{self._config.base_url}{path}", params=query_params(query), headers=self._config.headers(False, None))
|
|
112
|
+
except httpx.HTTPError as error:
|
|
113
|
+
raise _unreachable(error) from error
|
|
114
|
+
if not res.is_success:
|
|
115
|
+
_parse(res)
|
|
116
|
+
return res.text
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
self._http.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AsyncTransport:
|
|
123
|
+
def __init__(self, config: _Config, timeout: float, http_client: Optional[httpx.AsyncClient]) -> None:
|
|
124
|
+
self._config = config
|
|
125
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
126
|
+
|
|
127
|
+
async def request(self, method: str, path: str, body: Body = None, query: Optional[Query] = None, headers: RequestHeaders = None) -> Any:
|
|
128
|
+
try:
|
|
129
|
+
res = await self._http.request(
|
|
130
|
+
method,
|
|
131
|
+
f"{self._config.base_url}{path}",
|
|
132
|
+
params=query_params(query),
|
|
133
|
+
json=body,
|
|
134
|
+
headers=self._config.headers(body is not None, headers),
|
|
135
|
+
)
|
|
136
|
+
except httpx.HTTPError as error:
|
|
137
|
+
raise _unreachable(error) from error
|
|
138
|
+
return _parse(res)
|
|
139
|
+
|
|
140
|
+
async def text(self, path: str, query: Optional[Query] = None) -> Any:
|
|
141
|
+
try:
|
|
142
|
+
res = await self._http.get(f"{self._config.base_url}{path}", params=query_params(query), headers=self._config.headers(False, None))
|
|
143
|
+
except httpx.HTTPError as error:
|
|
144
|
+
raise _unreachable(error) from error
|
|
145
|
+
if not res.is_success:
|
|
146
|
+
_parse(res)
|
|
147
|
+
return res.text
|
|
148
|
+
|
|
149
|
+
async def aclose(self) -> None:
|
|
150
|
+
await self._http.aclose()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HatangError(Exception):
|
|
5
|
+
"""An API call that did not succeed.
|
|
6
|
+
|
|
7
|
+
``status_code`` is the HTTP status, or 0 when Ha Tang was not reached (network error, timeout).
|
|
8
|
+
``name`` is the API's error code (``validation_error``, ``not_found``, ``rate_limit_exceeded``…).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status_code: int, name: str, message: str) -> None:
|
|
12
|
+
super().__init__(f"{status_code} {name}: {message}")
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.name = name
|
|
15
|
+
self.message = message
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def retryable(self) -> bool:
|
|
19
|
+
"""True for outages worth retrying: unreachable, rate limited, or a server error."""
|
|
20
|
+
return self.status_code == 0 or self.status_code == 429 or self.status_code >= 500
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HatangSignatureError(Exception):
|
|
24
|
+
"""A webhook or IPN whose signature (or timestamp) does not match."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import TracebackType
|
|
4
|
+
from typing import Any, Awaitable, Optional, Type
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._client import DEFAULT_TIMEOUT, JSON, AsyncTransport, SyncTransport, _Config
|
|
9
|
+
from ._resources import Batch, Chat, Emails, Events, Knowledge, Payments
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Hatang:
|
|
13
|
+
"""The Ha Tang API.
|
|
14
|
+
|
|
15
|
+
``Hatang("ht_…")``, or set ``HATANG_API_KEY``. Every method returns the API's JSON as a dict and raises
|
|
16
|
+
``HatangError`` when the call does not succeed. Use it as a context manager, or call ``close()``, to release
|
|
17
|
+
its connections.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: Optional[str] = None,
|
|
23
|
+
*,
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
environment: Optional[str] = None,
|
|
26
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
27
|
+
http_client: Optional[httpx.Client] = None,
|
|
28
|
+
user_agent: Optional[str] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
config = _Config(api_key, base_url, environment, user_agent)
|
|
31
|
+
self.base_url = config.base_url
|
|
32
|
+
self._transport = SyncTransport(config, timeout, http_client)
|
|
33
|
+
request, text = self._transport.request, self._transport.text
|
|
34
|
+
self.emails: Emails[JSON, str] = Emails(request, text)
|
|
35
|
+
self.batch: Batch[JSON, str] = Batch(request, text)
|
|
36
|
+
self.knowledge: Knowledge[JSON, str] = Knowledge(request, text)
|
|
37
|
+
self.chat: Chat[JSON, str] = Chat(request, text)
|
|
38
|
+
self.payments: Payments[JSON, str] = Payments(request, text)
|
|
39
|
+
self.events: Events[JSON, str] = Events(request, text)
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
self._transport.close()
|
|
43
|
+
|
|
44
|
+
def __enter__(self) -> "Hatang":
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]) -> None:
|
|
48
|
+
self.close()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AsyncHatang:
|
|
52
|
+
"""The Ha Tang API for asyncio (FastAPI, aiohttp…): the same namespaces as ``Hatang``, every method awaited."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
base_url: Optional[str] = None,
|
|
59
|
+
environment: Optional[str] = None,
|
|
60
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
61
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
62
|
+
user_agent: Optional[str] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
config = _Config(api_key, base_url, environment, user_agent)
|
|
65
|
+
self.base_url = config.base_url
|
|
66
|
+
self._transport = AsyncTransport(config, timeout, http_client)
|
|
67
|
+
request, text = self._transport.request, self._transport.text
|
|
68
|
+
self.emails: Emails[Awaitable[JSON], Awaitable[str]] = Emails(request, text)
|
|
69
|
+
self.batch: Batch[Awaitable[JSON], Awaitable[str]] = Batch(request, text)
|
|
70
|
+
self.knowledge: Knowledge[Awaitable[JSON], Awaitable[str]] = Knowledge(request, text)
|
|
71
|
+
self.chat: Chat[Awaitable[JSON], Awaitable[str]] = Chat(request, text)
|
|
72
|
+
self.payments: Payments[Awaitable[JSON], Awaitable[str]] = Payments(request, text)
|
|
73
|
+
self.events: Events[Awaitable[JSON], Awaitable[str]] = Events(request, text)
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
await self._transport.aclose()
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> "AsyncHatang":
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
82
|
+
await self.aclose()
|