writehuman 0.0.1__tar.gz → 0.2.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.
- writehuman-0.2.0/PKG-INFO +126 -0
- writehuman-0.2.0/README.md +106 -0
- writehuman-0.2.0/pyproject.toml +33 -0
- writehuman-0.2.0/src/writehuman/__init__.py +51 -0
- writehuman-0.2.0/src/writehuman/_policy.py +83 -0
- writehuman-0.2.0/src/writehuman/client.py +244 -0
- writehuman-0.2.0/src/writehuman/errors.py +162 -0
- writehuman-0.2.0/src/writehuman/py.typed +0 -0
- writehuman-0.2.0/src/writehuman/types.py +173 -0
- writehuman-0.2.0/src/writehuman.egg-info/PKG-INFO +126 -0
- writehuman-0.2.0/src/writehuman.egg-info/SOURCES.txt +13 -0
- writehuman-0.2.0/src/writehuman.egg-info/requires.txt +1 -0
- writehuman-0.0.1/PKG-INFO +0 -19
- writehuman-0.0.1/README.md +0 -6
- writehuman-0.0.1/pyproject.toml +0 -20
- writehuman-0.0.1/src/writehuman/__init__.py +0 -3
- writehuman-0.0.1/src/writehuman.egg-info/PKG-INFO +0 -19
- writehuman-0.0.1/src/writehuman.egg-info/SOURCES.txt +0 -7
- {writehuman-0.0.1 → writehuman-0.2.0}/setup.cfg +0 -0
- {writehuman-0.0.1 → writehuman-0.2.0}/src/writehuman.egg-info/dependency_links.txt +0 -0
- {writehuman-0.0.1 → writehuman-0.2.0}/src/writehuman.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: writehuman
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://writehuman.ai/api/docs
|
|
7
|
+
Project-URL: Documentation, https://writehuman.ai/api/docs
|
|
8
|
+
Project-URL: Source, https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python
|
|
9
|
+
Project-URL: Changelog, https://github.com/WriteHuman-ai/rephrase-api/blob/main/sdk/python/CHANGELOG.md
|
|
10
|
+
Keywords: writehuman,humanize,ai-detection,api-client
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx<1,>=0.25
|
|
20
|
+
|
|
21
|
+
# writehuman
|
|
22
|
+
|
|
23
|
+
Official WriteHuman API client for Python. Humanize AI-written text, score text for AI
|
|
24
|
+
authorship, and read your usage, with typed errors and a retry policy that follows the
|
|
25
|
+
API's own `Retry-After` and never resends a billed request.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install writehuman
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from writehuman import WriteHuman, InsufficientBalanceError, RateLimitError
|
|
33
|
+
|
|
34
|
+
wh = WriteHuman("wh_live_your_api_key_here")
|
|
35
|
+
|
|
36
|
+
out = wh.humanize("Artificial intelligence has transformed numerous industries over the past decade.",
|
|
37
|
+
tone="professional")
|
|
38
|
+
print(out["results"][0])
|
|
39
|
+
print(out["words_remaining"]["total"], "words left")
|
|
40
|
+
|
|
41
|
+
score = wh.detect("Your text to check here.", include_sentences=True)
|
|
42
|
+
print(score["ai_probability"], score["predicted_class"])
|
|
43
|
+
|
|
44
|
+
print(wh.usage())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Premium plans and above can ask for the enhanced model. On a Standard plan this raises
|
|
48
|
+
`PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
|
|
49
|
+
switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
|
|
50
|
+
humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
out = wh.humanize(text, enhanced=True)
|
|
54
|
+
print(out["model"]) # "enhanced"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Async, same methods:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from writehuman import AsyncWriteHuman
|
|
61
|
+
|
|
62
|
+
async with AsyncWriteHuman("wh_live_...") as wh:
|
|
63
|
+
out = await wh.humanize("...")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
|
|
69
|
+
code), `status`, `retry_after` (seconds, when the API sent one) and, on
|
|
70
|
+
`InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
|
|
71
|
+
|
|
72
|
+
| exception | API `code` |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
|
|
75
|
+
| `NoSubscriptionError` | `no_subscription` |
|
|
76
|
+
| `InsufficientBalanceError` | `insufficient_balance` |
|
|
77
|
+
| `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
|
|
78
|
+
| `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
|
|
79
|
+
| `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
|
|
80
|
+
| `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
|
|
81
|
+
| `ModerationError` | `moderation_blocked` |
|
|
82
|
+
| `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
|
|
83
|
+
| `ServiceUnavailableError` | `at_capacity`, `billing_unavailable`, `detector_unavailable` |
|
|
84
|
+
| `ServerTimeoutError` | `timeout` (the API's 504: nothing charged) |
|
|
85
|
+
| `ProcessingError` | `processing_failed`, `no_results`, `safety_limit` |
|
|
86
|
+
| `BillingError` | `billing_failed` (some words may have been charged; check `usage()`) |
|
|
87
|
+
| `APIError` | any code this version does not know (`code` is preserved) |
|
|
88
|
+
| `ProtocolError`, `ClientTimeoutError`, `ConnectionError` | no API body |
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
try:
|
|
92
|
+
wh.humanize(text)
|
|
93
|
+
except InsufficientBalanceError as e:
|
|
94
|
+
print("out of words:", e.words_remaining)
|
|
95
|
+
except RateLimitError as e:
|
|
96
|
+
print("slow down; the API asked for", e.retry_after, "seconds")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Retries and timeouts
|
|
100
|
+
|
|
101
|
+
- `429`, `503` and `504` are retried after exactly the `Retry-After` the API sends, up to
|
|
102
|
+
`max_retries` (default 2). A `Retry-After` above `max_retry_after` (default 60 s, for
|
|
103
|
+
example an authentication lockout) is not slept through: the error is raised at once with
|
|
104
|
+
`retry_after` set. A missing `Retry-After` waits `fallback_retry_after` (default 2 s).
|
|
105
|
+
- Nothing else is retried. `humanize` and `detect` are billed and non-idempotent, so a client
|
|
106
|
+
timeout, a dropped connection, or a 429/503/504 that did not come from the API (a load
|
|
107
|
+
balancer or proxy answering without the API's error body) raises immediately rather than
|
|
108
|
+
risking a second charge. `usage` is read-only and may retry those.
|
|
109
|
+
- Timeouts are total deadlines through the whole response, not per-read limits (`AsyncWriteHuman`
|
|
110
|
+
enforces them exactly; `WriteHuman` checks after every chunk of the body, so it can overrun
|
|
111
|
+
by at most one network read). `timeout=` sets all operations (float) or per operation
|
|
112
|
+
(`{"humanize": 120, "detect": 30, "usage": 15}`, the defaults); `connect_timeout` defaults
|
|
113
|
+
to 10 s. Every method takes a per-call `timeout=`.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
wh = WriteHuman(key, timeout={"humanize": 60}, max_retries=3)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Types
|
|
120
|
+
|
|
121
|
+
`HumanizeResponse`, `DetectResponse`, `UsageResponse`, `WordsRemaining` and `ErrorCode`
|
|
122
|
+
are generated from the API's OpenAPI document and exported for type checkers. The package
|
|
123
|
+
ships `py.typed`.
|
|
124
|
+
|
|
125
|
+
This client passes the WriteHuman SDK conformance suite (`sdk/conformance` in the API
|
|
126
|
+
repository), as does the TypeScript client; both implement the same documented policy.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# writehuman
|
|
2
|
+
|
|
3
|
+
Official WriteHuman API client for Python. Humanize AI-written text, score text for AI
|
|
4
|
+
authorship, and read your usage, with typed errors and a retry policy that follows the
|
|
5
|
+
API's own `Retry-After` and never resends a billed request.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install writehuman
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from writehuman import WriteHuman, InsufficientBalanceError, RateLimitError
|
|
13
|
+
|
|
14
|
+
wh = WriteHuman("wh_live_your_api_key_here")
|
|
15
|
+
|
|
16
|
+
out = wh.humanize("Artificial intelligence has transformed numerous industries over the past decade.",
|
|
17
|
+
tone="professional")
|
|
18
|
+
print(out["results"][0])
|
|
19
|
+
print(out["words_remaining"]["total"], "words left")
|
|
20
|
+
|
|
21
|
+
score = wh.detect("Your text to check here.", include_sentences=True)
|
|
22
|
+
print(score["ai_probability"], score["predicted_class"])
|
|
23
|
+
|
|
24
|
+
print(wh.usage())
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Premium plans and above can ask for the enhanced model. On a Standard plan this raises
|
|
28
|
+
`PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
|
|
29
|
+
switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
|
|
30
|
+
humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
out = wh.humanize(text, enhanced=True)
|
|
34
|
+
print(out["model"]) # "enhanced"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Async, same methods:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from writehuman import AsyncWriteHuman
|
|
41
|
+
|
|
42
|
+
async with AsyncWriteHuman("wh_live_...") as wh:
|
|
43
|
+
out = await wh.humanize("...")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Errors
|
|
47
|
+
|
|
48
|
+
Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
|
|
49
|
+
code), `status`, `retry_after` (seconds, when the API sent one) and, on
|
|
50
|
+
`InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
|
|
51
|
+
|
|
52
|
+
| exception | API `code` |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
|
|
55
|
+
| `NoSubscriptionError` | `no_subscription` |
|
|
56
|
+
| `InsufficientBalanceError` | `insufficient_balance` |
|
|
57
|
+
| `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
|
|
58
|
+
| `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
|
|
59
|
+
| `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
|
|
60
|
+
| `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
|
|
61
|
+
| `ModerationError` | `moderation_blocked` |
|
|
62
|
+
| `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
|
|
63
|
+
| `ServiceUnavailableError` | `at_capacity`, `billing_unavailable`, `detector_unavailable` |
|
|
64
|
+
| `ServerTimeoutError` | `timeout` (the API's 504: nothing charged) |
|
|
65
|
+
| `ProcessingError` | `processing_failed`, `no_results`, `safety_limit` |
|
|
66
|
+
| `BillingError` | `billing_failed` (some words may have been charged; check `usage()`) |
|
|
67
|
+
| `APIError` | any code this version does not know (`code` is preserved) |
|
|
68
|
+
| `ProtocolError`, `ClientTimeoutError`, `ConnectionError` | no API body |
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
try:
|
|
72
|
+
wh.humanize(text)
|
|
73
|
+
except InsufficientBalanceError as e:
|
|
74
|
+
print("out of words:", e.words_remaining)
|
|
75
|
+
except RateLimitError as e:
|
|
76
|
+
print("slow down; the API asked for", e.retry_after, "seconds")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Retries and timeouts
|
|
80
|
+
|
|
81
|
+
- `429`, `503` and `504` are retried after exactly the `Retry-After` the API sends, up to
|
|
82
|
+
`max_retries` (default 2). A `Retry-After` above `max_retry_after` (default 60 s, for
|
|
83
|
+
example an authentication lockout) is not slept through: the error is raised at once with
|
|
84
|
+
`retry_after` set. A missing `Retry-After` waits `fallback_retry_after` (default 2 s).
|
|
85
|
+
- Nothing else is retried. `humanize` and `detect` are billed and non-idempotent, so a client
|
|
86
|
+
timeout, a dropped connection, or a 429/503/504 that did not come from the API (a load
|
|
87
|
+
balancer or proxy answering without the API's error body) raises immediately rather than
|
|
88
|
+
risking a second charge. `usage` is read-only and may retry those.
|
|
89
|
+
- Timeouts are total deadlines through the whole response, not per-read limits (`AsyncWriteHuman`
|
|
90
|
+
enforces them exactly; `WriteHuman` checks after every chunk of the body, so it can overrun
|
|
91
|
+
by at most one network read). `timeout=` sets all operations (float) or per operation
|
|
92
|
+
(`{"humanize": 120, "detect": 30, "usage": 15}`, the defaults); `connect_timeout` defaults
|
|
93
|
+
to 10 s. Every method takes a per-call `timeout=`.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
wh = WriteHuman(key, timeout={"humanize": 60}, max_retries=3)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Types
|
|
100
|
+
|
|
101
|
+
`HumanizeResponse`, `DetectResponse`, `UsageResponse`, `WordsRemaining` and `ErrorCode`
|
|
102
|
+
are generated from the API's OpenAPI document and exported for type checkers. The package
|
|
103
|
+
ships `py.typed`.
|
|
104
|
+
|
|
105
|
+
This client passes the WriteHuman SDK conformance suite (`sdk/conformance` in the API
|
|
106
|
+
repository), as does the TypeScript client; both implement the same documented policy.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "writehuman"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
13
|
+
keywords = ["writehuman", "humanize", "ai-detection", "api-client"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://writehuman.ai/api/docs"
|
|
25
|
+
Documentation = "https://writehuman.ai/api/docs"
|
|
26
|
+
Source = "https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python"
|
|
27
|
+
Changelog = "https://github.com/WriteHuman-ai/rephrase-api/blob/main/sdk/python/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
writehuman = ["py.typed"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Official WriteHuman API client for Python.
|
|
2
|
+
|
|
3
|
+
from writehuman import WriteHuman
|
|
4
|
+
wh = WriteHuman("wh_live_...")
|
|
5
|
+
out = wh.humanize("Artificial intelligence has transformed numerous industries ...")
|
|
6
|
+
print(out["results"][0])
|
|
7
|
+
|
|
8
|
+
Docs: https://writehuman.ai/api/docs. Contract: https://api.writehuman.ai/openapi.json.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
from .client import DEFAULT_BASE_URL, USER_AGENT, AsyncWriteHuman, WriteHuman # noqa: E402
|
|
14
|
+
from .errors import ( # noqa: E402
|
|
15
|
+
APIError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
BillingError,
|
|
18
|
+
ClientTimeoutError,
|
|
19
|
+
ConnectionError,
|
|
20
|
+
FeatureUnavailableError,
|
|
21
|
+
InsufficientBalanceError,
|
|
22
|
+
InvalidRequestError,
|
|
23
|
+
ModerationError,
|
|
24
|
+
NoSubscriptionError,
|
|
25
|
+
PlanRequiredError,
|
|
26
|
+
ProcessingError,
|
|
27
|
+
ProtocolError,
|
|
28
|
+
RateLimitError,
|
|
29
|
+
ReverifyRequiredError,
|
|
30
|
+
ServerTimeoutError,
|
|
31
|
+
ServiceUnavailableError,
|
|
32
|
+
WriteHumanError,
|
|
33
|
+
)
|
|
34
|
+
from .types import ( # noqa: E402
|
|
35
|
+
API_VERSION,
|
|
36
|
+
DetectResponse,
|
|
37
|
+
ErrorCode,
|
|
38
|
+
HumanizeResponse,
|
|
39
|
+
UsageResponse,
|
|
40
|
+
WordsRemaining,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__", "API_VERSION", "DEFAULT_BASE_URL", "USER_AGENT",
|
|
45
|
+
"WriteHuman", "AsyncWriteHuman",
|
|
46
|
+
"WriteHumanError", "APIError", "AuthenticationError", "NoSubscriptionError", "InsufficientBalanceError",
|
|
47
|
+
"ReverifyRequiredError", "PlanRequiredError", "FeatureUnavailableError",
|
|
48
|
+
"InvalidRequestError", "ModerationError", "RateLimitError", "ServiceUnavailableError", "ServerTimeoutError",
|
|
49
|
+
"ProcessingError", "BillingError", "ProtocolError", "ClientTimeoutError", "ConnectionError",
|
|
50
|
+
"HumanizeResponse", "DetectResponse", "UsageResponse", "WordsRemaining", "ErrorCode",
|
|
51
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The request/retry policy shared by the sync and async clients. Pure functions, no I/O,
|
|
2
|
+
so both clients are thin loops around it and the behaviour is identical. The policy is the
|
|
3
|
+
one sdk/conformance/README.md specifies and the conformance scenarios enforce."""
|
|
4
|
+
from typing import Any, Dict, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from .errors import WriteHumanError, error_from_body
|
|
7
|
+
|
|
8
|
+
RETRYABLE_STATUSES = frozenset({429, 503, 504})
|
|
9
|
+
|
|
10
|
+
# Total timeouts per operation, in seconds: the 30-day p99 of successful calls with room,
|
|
11
|
+
# under the API's own 170 s humanize deadline.
|
|
12
|
+
DEFAULT_TIMEOUTS: Dict[str, float] = {"humanize": 120.0, "detect": 30.0, "usage": 15.0}
|
|
13
|
+
DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
DEFAULT_MAX_RETRY_AFTER = 60.0
|
|
16
|
+
DEFAULT_FALLBACK_RETRY_AFTER = 2.0
|
|
17
|
+
|
|
18
|
+
OPERATIONS = {
|
|
19
|
+
# name: (method, path, idempotent)
|
|
20
|
+
"humanize": ("POST", "/v1/humanize", False),
|
|
21
|
+
"detect": ("POST", "/v1/detect", False),
|
|
22
|
+
"usage": ("GET", "/v1/usage", True),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
27
|
+
if value is None:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return max(0.0, float(value))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Policy:
|
|
36
|
+
def __init__(self, *, max_retries: int = DEFAULT_MAX_RETRIES, max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
|
|
37
|
+
fallback_retry_after: float = DEFAULT_FALLBACK_RETRY_AFTER):
|
|
38
|
+
self.max_retries = max(0, int(max_retries))
|
|
39
|
+
self.max_retry_after = float(max_retry_after)
|
|
40
|
+
self.fallback_retry_after = float(fallback_retry_after)
|
|
41
|
+
|
|
42
|
+
def wait_after_response(self, status: int, retry_after: Optional[float], attempt: int, *,
|
|
43
|
+
from_api: bool = True, idempotent: bool = False) -> Optional[float]:
|
|
44
|
+
"""Seconds to sleep before retrying a non-2xx response, or None to raise now.
|
|
45
|
+
Only 429/503/504 are retried, and only when the API itself answered (its error body):
|
|
46
|
+
the contract guarantees those left nothing charged. The same status from a load
|
|
47
|
+
balancer or proxy carries no such guarantee (a balancer 504 can follow a request the
|
|
48
|
+
server went on to finish and bill), so it is treated like a transport error."""
|
|
49
|
+
if status not in RETRYABLE_STATUSES:
|
|
50
|
+
return None
|
|
51
|
+
if not from_api:
|
|
52
|
+
return self.wait_after_transport_error(idempotent, attempt)
|
|
53
|
+
if attempt >= self.max_retries:
|
|
54
|
+
return None
|
|
55
|
+
if retry_after is None:
|
|
56
|
+
return self.fallback_retry_after
|
|
57
|
+
if retry_after > self.max_retry_after:
|
|
58
|
+
return None # a 15-minute lockout is for the caller to decide about, not to sleep through
|
|
59
|
+
return retry_after
|
|
60
|
+
|
|
61
|
+
def wait_after_transport_error(self, idempotent: bool, attempt: int) -> Optional[float]:
|
|
62
|
+
"""A timeout or dropped connection: retried only for the read-only usage call. A
|
|
63
|
+
billed POST may have been processed, so it is never resent automatically."""
|
|
64
|
+
if not idempotent or attempt >= self.max_retries:
|
|
65
|
+
return None
|
|
66
|
+
return self.fallback_retry_after
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def interpret(status: int, headers: Any, body_text: str) -> Tuple[Optional[dict], Optional[WriteHumanError], Optional[float]]:
|
|
70
|
+
"""Turn a response into (data, None, retry_after) on success or (None, error, retry_after)."""
|
|
71
|
+
import json
|
|
72
|
+
|
|
73
|
+
retry_after = parse_retry_after(headers.get("Retry-After") if hasattr(headers, "get") else None)
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(body_text) if body_text else None
|
|
76
|
+
except ValueError:
|
|
77
|
+
data = None
|
|
78
|
+
if 200 <= status < 300:
|
|
79
|
+
if isinstance(data, dict):
|
|
80
|
+
return data, None, retry_after
|
|
81
|
+
from .errors import ProtocolError
|
|
82
|
+
return None, ProtocolError(f"HTTP {status} with a body that is not JSON", status=status), retry_after
|
|
83
|
+
return None, error_from_body(status, data, retry_after), retry_after
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Sync and async clients for the WriteHuman API.
|
|
2
|
+
|
|
3
|
+
from writehuman import WriteHuman
|
|
4
|
+
wh = WriteHuman("wh_live_...")
|
|
5
|
+
out = wh.humanize("Artificial intelligence has transformed ...", tone="professional")
|
|
6
|
+
print(out["results"][0], out["words_remaining"]["total"])
|
|
7
|
+
out = wh.humanize("...", enhanced=True) # Premium plans: the enhanced model
|
|
8
|
+
|
|
9
|
+
from writehuman import AsyncWriteHuman
|
|
10
|
+
async with AsyncWriteHuman("wh_live_...") as wh:
|
|
11
|
+
score = await wh.detect("...", include_sentences=True)
|
|
12
|
+
|
|
13
|
+
Both follow the policy in sdk/conformance/README.md and pass the same conformance suite.
|
|
14
|
+
"""
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from ._policy import (
|
|
23
|
+
DEFAULT_CONNECT_TIMEOUT,
|
|
24
|
+
DEFAULT_FALLBACK_RETRY_AFTER,
|
|
25
|
+
DEFAULT_MAX_RETRIES,
|
|
26
|
+
DEFAULT_MAX_RETRY_AFTER,
|
|
27
|
+
DEFAULT_TIMEOUTS,
|
|
28
|
+
OPERATIONS,
|
|
29
|
+
Policy,
|
|
30
|
+
interpret,
|
|
31
|
+
)
|
|
32
|
+
from .errors import ClientTimeoutError, ConnectionError, ProtocolError, WriteHumanError
|
|
33
|
+
from .types import DetectResponse, HumanizeResponse, UsageResponse
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://api.writehuman.ai"
|
|
36
|
+
USER_AGENT = f"writehuman-python/{__version__}"
|
|
37
|
+
|
|
38
|
+
Timeouts = Union[float, Dict[str, float], None]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _timeouts(timeout: Timeouts) -> Dict[str, float]:
|
|
42
|
+
if timeout is None:
|
|
43
|
+
return dict(DEFAULT_TIMEOUTS)
|
|
44
|
+
if isinstance(timeout, (int, float)):
|
|
45
|
+
return {k: float(timeout) for k in DEFAULT_TIMEOUTS}
|
|
46
|
+
return {**DEFAULT_TIMEOUTS, **{k: float(v) for k, v in timeout.items()}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Base:
|
|
50
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: Timeouts = None,
|
|
51
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, max_retries: int = DEFAULT_MAX_RETRIES,
|
|
52
|
+
max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
|
|
53
|
+
fallback_retry_after: float = DEFAULT_FALLBACK_RETRY_AFTER):
|
|
54
|
+
if not api_key or not isinstance(api_key, str):
|
|
55
|
+
raise ValueError("api_key is required")
|
|
56
|
+
self.api_key = api_key
|
|
57
|
+
self.base_url = base_url.rstrip("/")
|
|
58
|
+
self.timeouts = _timeouts(timeout)
|
|
59
|
+
self.connect_timeout = float(connect_timeout)
|
|
60
|
+
self.policy = Policy(max_retries=max_retries, max_retry_after=max_retry_after,
|
|
61
|
+
fallback_retry_after=fallback_retry_after)
|
|
62
|
+
|
|
63
|
+
def _headers(self) -> Dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Bearer {self.api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
65
|
+
|
|
66
|
+
def _total(self, operation: str, override: Optional[float]) -> float:
|
|
67
|
+
return float(override) if override is not None else self.timeouts[operation]
|
|
68
|
+
|
|
69
|
+
def _httpx_timeout(self, operation: str, override: Optional[float]) -> httpx.Timeout:
|
|
70
|
+
total = self._total(operation, override)
|
|
71
|
+
return httpx.Timeout(total, connect=min(self.connect_timeout, total))
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _humanize_body(text: str, tone: Optional[str], language: Optional[str], enhanced: bool) -> Dict[str, Any]:
|
|
75
|
+
body: Dict[str, Any] = {"text": text}
|
|
76
|
+
if tone is not None:
|
|
77
|
+
body["tone"] = tone
|
|
78
|
+
if language is not None:
|
|
79
|
+
body["language"] = language
|
|
80
|
+
if enhanced:
|
|
81
|
+
# only when asked for, so a default request's body is unchanged
|
|
82
|
+
body["enhanced"] = True
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _detect_body(text: str, language: Optional[str], include_sentences: bool) -> Dict[str, Any]:
|
|
87
|
+
body: Dict[str, Any] = {"text": text, "include_sentences": bool(include_sentences)}
|
|
88
|
+
if language is not None:
|
|
89
|
+
body["language"] = language
|
|
90
|
+
return body
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class WriteHuman(_Base):
|
|
94
|
+
"""Synchronous client. Thread-safe for concurrent calls (httpx.Client is)."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, api_key: str, *, http_client: Optional[httpx.Client] = None, **options: Any):
|
|
97
|
+
super().__init__(api_key, **options)
|
|
98
|
+
self._client = http_client or httpx.Client()
|
|
99
|
+
self._owns_client = http_client is None
|
|
100
|
+
|
|
101
|
+
def close(self) -> None:
|
|
102
|
+
if self._owns_client:
|
|
103
|
+
self._client.close()
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> "WriteHuman":
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *exc: Any) -> None:
|
|
109
|
+
self.close()
|
|
110
|
+
|
|
111
|
+
def humanize(self, text: str, *, tone: Optional[str] = None, language: Optional[str] = None,
|
|
112
|
+
enhanced: bool = False, timeout: Optional[float] = None) -> HumanizeResponse:
|
|
113
|
+
"""`enhanced=True` serves the request with the enhanced model (Premium plans and above;
|
|
114
|
+
the response's `model` says which model served it). On a Standard plan it raises
|
|
115
|
+
PlanRequiredError with `upgrade_url`; while the enhanced model is switched off it raises
|
|
116
|
+
FeatureUnavailableError. Nothing is charged for either."""
|
|
117
|
+
return self._call("humanize", self._humanize_body(text, tone, language, enhanced), timeout) # type: ignore[return-value]
|
|
118
|
+
|
|
119
|
+
def detect(self, text: str, *, language: Optional[str] = None, include_sentences: bool = False,
|
|
120
|
+
timeout: Optional[float] = None) -> DetectResponse:
|
|
121
|
+
return self._call("detect", self._detect_body(text, language, include_sentences), timeout) # type: ignore[return-value]
|
|
122
|
+
|
|
123
|
+
def usage(self, *, timeout: Optional[float] = None) -> UsageResponse:
|
|
124
|
+
return self._call("usage", None, timeout) # type: ignore[return-value]
|
|
125
|
+
|
|
126
|
+
def _call(self, operation: str, body: Optional[dict], timeout: Optional[float]) -> dict:
|
|
127
|
+
method, path, idempotent = OPERATIONS[operation]
|
|
128
|
+
url = self.base_url + path
|
|
129
|
+
attempt = 0
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
status, headers, text = self._send(method, url, body, operation, timeout)
|
|
133
|
+
except httpx.TimeoutException as exc:
|
|
134
|
+
wait = self.policy.wait_after_transport_error(idempotent, attempt)
|
|
135
|
+
if wait is None:
|
|
136
|
+
raise ClientTimeoutError(f"{operation}: no response within the client timeout") from exc
|
|
137
|
+
attempt += 1
|
|
138
|
+
time.sleep(wait)
|
|
139
|
+
continue
|
|
140
|
+
except httpx.TransportError as exc:
|
|
141
|
+
wait = self.policy.wait_after_transport_error(idempotent, attempt)
|
|
142
|
+
if wait is None:
|
|
143
|
+
raise ConnectionError(f"{operation}: could not reach {self.base_url}: {exc}") from exc
|
|
144
|
+
attempt += 1
|
|
145
|
+
time.sleep(wait)
|
|
146
|
+
continue
|
|
147
|
+
data, err, retry_after = interpret(status, headers, text)
|
|
148
|
+
if err is None:
|
|
149
|
+
return data # type: ignore[return-value]
|
|
150
|
+
wait = self.policy.wait_after_response(status, retry_after, attempt,
|
|
151
|
+
from_api=not isinstance(err, ProtocolError),
|
|
152
|
+
idempotent=idempotent)
|
|
153
|
+
if wait is None:
|
|
154
|
+
raise err
|
|
155
|
+
attempt += 1
|
|
156
|
+
time.sleep(wait)
|
|
157
|
+
|
|
158
|
+
def _send(self, method: str, url: str, body: Optional[dict], operation: str,
|
|
159
|
+
timeout: Optional[float]) -> Tuple[int, Any, str]:
|
|
160
|
+
"""One attempt. The timeout is a total deadline through the whole body: httpx's own
|
|
161
|
+
timeouts bound each read, so the body is streamed and the elapsed time is checked
|
|
162
|
+
after every chunk; a response can overrun the deadline by at most one network read."""
|
|
163
|
+
deadline = time.monotonic() + self._total(operation, timeout)
|
|
164
|
+
with self._client.stream(method, url, headers=self._headers(), json=body,
|
|
165
|
+
timeout=self._httpx_timeout(operation, timeout)) as resp:
|
|
166
|
+
chunks = []
|
|
167
|
+
for chunk in resp.iter_bytes():
|
|
168
|
+
chunks.append(chunk)
|
|
169
|
+
if time.monotonic() > deadline:
|
|
170
|
+
raise httpx.ReadTimeout("total client timeout exceeded while reading the response")
|
|
171
|
+
return resp.status_code, resp.headers, b"".join(chunks).decode(resp.encoding or "utf-8", "replace")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class AsyncWriteHuman(_Base):
|
|
175
|
+
"""Asynchronous client, same policy and same results as WriteHuman."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, api_key: str, *, http_client: Optional[httpx.AsyncClient] = None, **options: Any):
|
|
178
|
+
super().__init__(api_key, **options)
|
|
179
|
+
self._client = http_client or httpx.AsyncClient()
|
|
180
|
+
self._owns_client = http_client is None
|
|
181
|
+
|
|
182
|
+
async def aclose(self) -> None:
|
|
183
|
+
if self._owns_client:
|
|
184
|
+
await self._client.aclose()
|
|
185
|
+
|
|
186
|
+
async def __aenter__(self) -> "AsyncWriteHuman":
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
190
|
+
await self.aclose()
|
|
191
|
+
|
|
192
|
+
async def humanize(self, text: str, *, tone: Optional[str] = None, language: Optional[str] = None,
|
|
193
|
+
enhanced: bool = False, timeout: Optional[float] = None) -> HumanizeResponse:
|
|
194
|
+
"""`enhanced=True` serves the request with the enhanced model (Premium plans and above;
|
|
195
|
+
the response's `model` says which model served it). On a Standard plan it raises
|
|
196
|
+
PlanRequiredError with `upgrade_url`; while the enhanced model is switched off it raises
|
|
197
|
+
FeatureUnavailableError. Nothing is charged for either."""
|
|
198
|
+
return await self._call("humanize", self._humanize_body(text, tone, language, enhanced), timeout) # type: ignore[return-value]
|
|
199
|
+
|
|
200
|
+
async def detect(self, text: str, *, language: Optional[str] = None, include_sentences: bool = False,
|
|
201
|
+
timeout: Optional[float] = None) -> DetectResponse:
|
|
202
|
+
return await self._call("detect", self._detect_body(text, language, include_sentences), timeout) # type: ignore[return-value]
|
|
203
|
+
|
|
204
|
+
async def usage(self, *, timeout: Optional[float] = None) -> UsageResponse:
|
|
205
|
+
return await self._call("usage", None, timeout) # type: ignore[return-value]
|
|
206
|
+
|
|
207
|
+
async def _call(self, operation: str, body: Optional[dict], timeout: Optional[float]) -> dict:
|
|
208
|
+
method, path, idempotent = OPERATIONS[operation]
|
|
209
|
+
url = self.base_url + path
|
|
210
|
+
attempt = 0
|
|
211
|
+
while True:
|
|
212
|
+
try:
|
|
213
|
+
# total deadline through the whole body (httpx's own timeouts bound each read)
|
|
214
|
+
resp = await asyncio.wait_for(
|
|
215
|
+
self._client.request(method, url, headers=self._headers(), json=body,
|
|
216
|
+
timeout=self._httpx_timeout(operation, timeout)),
|
|
217
|
+
timeout=self._total(operation, timeout))
|
|
218
|
+
except (httpx.TimeoutException, asyncio.TimeoutError) as exc:
|
|
219
|
+
wait = self.policy.wait_after_transport_error(idempotent, attempt)
|
|
220
|
+
if wait is None:
|
|
221
|
+
raise ClientTimeoutError(f"{operation}: no response within the client timeout") from exc
|
|
222
|
+
attempt += 1
|
|
223
|
+
await asyncio.sleep(wait)
|
|
224
|
+
continue
|
|
225
|
+
except httpx.TransportError as exc:
|
|
226
|
+
wait = self.policy.wait_after_transport_error(idempotent, attempt)
|
|
227
|
+
if wait is None:
|
|
228
|
+
raise ConnectionError(f"{operation}: could not reach {self.base_url}: {exc}") from exc
|
|
229
|
+
attempt += 1
|
|
230
|
+
await asyncio.sleep(wait)
|
|
231
|
+
continue
|
|
232
|
+
data, err, retry_after = interpret(resp.status_code, resp.headers, resp.text)
|
|
233
|
+
if err is None:
|
|
234
|
+
return data # type: ignore[return-value]
|
|
235
|
+
wait = self.policy.wait_after_response(resp.status_code, retry_after, attempt,
|
|
236
|
+
from_api=not isinstance(err, ProtocolError),
|
|
237
|
+
idempotent=idempotent)
|
|
238
|
+
if wait is None:
|
|
239
|
+
raise err
|
|
240
|
+
attempt += 1
|
|
241
|
+
await asyncio.sleep(wait)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
__all__ = ["WriteHuman", "AsyncWriteHuman", "WriteHumanError", "DEFAULT_BASE_URL", "USER_AGENT"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Typed errors. Every error carries the attributes the conformance suite reads:
|
|
2
|
+
|
|
3
|
+
kind abstract type (see sdk/conformance/README.md); one class per kind
|
|
4
|
+
code the API's `code`, or None when there was no API body
|
|
5
|
+
status HTTP status, or None for client-side failures
|
|
6
|
+
retry_after seconds the API asked us to wait (from the last response), or None
|
|
7
|
+
words_remaining on InsufficientBalanceError: {"included", "topup", "total"}
|
|
8
|
+
upgrade_url on PlanRequiredError: where the account owner upgrades the plan
|
|
9
|
+
"""
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .types import ErrorCode, WordsRemaining
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WriteHumanError(Exception):
|
|
16
|
+
"""Base of every error this library raises."""
|
|
17
|
+
|
|
18
|
+
kind = "api"
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None,
|
|
21
|
+
retry_after: Optional[float] = None, words_remaining: Optional[WordsRemaining] = None,
|
|
22
|
+
upgrade_url: Optional[str] = None):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.code = code
|
|
27
|
+
self.retry_after = retry_after
|
|
28
|
+
self.words_remaining = words_remaining
|
|
29
|
+
self.upgrade_url = upgrade_url
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"{type(self).__name__}({self.message!r}, status={self.status}, code={self.code!r})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class APIError(WriteHumanError):
|
|
36
|
+
"""A well-formed error whose `code` this version of the library does not know."""
|
|
37
|
+
kind = "api"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(WriteHumanError):
|
|
41
|
+
"""missing_api_key, invalid_api_key."""
|
|
42
|
+
kind = "authentication"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoSubscriptionError(WriteHumanError):
|
|
46
|
+
"""The key is valid but the account has no active API subscription."""
|
|
47
|
+
kind = "no_subscription"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class InsufficientBalanceError(WriteHumanError):
|
|
51
|
+
"""Out of words. `words_remaining` says what is left."""
|
|
52
|
+
kind = "insufficient_balance"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ReverifyRequiredError(WriteHumanError):
|
|
56
|
+
"""reverify_required (403): the account must be re-verified by email. The account owner
|
|
57
|
+
signs in at writehuman.ai to continue. Never retried."""
|
|
58
|
+
kind = "reverify_required"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PlanRequiredError(WriteHumanError):
|
|
62
|
+
"""enhanced_requires_premium (403): the request asked for a feature the plan does not
|
|
63
|
+
include (`enhanced=True` on a Standard plan). `upgrade_url` is where to upgrade; or send
|
|
64
|
+
the request without the feature. Nothing is charged. Never retried."""
|
|
65
|
+
kind = "plan_required"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FeatureUnavailableError(WriteHumanError):
|
|
69
|
+
"""enhanced_unavailable (403): the feature the request asked for is switched off on the
|
|
70
|
+
API for every account right now (`enhanced=True` while the enhanced model is off). Send
|
|
71
|
+
the request without it. Upgrading does not help. Nothing is charged. Never retried."""
|
|
72
|
+
kind = "feature_unavailable"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InvalidRequestError(WriteHumanError):
|
|
76
|
+
"""invalid_request, forbidden_param, word_limit."""
|
|
77
|
+
kind = "invalid_request"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ModerationError(WriteHumanError):
|
|
81
|
+
"""Content moderation refused the input."""
|
|
82
|
+
kind = "moderation"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RateLimitError(WriteHumanError):
|
|
86
|
+
"""rate_limited, auth_locked_out, queue_full, queue_timeout. Retried per Retry-After."""
|
|
87
|
+
kind = "rate_limit"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ServiceUnavailableError(WriteHumanError):
|
|
91
|
+
"""at_capacity, billing_unavailable, detector_unavailable. Retried per Retry-After."""
|
|
92
|
+
kind = "service_unavailable"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ServerTimeoutError(WriteHumanError):
|
|
96
|
+
"""The API's own deadline (504 `timeout`): nothing charged, retried per Retry-After."""
|
|
97
|
+
kind = "server_timeout"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ProcessingError(WriteHumanError):
|
|
101
|
+
"""processing_failed, no_results, safety_limit. Never retried."""
|
|
102
|
+
kind = "processing"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BillingError(WriteHumanError):
|
|
106
|
+
"""billing_failed: the result was produced but the usage write failed part-way; some
|
|
107
|
+
words may have been charged. Never retried; check usage()."""
|
|
108
|
+
kind = "billing"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ProtocolError(WriteHumanError):
|
|
112
|
+
"""The response was not the JSON the contract promises."""
|
|
113
|
+
kind = "protocol"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ClientTimeoutError(WriteHumanError):
|
|
117
|
+
"""No response within the client timeout. Not retried on humanize/detect (billed)."""
|
|
118
|
+
kind = "client_timeout"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ConnectionError(WriteHumanError): # noqa: A001 — mirrors the TypeScript name on purpose
|
|
122
|
+
"""The connection failed. Not retried on humanize/detect (billed)."""
|
|
123
|
+
kind = "connection"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
KIND_BY_CODE = {
|
|
127
|
+
"missing_api_key": "authentication", "invalid_api_key": "authentication",
|
|
128
|
+
"no_subscription": "no_subscription",
|
|
129
|
+
"insufficient_balance": "insufficient_balance",
|
|
130
|
+
"reverify_required": "reverify_required",
|
|
131
|
+
"enhanced_requires_premium": "plan_required",
|
|
132
|
+
"enhanced_unavailable": "feature_unavailable",
|
|
133
|
+
"invalid_request": "invalid_request", "forbidden_param": "invalid_request", "word_limit": "invalid_request",
|
|
134
|
+
"moderation_blocked": "moderation",
|
|
135
|
+
"rate_limited": "rate_limit", "auth_locked_out": "rate_limit", "queue_full": "rate_limit", "queue_timeout": "rate_limit",
|
|
136
|
+
"at_capacity": "service_unavailable", "billing_unavailable": "service_unavailable", "detector_unavailable": "service_unavailable",
|
|
137
|
+
"timeout": "server_timeout",
|
|
138
|
+
"processing_failed": "processing", "no_results": "processing", "safety_limit": "processing",
|
|
139
|
+
"billing_failed": "billing",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
CLASS_BY_KIND = {cls.kind: cls for cls in (
|
|
143
|
+
APIError, AuthenticationError, NoSubscriptionError, InsufficientBalanceError, ReverifyRequiredError,
|
|
144
|
+
PlanRequiredError, FeatureUnavailableError, InvalidRequestError, ModerationError, RateLimitError, ServiceUnavailableError, ServerTimeoutError, ProcessingError, BillingError,
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def error_from_body(status: int, body: object, retry_after: Optional[float]) -> WriteHumanError:
|
|
149
|
+
"""Build the typed error for a non-2xx response."""
|
|
150
|
+
# The API's error body always has a string `error` and a string `code`. Anything else (an
|
|
151
|
+
# HTML page, or JSON like {"error": "Gateway timeout"} with no code) came from a load
|
|
152
|
+
# balancer or proxy, which gives no guarantee about whether the request was processed.
|
|
153
|
+
if not isinstance(body, dict) or not isinstance(body.get("error"), str) or not isinstance(body.get("code"), str):
|
|
154
|
+
return ProtocolError(f"HTTP {status} with a body that is not the API's error shape", status=status,
|
|
155
|
+
retry_after=retry_after)
|
|
156
|
+
code: Optional[ErrorCode] = body["code"] # type: ignore[assignment]
|
|
157
|
+
cls = CLASS_BY_KIND.get(KIND_BY_CODE.get(code or "", "api"), APIError)
|
|
158
|
+
wr = body.get("words_remaining")
|
|
159
|
+
up = body.get("upgrade_url")
|
|
160
|
+
return cls(body["error"], status=status, code=code, retry_after=retry_after,
|
|
161
|
+
words_remaining=wr if isinstance(wr, dict) else None,
|
|
162
|
+
upgrade_url=up if isinstance(up, str) else None)
|
|
File without changes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# GENERATED from src/openapi.json by sdk/gen_types.py. Do not edit; run the generator.
|
|
2
|
+
# Contract version 1.2.
|
|
3
|
+
from typing import Any, List, Literal, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
API_VERSION = "v1.2"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _HumanizeRequestOptional(TypedDict, total=False):
|
|
9
|
+
# Optional writing tone. Omit for default style. A value outside this list is ignored.
|
|
10
|
+
tone: Optional[Literal["professional", "academic", "blog", "casual", "creative", "scientific", "technical"]]
|
|
11
|
+
# Target language (e.g. `English`, `Spanish`, `Chinese (Mandarin)`). Auto-detected if omitted.
|
|
12
|
+
language: Optional[str]
|
|
13
|
+
# Serve this request with our enhanced model. Premium plans and above; on a Standard plan `true` returns `403 enhanced_requires_premium`. Must be a JSON boolean.
|
|
14
|
+
enhanced: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HumanizeRequest(_HumanizeRequestOptional):
|
|
18
|
+
# The text to humanize.
|
|
19
|
+
text: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WordsRemaining(TypedDict):
|
|
23
|
+
# Words remaining in your monthly subscription allowance.
|
|
24
|
+
included: int
|
|
25
|
+
# Words remaining from purchased top-ups.
|
|
26
|
+
topup: int
|
|
27
|
+
# Total words available (included + topup).
|
|
28
|
+
total: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HumanizeResponse(TypedDict):
|
|
32
|
+
# Unique identifier for this humanization request.
|
|
33
|
+
id: str
|
|
34
|
+
# Unix timestamp (milliseconds) when the request was received.
|
|
35
|
+
created: int
|
|
36
|
+
# Humanized text variations, sorted by score (best first).
|
|
37
|
+
results: List[str]
|
|
38
|
+
# Human-likeness score per result (0–1, higher = more human). `null` on Standard plans; array on Premium.
|
|
39
|
+
scores: Optional[List[Optional[float]]]
|
|
40
|
+
# Number of words billed for this request.
|
|
41
|
+
input_words: int
|
|
42
|
+
words_remaining: "WordsRemaining"
|
|
43
|
+
# Which model served the request: `enhanced` when the request was sent with `enhanced: true`, else `standard`.
|
|
44
|
+
model: Literal["standard", "enhanced"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UsageResponse(TypedDict):
|
|
48
|
+
# Monthly word allowance for your plan.
|
|
49
|
+
included_words: int
|
|
50
|
+
# Words consumed in the current billing cycle.
|
|
51
|
+
words_used_this_cycle: int
|
|
52
|
+
# Remaining purchased top-up words.
|
|
53
|
+
topup_balance: int
|
|
54
|
+
# Your plan. Every Premium size reports `api_premium`.
|
|
55
|
+
tier: Literal["api_standard", "api_premium"]
|
|
56
|
+
# API version.
|
|
57
|
+
version: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _DetectRequestOptional(TypedDict, total=False):
|
|
61
|
+
# Language of the input (e.g. `English`, `Spanish`). Auto-detected if omitted. Non-English text is translated to English before scoring.
|
|
62
|
+
language: Optional[str]
|
|
63
|
+
# Include per-sentence AI probabilities in the response.
|
|
64
|
+
include_sentences: bool
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DetectRequest(_DetectRequestOptional):
|
|
68
|
+
# The text to score.
|
|
69
|
+
text: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _DetectResponseClassProbabilitiesOptional(TypedDict, total=False):
|
|
73
|
+
ai: Optional[float]
|
|
74
|
+
human: Optional[float]
|
|
75
|
+
mixed: Optional[float]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DetectResponseClassProbabilities(_DetectResponseClassProbabilitiesOptional):
|
|
79
|
+
"""Probabilities for each class."""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class DetectResponseRequestMetaDocLength(TypedDict):
|
|
84
|
+
"""Length of the scored text."""
|
|
85
|
+
chars: Optional[int]
|
|
86
|
+
tokens: Optional[int]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DetectResponseRequestMeta(TypedDict):
|
|
90
|
+
# Length of the scored text.
|
|
91
|
+
doc_length: "DetectResponseRequestMetaDocLength"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DetectResponseSentencesItem(TypedDict):
|
|
95
|
+
text: Optional[str]
|
|
96
|
+
ai_prob: Optional[float]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _DetectResponseOptional(TypedDict, total=False):
|
|
100
|
+
# Per-sentence AI probabilities. Present only when `include_sentences` is `true`.
|
|
101
|
+
sentences: List["DetectResponseSentencesItem"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DetectResponse(_DetectResponseOptional):
|
|
105
|
+
# Identifier for this detection scan.
|
|
106
|
+
scanId: Optional[str]
|
|
107
|
+
# Detector model version.
|
|
108
|
+
version: Optional[str]
|
|
109
|
+
# Detector build identifier.
|
|
110
|
+
versionID: Optional[str]
|
|
111
|
+
# Probability the text is AI-generated (`1` = AI — opposite orientation of `/v1/humanize` scores).
|
|
112
|
+
ai_probability: Optional[float]
|
|
113
|
+
# Overall label for the document.
|
|
114
|
+
predicted_class: Optional[Literal["ai", "human", "mixed"]]
|
|
115
|
+
# Probabilities for each class.
|
|
116
|
+
class_probabilities: "DetectResponseClassProbabilities"
|
|
117
|
+
request_meta: "DetectResponseRequestMeta"
|
|
118
|
+
# Detected (or supplied) language of the input.
|
|
119
|
+
language: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
ErrorCode = Literal[
|
|
123
|
+
"invalid_request",
|
|
124
|
+
"forbidden_param",
|
|
125
|
+
"word_limit",
|
|
126
|
+
"moderation_blocked",
|
|
127
|
+
"missing_api_key",
|
|
128
|
+
"invalid_api_key",
|
|
129
|
+
"no_subscription",
|
|
130
|
+
"insufficient_balance",
|
|
131
|
+
"reverify_required",
|
|
132
|
+
"enhanced_requires_premium",
|
|
133
|
+
"enhanced_unavailable",
|
|
134
|
+
"rate_limited",
|
|
135
|
+
"auth_locked_out",
|
|
136
|
+
"queue_full",
|
|
137
|
+
"queue_timeout",
|
|
138
|
+
"at_capacity",
|
|
139
|
+
"billing_unavailable",
|
|
140
|
+
"detector_unavailable",
|
|
141
|
+
"timeout",
|
|
142
|
+
"processing_failed",
|
|
143
|
+
"no_results",
|
|
144
|
+
"safety_limit",
|
|
145
|
+
"billing_failed",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ErrorResponse(TypedDict):
|
|
150
|
+
# Human-readable error message.
|
|
151
|
+
error: str
|
|
152
|
+
code: "ErrorCode"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _ErrorResponseWithBalanceOptional(TypedDict, total=False):
|
|
156
|
+
words_remaining: "WordsRemaining"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ErrorResponseWithBalance(_ErrorResponseWithBalanceOptional):
|
|
160
|
+
# Human-readable error message.
|
|
161
|
+
error: str
|
|
162
|
+
code: "ErrorCode"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _ErrorResponseWithUpgradeOptional(TypedDict, total=False):
|
|
166
|
+
# Where to upgrade the plan. Present on `enhanced_requires_premium`.
|
|
167
|
+
upgrade_url: str
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ErrorResponseWithUpgrade(_ErrorResponseWithUpgradeOptional):
|
|
171
|
+
# Human-readable error message.
|
|
172
|
+
error: str
|
|
173
|
+
code: "ErrorCode"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: writehuman
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://writehuman.ai/api/docs
|
|
7
|
+
Project-URL: Documentation, https://writehuman.ai/api/docs
|
|
8
|
+
Project-URL: Source, https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python
|
|
9
|
+
Project-URL: Changelog, https://github.com/WriteHuman-ai/rephrase-api/blob/main/sdk/python/CHANGELOG.md
|
|
10
|
+
Keywords: writehuman,humanize,ai-detection,api-client
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: httpx<1,>=0.25
|
|
20
|
+
|
|
21
|
+
# writehuman
|
|
22
|
+
|
|
23
|
+
Official WriteHuman API client for Python. Humanize AI-written text, score text for AI
|
|
24
|
+
authorship, and read your usage, with typed errors and a retry policy that follows the
|
|
25
|
+
API's own `Retry-After` and never resends a billed request.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
pip install writehuman
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from writehuman import WriteHuman, InsufficientBalanceError, RateLimitError
|
|
33
|
+
|
|
34
|
+
wh = WriteHuman("wh_live_your_api_key_here")
|
|
35
|
+
|
|
36
|
+
out = wh.humanize("Artificial intelligence has transformed numerous industries over the past decade.",
|
|
37
|
+
tone="professional")
|
|
38
|
+
print(out["results"][0])
|
|
39
|
+
print(out["words_remaining"]["total"], "words left")
|
|
40
|
+
|
|
41
|
+
score = wh.detect("Your text to check here.", include_sentences=True)
|
|
42
|
+
print(score["ai_probability"], score["predicted_class"])
|
|
43
|
+
|
|
44
|
+
print(wh.usage())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Premium plans and above can ask for the enhanced model. On a Standard plan this raises
|
|
48
|
+
`PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
|
|
49
|
+
switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
|
|
50
|
+
humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
out = wh.humanize(text, enhanced=True)
|
|
54
|
+
print(out["model"]) # "enhanced"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Async, same methods:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from writehuman import AsyncWriteHuman
|
|
61
|
+
|
|
62
|
+
async with AsyncWriteHuman("wh_live_...") as wh:
|
|
63
|
+
out = await wh.humanize("...")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Errors
|
|
67
|
+
|
|
68
|
+
Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
|
|
69
|
+
code), `status`, `retry_after` (seconds, when the API sent one) and, on
|
|
70
|
+
`InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
|
|
71
|
+
|
|
72
|
+
| exception | API `code` |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
|
|
75
|
+
| `NoSubscriptionError` | `no_subscription` |
|
|
76
|
+
| `InsufficientBalanceError` | `insufficient_balance` |
|
|
77
|
+
| `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
|
|
78
|
+
| `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
|
|
79
|
+
| `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
|
|
80
|
+
| `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
|
|
81
|
+
| `ModerationError` | `moderation_blocked` |
|
|
82
|
+
| `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
|
|
83
|
+
| `ServiceUnavailableError` | `at_capacity`, `billing_unavailable`, `detector_unavailable` |
|
|
84
|
+
| `ServerTimeoutError` | `timeout` (the API's 504: nothing charged) |
|
|
85
|
+
| `ProcessingError` | `processing_failed`, `no_results`, `safety_limit` |
|
|
86
|
+
| `BillingError` | `billing_failed` (some words may have been charged; check `usage()`) |
|
|
87
|
+
| `APIError` | any code this version does not know (`code` is preserved) |
|
|
88
|
+
| `ProtocolError`, `ClientTimeoutError`, `ConnectionError` | no API body |
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
try:
|
|
92
|
+
wh.humanize(text)
|
|
93
|
+
except InsufficientBalanceError as e:
|
|
94
|
+
print("out of words:", e.words_remaining)
|
|
95
|
+
except RateLimitError as e:
|
|
96
|
+
print("slow down; the API asked for", e.retry_after, "seconds")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Retries and timeouts
|
|
100
|
+
|
|
101
|
+
- `429`, `503` and `504` are retried after exactly the `Retry-After` the API sends, up to
|
|
102
|
+
`max_retries` (default 2). A `Retry-After` above `max_retry_after` (default 60 s, for
|
|
103
|
+
example an authentication lockout) is not slept through: the error is raised at once with
|
|
104
|
+
`retry_after` set. A missing `Retry-After` waits `fallback_retry_after` (default 2 s).
|
|
105
|
+
- Nothing else is retried. `humanize` and `detect` are billed and non-idempotent, so a client
|
|
106
|
+
timeout, a dropped connection, or a 429/503/504 that did not come from the API (a load
|
|
107
|
+
balancer or proxy answering without the API's error body) raises immediately rather than
|
|
108
|
+
risking a second charge. `usage` is read-only and may retry those.
|
|
109
|
+
- Timeouts are total deadlines through the whole response, not per-read limits (`AsyncWriteHuman`
|
|
110
|
+
enforces them exactly; `WriteHuman` checks after every chunk of the body, so it can overrun
|
|
111
|
+
by at most one network read). `timeout=` sets all operations (float) or per operation
|
|
112
|
+
(`{"humanize": 120, "detect": 30, "usage": 15}`, the defaults); `connect_timeout` defaults
|
|
113
|
+
to 10 s. Every method takes a per-call `timeout=`.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
wh = WriteHuman(key, timeout={"humanize": 60}, max_retries=3)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Types
|
|
120
|
+
|
|
121
|
+
`HumanizeResponse`, `DetectResponse`, `UsageResponse`, `WordsRemaining` and `ErrorCode`
|
|
122
|
+
are generated from the API's OpenAPI document and exported for type checkers. The package
|
|
123
|
+
ships `py.typed`.
|
|
124
|
+
|
|
125
|
+
This client passes the WriteHuman SDK conformance suite (`sdk/conformance` in the API
|
|
126
|
+
repository), as does the TypeScript client; both implement the same documented policy.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/writehuman/__init__.py
|
|
4
|
+
src/writehuman/_policy.py
|
|
5
|
+
src/writehuman/client.py
|
|
6
|
+
src/writehuman/errors.py
|
|
7
|
+
src/writehuman/py.typed
|
|
8
|
+
src/writehuman/types.py
|
|
9
|
+
src/writehuman.egg-info/PKG-INFO
|
|
10
|
+
src/writehuman.egg-info/SOURCES.txt
|
|
11
|
+
src/writehuman.egg-info/dependency_links.txt
|
|
12
|
+
src/writehuman.egg-info/requires.txt
|
|
13
|
+
src/writehuman.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1,>=0.25
|
writehuman-0.0.1/PKG-INFO
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: writehuman
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: Official WriteHuman API client for Python (placeholder release; the client ships in 0.1).
|
|
5
|
-
License: MIT
|
|
6
|
-
Project-URL: Homepage, https://writehuman.ai/api/docs
|
|
7
|
-
Project-URL: Source, https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python
|
|
8
|
-
Keywords: writehuman,humanize,ai-detection,api-client
|
|
9
|
-
Classifier: Development Status :: 1 - Planning
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Requires-Python: >=3.9
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
|
-
# writehuman
|
|
15
|
-
|
|
16
|
-
Official WriteHuman API client for Python.
|
|
17
|
-
|
|
18
|
-
This is a placeholder release that reserves the package name. The client ships in 0.1.
|
|
19
|
-
Until then, call the REST API directly: https://writehuman.ai/api/docs
|
writehuman-0.0.1/README.md
DELETED
writehuman-0.0.1/pyproject.toml
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "writehuman"
|
|
7
|
-
version = "0.0.1"
|
|
8
|
-
description = "Official WriteHuman API client for Python (placeholder release; the client ships in 0.1)."
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
license = { text = "MIT" }
|
|
11
|
-
requires-python = ">=3.9"
|
|
12
|
-
keywords = ["writehuman", "humanize", "ai-detection", "api-client"]
|
|
13
|
-
classifiers = ["Development Status :: 1 - Planning", "Programming Language :: Python :: 3"]
|
|
14
|
-
|
|
15
|
-
[project.urls]
|
|
16
|
-
Homepage = "https://writehuman.ai/api/docs"
|
|
17
|
-
Source = "https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python"
|
|
18
|
-
|
|
19
|
-
[tool.setuptools.packages.find]
|
|
20
|
-
where = ["src"]
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: writehuman
|
|
3
|
-
Version: 0.0.1
|
|
4
|
-
Summary: Official WriteHuman API client for Python (placeholder release; the client ships in 0.1).
|
|
5
|
-
License: MIT
|
|
6
|
-
Project-URL: Homepage, https://writehuman.ai/api/docs
|
|
7
|
-
Project-URL: Source, https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python
|
|
8
|
-
Keywords: writehuman,humanize,ai-detection,api-client
|
|
9
|
-
Classifier: Development Status :: 1 - Planning
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Requires-Python: >=3.9
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
|
-
# writehuman
|
|
15
|
-
|
|
16
|
-
Official WriteHuman API client for Python.
|
|
17
|
-
|
|
18
|
-
This is a placeholder release that reserves the package name. The client ships in 0.1.
|
|
19
|
-
Until then, call the REST API directly: https://writehuman.ai/api/docs
|
|
File without changes
|
|
File without changes
|
|
File without changes
|