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.
@@ -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
@@ -1,6 +0,0 @@
1
- # writehuman
2
-
3
- Official WriteHuman API client for Python.
4
-
5
- This is a placeholder release that reserves the package name. The client ships in 0.1.
6
- Until then, call the REST API directly: https://writehuman.ai/api/docs
@@ -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,3 +0,0 @@
1
- """WriteHuman API client. Placeholder release; the client ships in 0.1."""
2
-
3
- __version__ = "0.0.1"
@@ -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
@@ -1,7 +0,0 @@
1
- README.md
2
- pyproject.toml
3
- src/writehuman/__init__.py
4
- src/writehuman.egg-info/PKG-INFO
5
- src/writehuman.egg-info/SOURCES.txt
6
- src/writehuman.egg-info/dependency_links.txt
7
- src/writehuman.egg-info/top_level.txt
File without changes