forjio-suppuo 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,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ *.egg-info/
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: forjio-suppuo
3
+ Version: 0.1.0
4
+ Summary: Suppuo SDK — typed Python client for the suppuo.com REST API. Sister to the JS + Go SDKs.
5
+ Project-URL: Homepage, https://suppuo.com/docs/sdk/python
6
+ Project-URL: Repository, https://github.com/hachimi-cat/saas-suppuo
7
+ Author-email: Forjio <support@forjio.com>
8
+ License: Proprietary
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # forjio-suppuo
16
+
17
+ Typed Python client for the [suppuo.com](https://suppuo.com) helpdesk REST API.
18
+
19
+ ```bash
20
+ pip install forjio-suppuo
21
+ ```
22
+
23
+ ```python
24
+ from forjio_suppuo import SuppuoClient
25
+
26
+ # Bearer token from token= or the SUPPUO_TOKEN env var.
27
+ client = SuppuoClient(token="...")
28
+
29
+ # Agent workspace surface
30
+ page = client.tickets.list(status="open")
31
+ ticket = client.tickets.get(page["tickets"][0]["id"])
32
+ client.tickets.reply(ticket["id"], body="On it!", is_internal=False)
33
+ client.tickets.update(ticket["id"], status="resolved")
34
+
35
+ # Canned replies
36
+ client.canned_replies.create(title="Refund policy", body="...")
37
+
38
+ # Public (requester) surface — no token required
39
+ out = client.public.submit_ticket(
40
+ account_id="acc_...",
41
+ subject="Order question",
42
+ body="Where is my order?",
43
+ email="customer@example.com",
44
+ )
45
+ view = client.public.get_ticket(out["accessToken"])
46
+ client.public.reply_ticket(out["accessToken"], body="Any update?")
47
+ ```
48
+
49
+ Errors raise `SuppuoError` carrying the API envelope's `error.code`
50
+ (`NOT_FOUND`, `VALIDATION_ERROR`, `AUTH_REQUIRED`, ...), the HTTP
51
+ status, and the `meta.requestId`.
52
+
53
+ ## Family
54
+
55
+ Sister to:
56
+ - [`@forjio/suppuo`](https://www.npmjs.com/package/@forjio/suppuo) (JS/TS)
57
+ - [`hachimi-cat/suppuo-go`](https://github.com/hachimi-cat/suppuo-go) (Go)
@@ -0,0 +1,43 @@
1
+ # forjio-suppuo
2
+
3
+ Typed Python client for the [suppuo.com](https://suppuo.com) helpdesk REST API.
4
+
5
+ ```bash
6
+ pip install forjio-suppuo
7
+ ```
8
+
9
+ ```python
10
+ from forjio_suppuo import SuppuoClient
11
+
12
+ # Bearer token from token= or the SUPPUO_TOKEN env var.
13
+ client = SuppuoClient(token="...")
14
+
15
+ # Agent workspace surface
16
+ page = client.tickets.list(status="open")
17
+ ticket = client.tickets.get(page["tickets"][0]["id"])
18
+ client.tickets.reply(ticket["id"], body="On it!", is_internal=False)
19
+ client.tickets.update(ticket["id"], status="resolved")
20
+
21
+ # Canned replies
22
+ client.canned_replies.create(title="Refund policy", body="...")
23
+
24
+ # Public (requester) surface — no token required
25
+ out = client.public.submit_ticket(
26
+ account_id="acc_...",
27
+ subject="Order question",
28
+ body="Where is my order?",
29
+ email="customer@example.com",
30
+ )
31
+ view = client.public.get_ticket(out["accessToken"])
32
+ client.public.reply_ticket(out["accessToken"], body="Any update?")
33
+ ```
34
+
35
+ Errors raise `SuppuoError` carrying the API envelope's `error.code`
36
+ (`NOT_FOUND`, `VALIDATION_ERROR`, `AUTH_REQUIRED`, ...), the HTTP
37
+ status, and the `meta.requestId`.
38
+
39
+ ## Family
40
+
41
+ Sister to:
42
+ - [`@forjio/suppuo`](https://www.npmjs.com/package/@forjio/suppuo) (JS/TS)
43
+ - [`hachimi-cat/suppuo-go`](https://github.com/hachimi-cat/suppuo-go) (Go)
@@ -0,0 +1,6 @@
1
+ """Suppuo Python SDK — typed client for the suppuo.com helpdesk REST API."""
2
+ from .client import SuppuoClient
3
+ from .errors import SuppuoError
4
+
5
+ __all__ = ["SuppuoClient", "SuppuoError"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,279 @@
1
+ """Suppuo client — mirrors ``@forjio/suppuo`` (JS) 1:1.
2
+
3
+ Auth = Bearer JWT (a Huudis-minted access token). Pass ``token=`` or set
4
+ ``SUPPUO_TOKEN``. The ``public`` namespace (requester-facing hosted-form
5
+ endpoints) needs no token at all.
6
+
7
+ Every response rides the Forjio envelope ``{data, error, meta}``; the
8
+ client unwraps it and raises :class:`SuppuoError` (with the envelope's
9
+ ``error.code``) on failure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import Any, Dict, List, Optional
16
+ from urllib.parse import quote, urlencode
17
+
18
+ import httpx
19
+
20
+ from .errors import SuppuoError
21
+
22
+
23
+ def _qs(params: Optional[Dict[str, Any]]) -> str:
24
+ if not params:
25
+ return ""
26
+ entries = [(k, v) for k, v in params.items() if v is not None]
27
+ if not entries:
28
+ return ""
29
+ return "?" + urlencode([(k, str(v)) for k, v in entries])
30
+
31
+
32
+ def _enc(segment: str) -> str:
33
+ return quote(segment, safe="")
34
+
35
+
36
+ class _Tickets:
37
+ """Agent workspace surface (Bearer auth)."""
38
+
39
+ def __init__(self, c: "SuppuoClient") -> None:
40
+ self._c = c
41
+
42
+ def list(
43
+ self,
44
+ *,
45
+ status: Optional[str] = None,
46
+ limit: Optional[int] = None,
47
+ ) -> Dict[str, Any]:
48
+ """GET /api/v1/tickets — ``{"tickets": [...], "counts": {...}}``."""
49
+ return self._c.request(
50
+ "GET", "/api/v1/tickets" + _qs({"status": status, "limit": limit})
51
+ )
52
+
53
+ def get(self, id: str) -> Dict[str, Any]:
54
+ """GET /api/v1/tickets/:id — full ticket incl. message thread."""
55
+ return self._c.request("GET", f"/api/v1/tickets/{_enc(id)}")
56
+
57
+ def create(
58
+ self,
59
+ *,
60
+ subject: str,
61
+ body: str,
62
+ requester_email: str,
63
+ requester_name: Optional[str] = None,
64
+ priority: Optional[str] = None,
65
+ channel: Optional[str] = None,
66
+ ) -> Dict[str, Any]:
67
+ """POST /api/v1/tickets — agent-logged ticket (e.g. arrived via WhatsApp)."""
68
+ payload: Dict[str, Any] = {
69
+ "subject": subject,
70
+ "body": body,
71
+ "requesterEmail": requester_email,
72
+ }
73
+ if requester_name is not None:
74
+ payload["requesterName"] = requester_name
75
+ if priority is not None:
76
+ payload["priority"] = priority
77
+ if channel is not None:
78
+ payload["channel"] = channel
79
+ return self._c.request("POST", "/api/v1/tickets", body=payload)
80
+
81
+ def reply(
82
+ self,
83
+ id: str,
84
+ *,
85
+ body: str,
86
+ is_internal: Optional[bool] = None,
87
+ author_name: Optional[str] = None,
88
+ ) -> Dict[str, Any]:
89
+ """POST /api/v1/tickets/:id/messages — agent reply (or internal note)."""
90
+ payload: Dict[str, Any] = {"body": body}
91
+ if is_internal is not None:
92
+ payload["isInternal"] = is_internal
93
+ if author_name is not None:
94
+ payload["authorName"] = author_name
95
+ return self._c.request("POST", f"/api/v1/tickets/{_enc(id)}/messages", body=payload)
96
+
97
+ def update(
98
+ self,
99
+ id: str,
100
+ *,
101
+ status: Optional[str] = None,
102
+ priority: Optional[str] = None,
103
+ assignee_sub: Optional[str] = ..., # type: ignore[assignment]
104
+ ) -> Dict[str, Any]:
105
+ """PATCH /api/v1/tickets/:id — status / priority / assignee.
106
+
107
+ Pass ``assignee_sub=None`` explicitly to unassign.
108
+ """
109
+ payload: Dict[str, Any] = {}
110
+ if status is not None:
111
+ payload["status"] = status
112
+ if priority is not None:
113
+ payload["priority"] = priority
114
+ if assignee_sub is not ...:
115
+ payload["assigneeSub"] = assignee_sub
116
+ return self._c.request("PATCH", f"/api/v1/tickets/{_enc(id)}", body=payload)
117
+
118
+
119
+ class _CannedReplies:
120
+ """Per-workspace saved reply snippets (Bearer auth)."""
121
+
122
+ def __init__(self, c: "SuppuoClient") -> None:
123
+ self._c = c
124
+
125
+ def list(self) -> Dict[str, Any]:
126
+ """GET /api/v1/canned-replies — ``{"cannedReplies": [...]}``."""
127
+ return self._c.request("GET", "/api/v1/canned-replies")
128
+
129
+ def create(self, *, title: str, body: str) -> Dict[str, Any]:
130
+ """POST /api/v1/canned-replies."""
131
+ return self._c.request(
132
+ "POST", "/api/v1/canned-replies", body={"title": title, "body": body}
133
+ )
134
+
135
+ def update(
136
+ self,
137
+ id: str,
138
+ *,
139
+ title: Optional[str] = None,
140
+ body: Optional[str] = None,
141
+ ) -> Dict[str, Any]:
142
+ """PATCH /api/v1/canned-replies/:id."""
143
+ payload: Dict[str, Any] = {}
144
+ if title is not None:
145
+ payload["title"] = title
146
+ if body is not None:
147
+ payload["body"] = body
148
+ return self._c.request("PATCH", f"/api/v1/canned-replies/{_enc(id)}", body=payload)
149
+
150
+ def delete(self, id: str) -> Dict[str, Any]:
151
+ """DELETE /api/v1/canned-replies/:id — ``{"deleted": true}``."""
152
+ return self._c.request("DELETE", f"/api/v1/canned-replies/{_enc(id)}")
153
+
154
+
155
+ class _Public:
156
+ """Requester-facing, unauthenticated surface."""
157
+
158
+ def __init__(self, c: "SuppuoClient") -> None:
159
+ self._c = c
160
+
161
+ def submit_ticket(
162
+ self,
163
+ *,
164
+ account_id: str,
165
+ subject: str,
166
+ body: str,
167
+ email: str,
168
+ name: Optional[str] = None,
169
+ ) -> Dict[str, Any]:
170
+ """POST /api/v1/public/tickets — ``{"number", "accessToken"}``.
171
+
172
+ The returned ``accessToken`` is the requester's only credential.
173
+ """
174
+ payload: Dict[str, Any] = {
175
+ "accountId": account_id,
176
+ "subject": subject,
177
+ "body": body,
178
+ "email": email,
179
+ }
180
+ if name is not None:
181
+ payload["name"] = name
182
+ return self._c.request("POST", "/api/v1/public/tickets", body=payload, no_auth=True)
183
+
184
+ def get_ticket(self, access_token: str) -> Dict[str, Any]:
185
+ """GET /api/v1/public/tickets/:accessToken — tokenized status view."""
186
+ return self._c.request(
187
+ "GET", f"/api/v1/public/tickets/{_enc(access_token)}", no_auth=True
188
+ )
189
+
190
+ def reply_ticket(self, access_token: str, *, body: str) -> Dict[str, Any]:
191
+ """POST /api/v1/public/tickets/:accessToken/messages — requester reply."""
192
+ return self._c.request(
193
+ "POST",
194
+ f"/api/v1/public/tickets/{_enc(access_token)}/messages",
195
+ body={"body": body},
196
+ no_auth=True,
197
+ )
198
+
199
+
200
+ class SuppuoClient:
201
+ """Suppuo typed client.
202
+
203
+ Example:
204
+ client = SuppuoClient(token=os.environ["SUPPUO_TOKEN"])
205
+ page = client.tickets.list(status="open")
206
+ client.tickets.reply(page["tickets"][0]["id"], body="On it!")
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ *,
212
+ token: Optional[str] = None,
213
+ base_url: str = "https://suppuo.com",
214
+ timeout: float = 30.0,
215
+ ) -> None:
216
+ self._token = token if token is not None else os.environ.get("SUPPUO_TOKEN")
217
+ self._base_url = base_url.rstrip("/")
218
+ self._timeout = timeout
219
+
220
+ self.tickets = _Tickets(self)
221
+ self.canned_replies = _CannedReplies(self)
222
+ self.public = _Public(self)
223
+
224
+ def request(
225
+ self,
226
+ method: str,
227
+ path: str,
228
+ *,
229
+ body: Optional[Dict[str, Any]] = None,
230
+ no_auth: bool = False,
231
+ ) -> Any:
232
+ headers = {"Accept": "application/json"}
233
+ if not no_auth:
234
+ if not self._token:
235
+ raise SuppuoError(
236
+ 0,
237
+ "AUTH_REQUIRED",
238
+ "No token configured. Pass token= or set SUPPUO_TOKEN.",
239
+ )
240
+ headers["Authorization"] = f"Bearer {self._token}"
241
+
242
+ try:
243
+ resp = httpx.request(
244
+ method,
245
+ self._base_url + path,
246
+ json=body,
247
+ headers=headers,
248
+ timeout=self._timeout,
249
+ )
250
+ except httpx.TimeoutException as e:
251
+ raise SuppuoError(0, "TIMEOUT", f"request timed out: {e}") from e
252
+ except httpx.HTTPError as e:
253
+ raise SuppuoError(0, "NETWORK_ERROR", str(e)) from e
254
+
255
+ try:
256
+ envelope = resp.json()
257
+ except ValueError as e:
258
+ raise SuppuoError(
259
+ resp.status_code,
260
+ "INVALID_RESPONSE",
261
+ f"non-JSON response (HTTP {resp.status_code})",
262
+ ) from e
263
+
264
+ error = envelope.get("error") if isinstance(envelope, dict) else None
265
+ meta = envelope.get("meta") if isinstance(envelope, dict) else None
266
+ request_id = meta.get("requestId") if isinstance(meta, dict) else None
267
+
268
+ if resp.status_code >= 400 or error:
269
+ raise SuppuoError(
270
+ resp.status_code,
271
+ (error or {}).get("code", "UNKNOWN"),
272
+ (error or {}).get("message", f"HTTP {resp.status_code}"),
273
+ request_id,
274
+ (error or {}).get("param"),
275
+ )
276
+ return envelope.get("data") if isinstance(envelope, dict) else envelope
277
+
278
+
279
+ __all__ = ["SuppuoClient", "SuppuoError"]
@@ -0,0 +1,47 @@
1
+ """Typed error class for the Suppuo SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class SuppuoError(Exception):
9
+ """Raised when a Suppuo API call fails.
10
+
11
+ Attributes
12
+ ----------
13
+ status:
14
+ HTTP status code (0 for transport-level errors like timeouts).
15
+ code:
16
+ Machine-readable error code from the API envelope
17
+ (``NOT_FOUND``, ``VALIDATION_ERROR``, ``AUTH_REQUIRED``, ...)
18
+ or one of ``TIMEOUT`` / ``NETWORK_ERROR`` / ``INVALID_RESPONSE``
19
+ for SDK-side failures.
20
+ message:
21
+ Human-readable description.
22
+ request_id:
23
+ The ``meta.requestId`` echoed by the API, when available.
24
+ param:
25
+ The offending parameter on validation errors, when available.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ status: int,
31
+ code: str,
32
+ message: str,
33
+ request_id: Optional[str] = None,
34
+ param: Optional[str] = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.status = status
38
+ self.code = code
39
+ self.message = message
40
+ self.request_id = request_id
41
+ self.param = param
42
+
43
+ def __repr__(self) -> str:
44
+ return (
45
+ f"SuppuoError(status={self.status}, code={self.code!r}, "
46
+ f"message={self.message!r}, request_id={self.request_id!r})"
47
+ )
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "forjio-suppuo"
3
+ version = "0.1.0"
4
+ description = "Suppuo SDK — typed Python client for the suppuo.com REST API. Sister to the JS + Go SDKs."
5
+ authors = [{ name = "Forjio", email = "support@forjio.com" }]
6
+ license = { text = "Proprietary" }
7
+ readme = "README.md"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "httpx>=0.27.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8.0.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://suppuo.com/docs/sdk/python"
18
+ Repository = "https://github.com/hachimi-cat/saas-suppuo"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["forjio_suppuo"]
@@ -0,0 +1,21 @@
1
+ """Smoke tests for SuppuoClient."""
2
+ import pytest
3
+
4
+ from forjio_suppuo import SuppuoClient, SuppuoError
5
+
6
+
7
+ def test_construct():
8
+ c = SuppuoClient(token="test")
9
+ assert c is not None
10
+ assert c.tickets is not None
11
+ assert c.canned_replies is not None
12
+ assert c.public is not None
13
+
14
+
15
+ def test_authed_call_without_token_raises(monkeypatch):
16
+ monkeypatch.delenv("SUPPUO_TOKEN", raising=False)
17
+ c = SuppuoClient()
18
+ with pytest.raises(SuppuoError) as exc:
19
+ c.canned_replies.list()
20
+ assert exc.value.code == "AUTH_REQUIRED"
21
+ assert exc.value.status == 0