promtexpress 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- promtexpress-0.1.0/PKG-INFO +76 -0
- promtexpress-0.1.0/README.md +60 -0
- promtexpress-0.1.0/pyproject.toml +30 -0
- promtexpress-0.1.0/setup.cfg +4 -0
- promtexpress-0.1.0/src/promtexpress/__init__.py +29 -0
- promtexpress-0.1.0/src/promtexpress/_client.py +141 -0
- promtexpress-0.1.0/src/promtexpress/_errors.py +97 -0
- promtexpress-0.1.0/src/promtexpress/_types.py +81 -0
- promtexpress-0.1.0/src/promtexpress/py.typed +0 -0
- promtexpress-0.1.0/src/promtexpress.egg-info/PKG-INFO +76 -0
- promtexpress-0.1.0/src/promtexpress.egg-info/SOURCES.txt +12 -0
- promtexpress-0.1.0/src/promtexpress.egg-info/dependency_links.txt +1 -0
- promtexpress-0.1.0/src/promtexpress.egg-info/top_level.txt +1 -0
- promtexpress-0.1.0/tests/test_client.py +183 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: promtexpress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the PromtExpress API
|
|
5
|
+
Author: Webitro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://promtexpress.com
|
|
8
|
+
Project-URL: Source, https://github.com/WebitroHQ/promtexpress-oss
|
|
9
|
+
Project-URL: Issues, https://github.com/WebitroHQ/promtexpress-oss/issues
|
|
10
|
+
Keywords: promtexpress,prompt,prompt-engineering,llm,ai,sdk
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# promtexpress
|
|
18
|
+
|
|
19
|
+
Official Python client for the [PromtExpress](https://promtexpress.com) API. Standard library only, Python 3.9+.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install promtexpress
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from promtexpress import PromtExpress
|
|
29
|
+
|
|
30
|
+
client = PromtExpress() # reads PROMTEXPRESS_API_KEY
|
|
31
|
+
|
|
32
|
+
result = client.generate(
|
|
33
|
+
"product launch email for a note-taking app, friendly tone",
|
|
34
|
+
"text", # text | code | image | video | audio | music
|
|
35
|
+
)
|
|
36
|
+
print(result["output"])
|
|
37
|
+
print(result["creditsRemaining"], "credits left")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Clarifying questions and iteration
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
if result.get("chipQuestions"):
|
|
44
|
+
better = client.generate(
|
|
45
|
+
"product launch email for a note-taking app",
|
|
46
|
+
"text",
|
|
47
|
+
answers=[{"question": result["chipQuestions"][0]["label"], "answer": "developers"}],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
refined = client.generate(
|
|
51
|
+
"product launch email for a note-taking app",
|
|
52
|
+
"text",
|
|
53
|
+
iteration={"ofPromptId": result["promptId"], "feedback": "shorter, mention the free tier"},
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Templates and history
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
templates = client.list_templates(modality="image")
|
|
61
|
+
|
|
62
|
+
page = client.list_history(page=0, limit=50)
|
|
63
|
+
|
|
64
|
+
for entry in client.iter_history(modality="video"):
|
|
65
|
+
print(entry["date"], entry["title"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
All errors inherit from `PromtExpressError`: `InvalidRequestError` (400), `AuthenticationError` (401), `InsufficientCreditsError` (402, with `remaining` and `required`), `RateLimitError` (429, with `retry_after`), `ServerError` (5xx) and `APIConnectionError` (network or timeout).
|
|
71
|
+
|
|
72
|
+
Time-based rate limits are retried automatically (`max_retries`, default 2).
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# promtexpress
|
|
2
|
+
|
|
3
|
+
Official Python client for the [PromtExpress](https://promtexpress.com) API. Standard library only, Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install promtexpress
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from promtexpress import PromtExpress
|
|
13
|
+
|
|
14
|
+
client = PromtExpress() # reads PROMTEXPRESS_API_KEY
|
|
15
|
+
|
|
16
|
+
result = client.generate(
|
|
17
|
+
"product launch email for a note-taking app, friendly tone",
|
|
18
|
+
"text", # text | code | image | video | audio | music
|
|
19
|
+
)
|
|
20
|
+
print(result["output"])
|
|
21
|
+
print(result["creditsRemaining"], "credits left")
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Clarifying questions and iteration
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
if result.get("chipQuestions"):
|
|
28
|
+
better = client.generate(
|
|
29
|
+
"product launch email for a note-taking app",
|
|
30
|
+
"text",
|
|
31
|
+
answers=[{"question": result["chipQuestions"][0]["label"], "answer": "developers"}],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
refined = client.generate(
|
|
35
|
+
"product launch email for a note-taking app",
|
|
36
|
+
"text",
|
|
37
|
+
iteration={"ofPromptId": result["promptId"], "feedback": "shorter, mention the free tier"},
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Templates and history
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
templates = client.list_templates(modality="image")
|
|
45
|
+
|
|
46
|
+
page = client.list_history(page=0, limit=50)
|
|
47
|
+
|
|
48
|
+
for entry in client.iter_history(modality="video"):
|
|
49
|
+
print(entry["date"], entry["title"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Errors
|
|
53
|
+
|
|
54
|
+
All errors inherit from `PromtExpressError`: `InvalidRequestError` (400), `AuthenticationError` (401), `InsufficientCreditsError` (402, with `remaining` and `required`), `RateLimitError` (429, with `retry_after`), `ServerError` (5xx) and `APIConnectionError` (network or timeout).
|
|
55
|
+
|
|
56
|
+
Time-based rate limits are retried automatically (`max_retries`, default 2).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "promtexpress"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the PromtExpress API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Webitro" }]
|
|
13
|
+
keywords = ["promtexpress", "prompt", "prompt-engineering", "llm", "ai", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://promtexpress.com"
|
|
23
|
+
Source = "https://github.com/WebitroHQ/promtexpress-oss"
|
|
24
|
+
Issues = "https://github.com/WebitroHQ/promtexpress-oss/issues"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.package-data]
|
|
30
|
+
promtexpress = ["py.typed"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Official Python client for the PromtExpress API."""
|
|
2
|
+
|
|
3
|
+
from ._client import DEFAULT_BASE_URL, MODALITIES, PromtExpress
|
|
4
|
+
from ._errors import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
InsufficientCreditsError,
|
|
9
|
+
InvalidRequestError,
|
|
10
|
+
PromtExpressError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
ServerError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_BASE_URL",
|
|
19
|
+
"MODALITIES",
|
|
20
|
+
"PromtExpress",
|
|
21
|
+
"PromtExpressError",
|
|
22
|
+
"APIError",
|
|
23
|
+
"APIConnectionError",
|
|
24
|
+
"InvalidRequestError",
|
|
25
|
+
"AuthenticationError",
|
|
26
|
+
"InsufficientCreditsError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"ServerError",
|
|
29
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence
|
|
11
|
+
|
|
12
|
+
from ._errors import APIConnectionError, PromtExpressError, RateLimitError, error_from_response
|
|
13
|
+
from ._types import Answer, GenerateResult, HistoryPage, HistoryRow, Iteration, Template
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://promtexpress.com/api/v1"
|
|
16
|
+
MODALITIES = ("text", "code", "image", "video", "audio", "music")
|
|
17
|
+
|
|
18
|
+
_USER_AGENT = "promtexpress-python/0.1.0"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PromtExpress:
|
|
22
|
+
"""Client for the PromtExpress API.
|
|
23
|
+
|
|
24
|
+
>>> client = PromtExpress() # reads PROMTEXPRESS_API_KEY
|
|
25
|
+
>>> result = client.generate("launch email for my CRM", "text")
|
|
26
|
+
>>> print(result["output"])
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: Optional[str] = None,
|
|
32
|
+
*,
|
|
33
|
+
base_url: Optional[str] = None,
|
|
34
|
+
timeout: float = 120.0,
|
|
35
|
+
max_retries: int = 2,
|
|
36
|
+
) -> None:
|
|
37
|
+
api_key = api_key or os.environ.get("PROMTEXPRESS_API_KEY")
|
|
38
|
+
if not api_key:
|
|
39
|
+
raise PromtExpressError(
|
|
40
|
+
"Missing API key: pass api_key or set PROMTEXPRESS_API_KEY. "
|
|
41
|
+
"Keys are created in the PromtExpress dashboard under API Keys."
|
|
42
|
+
)
|
|
43
|
+
self.base_url = (base_url or os.environ.get("PROMTEXPRESS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
44
|
+
self._api_key = api_key
|
|
45
|
+
self._timeout = timeout
|
|
46
|
+
self._max_retries = max_retries
|
|
47
|
+
|
|
48
|
+
def generate(
|
|
49
|
+
self,
|
|
50
|
+
intent: str,
|
|
51
|
+
modality: str,
|
|
52
|
+
*,
|
|
53
|
+
target_engine_id: Optional[str] = None,
|
|
54
|
+
answers: Optional[Sequence[Answer]] = None,
|
|
55
|
+
iteration: Optional[Iteration] = None,
|
|
56
|
+
) -> GenerateResult:
|
|
57
|
+
"""Compile a plain-language intent into a production-ready prompt. Consumes credits."""
|
|
58
|
+
if not isinstance(intent, str) or not 3 <= len(intent) <= 4000:
|
|
59
|
+
raise PromtExpressError("intent must be a string of 3 to 4000 characters")
|
|
60
|
+
if modality not in MODALITIES:
|
|
61
|
+
raise PromtExpressError(f"modality must be one of: {', '.join(MODALITIES)}")
|
|
62
|
+
if answers is not None and len(answers) > 10:
|
|
63
|
+
raise PromtExpressError("answers accepts at most 10 items")
|
|
64
|
+
|
|
65
|
+
body: Dict[str, Any] = {"intent": intent, "modality": modality}
|
|
66
|
+
if target_engine_id is not None:
|
|
67
|
+
body["targetEngineId"] = target_engine_id
|
|
68
|
+
if answers:
|
|
69
|
+
body["answers"] = list(answers)
|
|
70
|
+
if iteration:
|
|
71
|
+
body["iteration"] = dict(iteration)
|
|
72
|
+
return self._request("POST", "/generate", body=body)
|
|
73
|
+
|
|
74
|
+
def list_templates(self, modality: Optional[str] = None) -> List[Template]:
|
|
75
|
+
"""List published prompt templates."""
|
|
76
|
+
return self._request("GET", "/templates", query={"modality": modality})["templates"]
|
|
77
|
+
|
|
78
|
+
def list_history(self, *, page: int = 0, limit: int = 20, modality: Optional[str] = None) -> HistoryPage:
|
|
79
|
+
"""Fetch one page of your generation history, newest first."""
|
|
80
|
+
return self._request("GET", "/history", query={"page": page, "limit": limit, "modality": modality})
|
|
81
|
+
|
|
82
|
+
def iter_history(self, *, limit: int = 100, modality: Optional[str] = None) -> Iterator[HistoryRow]:
|
|
83
|
+
"""Walk your whole generation history, requesting pages as needed."""
|
|
84
|
+
page = 0
|
|
85
|
+
while True:
|
|
86
|
+
result = self.list_history(page=page, limit=limit, modality=modality)
|
|
87
|
+
yield from result["rows"]
|
|
88
|
+
if not result["rows"] or (page + 1) * result["pageSize"] >= result["total"]:
|
|
89
|
+
return
|
|
90
|
+
page += 1
|
|
91
|
+
|
|
92
|
+
def _request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
98
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
99
|
+
) -> Any:
|
|
100
|
+
url = self.base_url + path
|
|
101
|
+
params = {key: value for key, value in (query or {}).items() if value is not None}
|
|
102
|
+
if params:
|
|
103
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
104
|
+
|
|
105
|
+
headers = {
|
|
106
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
107
|
+
"Accept": "application/json",
|
|
108
|
+
"User-Agent": _USER_AGENT,
|
|
109
|
+
}
|
|
110
|
+
data = None
|
|
111
|
+
if body is not None:
|
|
112
|
+
headers["Content-Type"] = "application/json"
|
|
113
|
+
data = json.dumps(body).encode("utf-8")
|
|
114
|
+
|
|
115
|
+
attempt = 0
|
|
116
|
+
while True:
|
|
117
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
120
|
+
return _parse(response.read())
|
|
121
|
+
except urllib.error.HTTPError as exc:
|
|
122
|
+
error = error_from_response(exc.code, _parse(exc.read()), exc.headers)
|
|
123
|
+
# The API rejects rate-limited calls before charging credits, so retrying is safe even for generate.
|
|
124
|
+
if isinstance(error, RateLimitError) and error.retry_after is not None and attempt < self._max_retries:
|
|
125
|
+
attempt += 1
|
|
126
|
+
time.sleep(error.retry_after)
|
|
127
|
+
continue
|
|
128
|
+
raise error from None
|
|
129
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as exc:
|
|
130
|
+
reason = getattr(exc, "reason", exc)
|
|
131
|
+
raise APIConnectionError(f"Could not reach {self.base_url}: {reason}") from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse(raw: bytes) -> Any:
|
|
135
|
+
if not raw:
|
|
136
|
+
return None
|
|
137
|
+
text = raw.decode("utf-8", errors="replace")
|
|
138
|
+
try:
|
|
139
|
+
return json.loads(text)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return text
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PromtExpressError(Exception):
|
|
7
|
+
"""Base class for every error raised by this SDK."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(PromtExpressError):
|
|
11
|
+
"""The API responded with a non-2xx status."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status: int, message: str, body: Any) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.message = message
|
|
17
|
+
self.body = body
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidRequestError(APIError):
|
|
21
|
+
"""400: the request body failed server-side validation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(APIError):
|
|
25
|
+
"""401: the API key is missing, invalid, expired or revoked."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InsufficientCreditsError(APIError):
|
|
29
|
+
"""402: not enough credits left for this generation."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, message: str, body: Any) -> None:
|
|
32
|
+
super().__init__(status, message, body)
|
|
33
|
+
self.remaining = _number_field(body, "remaining")
|
|
34
|
+
self.required = _number_field(body, "required")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RateLimitError(APIError):
|
|
38
|
+
"""429: rate limited, or the iteration limit for a prompt was reached."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, status: int, message: str, body: Any, retry_after: Optional[float]) -> None:
|
|
41
|
+
super().__init__(status, message, body)
|
|
42
|
+
#: Seconds to wait before retrying; None when the limit is not time-based.
|
|
43
|
+
self.retry_after = retry_after
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerError(APIError):
|
|
47
|
+
"""5xx: the generation pipeline or an upstream engine failed."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APIConnectionError(PromtExpressError):
|
|
51
|
+
"""The request never got a response: network failure or timeout."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def error_from_response(status: int, body: Any, headers: Mapping[str, str]) -> APIError:
|
|
55
|
+
message = _describe(status, body)
|
|
56
|
+
if status == 400:
|
|
57
|
+
return InvalidRequestError(status, message, body)
|
|
58
|
+
if status == 401:
|
|
59
|
+
return AuthenticationError(status, message, body)
|
|
60
|
+
if status == 402:
|
|
61
|
+
return InsufficientCreditsError(status, message, body)
|
|
62
|
+
if status == 429:
|
|
63
|
+
retry_after = _number_field(body, "retryAfterSec")
|
|
64
|
+
if retry_after is None:
|
|
65
|
+
retry_after = _parse_retry_after(headers.get("Retry-After"))
|
|
66
|
+
return RateLimitError(status, message, body, retry_after)
|
|
67
|
+
if status >= 500:
|
|
68
|
+
return ServerError(status, message, body)
|
|
69
|
+
return APIError(status, message, body)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _describe(status: int, body: Any) -> str:
|
|
73
|
+
error = _string_field(body, "error")
|
|
74
|
+
detail = _string_field(body, "message")
|
|
75
|
+
if error and detail:
|
|
76
|
+
return f"{error}: {detail}"
|
|
77
|
+
return error or detail or f"HTTP {status}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
81
|
+
if value is None:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
seconds = float(value)
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
return seconds if seconds >= 0 else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _number_field(body: Any, key: str) -> Optional[float]:
|
|
91
|
+
value = body.get(key) if isinstance(body, dict) else None
|
|
92
|
+
return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _string_field(body: Any, key: str) -> Optional[str]:
|
|
96
|
+
value = body.get(key) if isinstance(body, dict) else None
|
|
97
|
+
return value if isinstance(value, str) and value else None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Literal, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Answer(TypedDict):
|
|
7
|
+
question: str
|
|
8
|
+
answer: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Iteration(TypedDict):
|
|
12
|
+
ofPromptId: str
|
|
13
|
+
feedback: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChipQuestion(TypedDict):
|
|
17
|
+
label: str
|
|
18
|
+
options: List[str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Assumption(TypedDict):
|
|
22
|
+
key: str
|
|
23
|
+
value: str
|
|
24
|
+
label_tr: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecentEntry(TypedDict):
|
|
28
|
+
id: str
|
|
29
|
+
mod: str
|
|
30
|
+
title: str
|
|
31
|
+
userInput: str
|
|
32
|
+
date: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _GenerateResultOptional(TypedDict, total=False):
|
|
36
|
+
chipQuestions: List[ChipQuestion]
|
|
37
|
+
ambiguityClarifications: List[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GenerateResult(_GenerateResultOptional):
|
|
41
|
+
promptId: str
|
|
42
|
+
output: str
|
|
43
|
+
creditsUsed: int
|
|
44
|
+
creditsRemaining: int
|
|
45
|
+
latencyMs: int
|
|
46
|
+
validationScore: Optional[float]
|
|
47
|
+
validationIssues: List[str]
|
|
48
|
+
assumptions: List[Assumption]
|
|
49
|
+
traceId: str
|
|
50
|
+
scenario: Literal["A", "B", "C"]
|
|
51
|
+
recentEntry: RecentEntry
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Template(TypedDict):
|
|
55
|
+
id: str
|
|
56
|
+
title: str
|
|
57
|
+
description: Optional[str]
|
|
58
|
+
category: str
|
|
59
|
+
modality: str
|
|
60
|
+
engine: Optional[str]
|
|
61
|
+
variables: object
|
|
62
|
+
version: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class HistoryRow(TypedDict):
|
|
66
|
+
id: str
|
|
67
|
+
title: str
|
|
68
|
+
modality: str
|
|
69
|
+
engine: str
|
|
70
|
+
credits: int
|
|
71
|
+
date: str
|
|
72
|
+
status: Literal["Done", "Failed"]
|
|
73
|
+
userInput: str
|
|
74
|
+
result: Optional[str]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class HistoryPage(TypedDict):
|
|
78
|
+
rows: List[HistoryRow]
|
|
79
|
+
total: int
|
|
80
|
+
page: int
|
|
81
|
+
pageSize: int
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: promtexpress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the PromtExpress API
|
|
5
|
+
Author: Webitro
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://promtexpress.com
|
|
8
|
+
Project-URL: Source, https://github.com/WebitroHQ/promtexpress-oss
|
|
9
|
+
Project-URL: Issues, https://github.com/WebitroHQ/promtexpress-oss/issues
|
|
10
|
+
Keywords: promtexpress,prompt,prompt-engineering,llm,ai,sdk
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# promtexpress
|
|
18
|
+
|
|
19
|
+
Official Python client for the [PromtExpress](https://promtexpress.com) API. Standard library only, Python 3.9+.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install promtexpress
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from promtexpress import PromtExpress
|
|
29
|
+
|
|
30
|
+
client = PromtExpress() # reads PROMTEXPRESS_API_KEY
|
|
31
|
+
|
|
32
|
+
result = client.generate(
|
|
33
|
+
"product launch email for a note-taking app, friendly tone",
|
|
34
|
+
"text", # text | code | image | video | audio | music
|
|
35
|
+
)
|
|
36
|
+
print(result["output"])
|
|
37
|
+
print(result["creditsRemaining"], "credits left")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Clarifying questions and iteration
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
if result.get("chipQuestions"):
|
|
44
|
+
better = client.generate(
|
|
45
|
+
"product launch email for a note-taking app",
|
|
46
|
+
"text",
|
|
47
|
+
answers=[{"question": result["chipQuestions"][0]["label"], "answer": "developers"}],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
refined = client.generate(
|
|
51
|
+
"product launch email for a note-taking app",
|
|
52
|
+
"text",
|
|
53
|
+
iteration={"ofPromptId": result["promptId"], "feedback": "shorter, mention the free tier"},
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Templates and history
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
templates = client.list_templates(modality="image")
|
|
61
|
+
|
|
62
|
+
page = client.list_history(page=0, limit=50)
|
|
63
|
+
|
|
64
|
+
for entry in client.iter_history(modality="video"):
|
|
65
|
+
print(entry["date"], entry["title"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Errors
|
|
69
|
+
|
|
70
|
+
All errors inherit from `PromtExpressError`: `InvalidRequestError` (400), `AuthenticationError` (401), `InsufficientCreditsError` (402, with `remaining` and `required`), `RateLimitError` (429, with `retry_after`), `ServerError` (5xx) and `APIConnectionError` (network or timeout).
|
|
71
|
+
|
|
72
|
+
Time-based rate limits are retried automatically (`max_retries`, default 2).
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/promtexpress/__init__.py
|
|
4
|
+
src/promtexpress/_client.py
|
|
5
|
+
src/promtexpress/_errors.py
|
|
6
|
+
src/promtexpress/_types.py
|
|
7
|
+
src/promtexpress/py.typed
|
|
8
|
+
src/promtexpress.egg-info/PKG-INFO
|
|
9
|
+
src/promtexpress.egg-info/SOURCES.txt
|
|
10
|
+
src/promtexpress.egg-info/dependency_links.txt
|
|
11
|
+
src/promtexpress.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
promtexpress
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import unittest
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
from promtexpress import (
|
|
9
|
+
APIConnectionError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
InsufficientCreditsError,
|
|
12
|
+
PromtExpress,
|
|
13
|
+
PromtExpressError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
RESULT = {
|
|
19
|
+
"promptId": "p_1",
|
|
20
|
+
"output": "You are a senior copywriter...",
|
|
21
|
+
"creditsUsed": 2,
|
|
22
|
+
"creditsRemaining": 98,
|
|
23
|
+
"latencyMs": 1200,
|
|
24
|
+
"validationScore": 0.92,
|
|
25
|
+
"validationIssues": [],
|
|
26
|
+
"assumptions": [],
|
|
27
|
+
"traceId": "t_1",
|
|
28
|
+
"scenario": "A",
|
|
29
|
+
"recentEntry": {"id": "p_1", "mod": "text", "title": "t", "userInput": "u", "date": "d"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MockApi:
|
|
34
|
+
"""A real HTTP server that replays scripted responses and records requests."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.responses = []
|
|
38
|
+
self.requests = []
|
|
39
|
+
api = self
|
|
40
|
+
|
|
41
|
+
class Handler(BaseHTTPRequestHandler):
|
|
42
|
+
def _reply(self):
|
|
43
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
44
|
+
raw = self.rfile.read(length) if length else b""
|
|
45
|
+
api.requests.append(
|
|
46
|
+
{
|
|
47
|
+
"method": self.command,
|
|
48
|
+
"url": urlparse(self.path),
|
|
49
|
+
"headers": dict(self.headers),
|
|
50
|
+
"body": json.loads(raw) if raw else None,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
status, body, headers = api.responses.pop(0)
|
|
54
|
+
payload = json.dumps(body).encode()
|
|
55
|
+
self.send_response(status)
|
|
56
|
+
self.send_header("Content-Type", "application/json")
|
|
57
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
58
|
+
for key, value in headers.items():
|
|
59
|
+
self.send_header(key, value)
|
|
60
|
+
self.end_headers()
|
|
61
|
+
self.wfile.write(payload)
|
|
62
|
+
|
|
63
|
+
do_GET = _reply
|
|
64
|
+
do_POST = _reply
|
|
65
|
+
|
|
66
|
+
def log_message(self, *args):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
70
|
+
self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
|
|
71
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
72
|
+
|
|
73
|
+
def reply(self, status, body, headers=None):
|
|
74
|
+
self.responses.append((status, body, headers or {}))
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
self.server.shutdown()
|
|
78
|
+
self.server.server_close()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ClientTest(unittest.TestCase):
|
|
82
|
+
def setUp(self):
|
|
83
|
+
os.environ.pop("PROMTEXPRESS_API_KEY", None)
|
|
84
|
+
os.environ.pop("PROMTEXPRESS_BASE_URL", None)
|
|
85
|
+
self.api = MockApi()
|
|
86
|
+
self.client = PromtExpress("pe_test_py", base_url=self.api.base_url + "/")
|
|
87
|
+
|
|
88
|
+
def tearDown(self):
|
|
89
|
+
self.api.close()
|
|
90
|
+
|
|
91
|
+
def test_generate_sends_authenticated_json(self):
|
|
92
|
+
self.api.reply(200, RESULT)
|
|
93
|
+
|
|
94
|
+
result = self.client.generate("launch email for a CRM", "text", target_engine_id="eng_1")
|
|
95
|
+
|
|
96
|
+
self.assertEqual(result["output"], RESULT["output"])
|
|
97
|
+
request = self.api.requests[0]
|
|
98
|
+
self.assertEqual(request["method"], "POST")
|
|
99
|
+
self.assertEqual(request["url"].path, "/api/v1/generate")
|
|
100
|
+
self.assertEqual(request["headers"]["Authorization"], "Bearer pe_test_py")
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
request["body"],
|
|
103
|
+
{"intent": "launch email for a CRM", "modality": "text", "targetEngineId": "eng_1"},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_reads_api_key_from_environment(self):
|
|
107
|
+
os.environ["PROMTEXPRESS_API_KEY"] = "pe_test_env"
|
|
108
|
+
self.api.reply(200, {"templates": []})
|
|
109
|
+
|
|
110
|
+
PromtExpress(base_url=self.api.base_url).list_templates()
|
|
111
|
+
|
|
112
|
+
self.assertEqual(self.api.requests[0]["headers"]["Authorization"], "Bearer pe_test_env")
|
|
113
|
+
|
|
114
|
+
def test_missing_api_key(self):
|
|
115
|
+
with self.assertRaises(PromtExpressError):
|
|
116
|
+
PromtExpress()
|
|
117
|
+
|
|
118
|
+
def test_validates_before_calling_api(self):
|
|
119
|
+
with self.assertRaisesRegex(PromtExpressError, "3 to 4000"):
|
|
120
|
+
self.client.generate("hi", "text")
|
|
121
|
+
with self.assertRaisesRegex(PromtExpressError, "modality"):
|
|
122
|
+
self.client.generate("hello there", "poem")
|
|
123
|
+
self.assertEqual(self.api.requests, [])
|
|
124
|
+
|
|
125
|
+
def test_authentication_error(self):
|
|
126
|
+
self.api.reply(401, {"error": "Invalid or inactive API key"})
|
|
127
|
+
|
|
128
|
+
with self.assertRaises(AuthenticationError) as ctx:
|
|
129
|
+
self.client.list_templates()
|
|
130
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
131
|
+
self.assertEqual(str(ctx.exception), "Invalid or inactive API key")
|
|
132
|
+
|
|
133
|
+
def test_insufficient_credits(self):
|
|
134
|
+
self.api.reply(402, {"error": "Insufficient credits", "remaining": 1, "required": 4})
|
|
135
|
+
|
|
136
|
+
with self.assertRaises(InsufficientCreditsError) as ctx:
|
|
137
|
+
self.client.generate("a product video", "video")
|
|
138
|
+
self.assertEqual((ctx.exception.remaining, ctx.exception.required), (1, 4))
|
|
139
|
+
|
|
140
|
+
def test_retries_time_based_rate_limit(self):
|
|
141
|
+
self.api.reply(429, {"error": "Rate limit exceeded", "retryAfterSec": 0}, {"Retry-After": "0"})
|
|
142
|
+
self.api.reply(200, RESULT)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(self.client.generate("launch email", "text")["promptId"], "p_1")
|
|
145
|
+
self.assertEqual(len(self.api.requests), 2)
|
|
146
|
+
|
|
147
|
+
def test_does_not_retry_iteration_limit(self):
|
|
148
|
+
self.api.reply(429, {"error": "Iteration limit reached"})
|
|
149
|
+
|
|
150
|
+
with self.assertRaises(RateLimitError) as ctx:
|
|
151
|
+
self.client.generate("launch email", "text")
|
|
152
|
+
self.assertIsNone(ctx.exception.retry_after)
|
|
153
|
+
self.assertEqual(len(self.api.requests), 1)
|
|
154
|
+
|
|
155
|
+
def test_server_error_includes_pipeline_message(self):
|
|
156
|
+
self.api.reply(503, {"error": "Pipeline error at layer 4", "message": "synth timeout"})
|
|
157
|
+
|
|
158
|
+
with self.assertRaises(ServerError) as ctx:
|
|
159
|
+
self.client.generate("launch email", "text")
|
|
160
|
+
self.assertEqual(str(ctx.exception), "Pipeline error at layer 4: synth timeout")
|
|
161
|
+
|
|
162
|
+
def test_connection_error(self):
|
|
163
|
+
client = PromtExpress("k", base_url="http://127.0.0.1:9/api/v1", timeout=2)
|
|
164
|
+
with self.assertRaises(APIConnectionError):
|
|
165
|
+
client.list_history()
|
|
166
|
+
|
|
167
|
+
def test_iter_history_walks_all_pages(self):
|
|
168
|
+
def row(i):
|
|
169
|
+
return {"id": i, "title": i, "modality": "Text", "engine": "e", "credits": 1, "date": "d", "status": "Done", "userInput": i, "result": "r"}
|
|
170
|
+
|
|
171
|
+
self.api.reply(200, {"rows": [row("a"), row("b")], "total": 3, "page": 0, "pageSize": 2})
|
|
172
|
+
self.api.reply(200, {"rows": [row("c")], "total": 3, "page": 1, "pageSize": 2})
|
|
173
|
+
|
|
174
|
+
ids = [entry["id"] for entry in self.client.iter_history(limit=2, modality="text")]
|
|
175
|
+
|
|
176
|
+
self.assertEqual(ids, ["a", "b", "c"])
|
|
177
|
+
queries = [parse_qs(r["url"].query) for r in self.api.requests]
|
|
178
|
+
self.assertEqual([q["page"] for q in queries], [["0"], ["1"]])
|
|
179
|
+
self.assertEqual(queries[0]["modality"], ["text"])
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
unittest.main()
|