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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()