pilot-status 0.0.9__py3-none-any.whl

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
+ from .client import PilotStatusClient
2
+ from .errors import (
3
+ AuthenticationError,
4
+ ConflictError,
5
+ ForbiddenError,
6
+ NotFoundError,
7
+ PilotStatusError,
8
+ PilotStatusHttpError,
9
+ RateLimitError,
10
+ ServerError,
11
+ ValidationError,
12
+ )
13
+ from .types import (
14
+ ApiKeyCreateInput,
15
+ ApiKeyCreated,
16
+ ApiKeyListItem,
17
+ DashboardStats,
18
+ CreateNumberInput,
19
+ CreateNumberResult,
20
+ CreateProjectInput,
21
+ Environment,
22
+ MessageDetails,
23
+ MessageStatus,
24
+ NumberConnectResult,
25
+ NumberStatusResult,
26
+ OptInCheckResult,
27
+ Project,
28
+ SendMessageAccepted,
29
+ SendMessageInput,
30
+ WhatsAppNumberInstance,
31
+ )
32
+ from .webhooks.parse import is_customer_webhook_event, parse_customer_webhook
33
+ from .webhooks.types import (
34
+ CustomerWebhookEvent,
35
+ MessageFailedEvent,
36
+ MessageReadEvent,
37
+ MessageReplyEvent,
38
+ MessageSentEvent,
39
+ )
40
+
41
+ __all__ = [
42
+ "PilotStatusClient",
43
+ "PilotStatusError",
44
+ "PilotStatusHttpError",
45
+ "AuthenticationError",
46
+ "ValidationError",
47
+ "ForbiddenError",
48
+ "NotFoundError",
49
+ "ConflictError",
50
+ "RateLimitError",
51
+ "ServerError",
52
+ "Environment",
53
+ "MessageStatus",
54
+ "SendMessageInput",
55
+ "SendMessageAccepted",
56
+ "MessageDetails",
57
+ "DashboardStats",
58
+ "OptInCheckResult",
59
+ "Project",
60
+ "CreateProjectInput",
61
+ "ApiKeyCreateInput",
62
+ "ApiKeyCreated",
63
+ "ApiKeyListItem",
64
+ "WhatsAppNumberInstance",
65
+ "CreateNumberInput",
66
+ "CreateNumberResult",
67
+ "NumberConnectResult",
68
+ "NumberStatusResult",
69
+ "CustomerWebhookEvent",
70
+ "MessageSentEvent",
71
+ "MessageReadEvent",
72
+ "MessageFailedEvent",
73
+ "MessageReplyEvent",
74
+ "parse_customer_webhook",
75
+ "is_customer_webhook_event",
76
+ ]
pilot_status/client.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ import urllib.request
6
+
7
+ from .http import HttpClient, HttpClientOptions
8
+ from .resources import AnalyticsResource, ApiKeysResource, MessagesResource, NumbersResource, ProjectsResource
9
+
10
+
11
+ class PilotStatusClient:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ api_key: str,
16
+ timeout_s: float = 30.0,
17
+ user_agent: str | None = None,
18
+ get_retries: int = 2,
19
+ transport: Callable[[urllib.request.Request, float], tuple[int, dict[str, str], str]] | None = None,
20
+ ):
21
+ base_url = "https://pilotstatus.online"
22
+ http = HttpClient(
23
+ HttpClientOptions(
24
+ base_url=base_url,
25
+ api_key=api_key,
26
+ timeout_s=timeout_s,
27
+ user_agent=user_agent,
28
+ get_retries=get_retries,
29
+ transport=transport,
30
+ )
31
+ )
32
+ self.messages = MessagesResource(http)
33
+ self.analytics = AnalyticsResource(http)
34
+ self.projects = ProjectsResource(http)
35
+ self.api_keys = ApiKeysResource(http)
36
+ self.numbers = NumbersResource(http)
pilot_status/errors.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ PilotStatusErrorBody = Any
8
+
9
+
10
+ class PilotStatusError(Exception):
11
+ pass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PilotStatusHttpError(PilotStatusError):
16
+ message: str
17
+ status: int
18
+ method: str
19
+ url: str
20
+ headers: dict[str, str]
21
+ body: PilotStatusErrorBody | None = None
22
+ raw_body: str | None = None
23
+
24
+ def __str__(self) -> str:
25
+ return self.message
26
+
27
+
28
+ class AuthenticationError(PilotStatusHttpError):
29
+ pass
30
+
31
+
32
+ class ValidationError(PilotStatusHttpError):
33
+ pass
34
+
35
+
36
+ class ForbiddenError(PilotStatusHttpError):
37
+ pass
38
+
39
+
40
+ class NotFoundError(PilotStatusHttpError):
41
+ pass
42
+
43
+
44
+ class ConflictError(PilotStatusHttpError):
45
+ pass
46
+
47
+
48
+ class RateLimitError(PilotStatusHttpError):
49
+ pass
50
+
51
+
52
+ class ServerError(PilotStatusHttpError):
53
+ pass
54
+
55
+
56
+ def create_http_error(
57
+ *,
58
+ message: str,
59
+ status: int,
60
+ method: str,
61
+ url: str,
62
+ headers: dict[str, str],
63
+ body: PilotStatusErrorBody | None = None,
64
+ raw_body: str | None = None,
65
+ ) -> PilotStatusHttpError:
66
+ if status == 401:
67
+ return AuthenticationError(message, status, method, url, headers, body, raw_body)
68
+ if status == 400:
69
+ return ValidationError(message, status, method, url, headers, body, raw_body)
70
+ if status == 403:
71
+ return ForbiddenError(message, status, method, url, headers, body, raw_body)
72
+ if status == 404:
73
+ return NotFoundError(message, status, method, url, headers, body, raw_body)
74
+ if status == 409:
75
+ return ConflictError(message, status, method, url, headers, body, raw_body)
76
+ if status == 429:
77
+ return RateLimitError(message, status, method, url, headers, body, raw_body)
78
+ if status >= 500:
79
+ return ServerError(message, status, method, url, headers, body, raw_body)
80
+ return PilotStatusHttpError(message, status, method, url, headers, body, raw_body)
81
+
pilot_status/http.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from dataclasses import dataclass
9
+ from typing import Any, Callable, Mapping
10
+
11
+ from .errors import create_http_error
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class HttpClientOptions:
16
+ base_url: str
17
+ api_key: str
18
+ timeout_s: float
19
+ user_agent: str | None
20
+ get_retries: int
21
+ transport: Callable[[urllib.request.Request, float], tuple[int, Mapping[str, str], str]] | None = None
22
+
23
+
24
+ def _normalize_base_url(base_url: str) -> str:
25
+ return base_url.rstrip("/")
26
+
27
+
28
+ def _join_url(base_url: str, path: str) -> str:
29
+ base = _normalize_base_url(base_url)
30
+ if not path.startswith("/"):
31
+ return f"{base}/{path}"
32
+ return f"{base}{path}"
33
+
34
+
35
+ def _build_url(
36
+ base_url: str,
37
+ path: str,
38
+ query: Mapping[str, str | int | float | bool | None] | None,
39
+ ) -> str:
40
+ url = _join_url(base_url, path)
41
+ if not query:
42
+ return url
43
+ parts = list(urllib.parse.urlsplit(url))
44
+ current = urllib.parse.parse_qsl(parts[3], keep_blank_values=True)
45
+ for k, v in query.items():
46
+ if v is None:
47
+ continue
48
+ current.append((k, str(v)))
49
+ parts[3] = urllib.parse.urlencode(current)
50
+ return urllib.parse.urlunsplit(parts)
51
+
52
+
53
+ def _parse_json_if_possible(text: str) -> Any:
54
+ trimmed = text.strip()
55
+ if not trimmed:
56
+ return None
57
+ try:
58
+ return json.loads(trimmed)
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def _default_transport(req: urllib.request.Request, timeout_s: float) -> tuple[int, Mapping[str, str], str]:
64
+ with urllib.request.urlopen(req, timeout=timeout_s) as res:
65
+ status = int(getattr(res, "status", 200))
66
+ headers = {k.lower(): v for k, v in dict(res.headers).items()}
67
+ raw = res.read().decode("utf-8", errors="replace")
68
+ return status, headers, raw
69
+
70
+
71
+ def _should_retry(method: str, status: int | None) -> bool:
72
+ if method != "GET":
73
+ return False
74
+ if status is None:
75
+ return True
76
+ return status >= 500
77
+
78
+
79
+ class HttpClient:
80
+ def __init__(self, options: HttpClientOptions):
81
+ self._base_url = _normalize_base_url(options.base_url)
82
+ self._api_key = options.api_key
83
+ self._timeout_s = options.timeout_s
84
+ self._user_agent = options.user_agent
85
+ self._get_retries = options.get_retries
86
+ self._transport = options.transport or _default_transport
87
+
88
+ def request_json(
89
+ self,
90
+ *,
91
+ method: str,
92
+ path: str,
93
+ query: Mapping[str, str | int | float | bool | None] | None = None,
94
+ body: Any | None = None,
95
+ ) -> Any:
96
+ url = _build_url(self._base_url, path, query)
97
+
98
+ headers: dict[str, str] = {
99
+ "x-api-key": self._api_key,
100
+ "accept": "application/json",
101
+ }
102
+ if self._user_agent:
103
+ headers["user-agent"] = self._user_agent
104
+
105
+ data: bytes | None = None
106
+ if body is not None:
107
+ headers["content-type"] = "application/json"
108
+ data = json.dumps(body).encode("utf-8")
109
+
110
+ req = urllib.request.Request(url=url, data=data, method=method, headers=headers)
111
+
112
+ attempt = 0
113
+ while True:
114
+ status: int | None = None
115
+ response_headers: Mapping[str, str] = {}
116
+ raw: str = ""
117
+ try:
118
+ status, response_headers, raw = self._transport(req, self._timeout_s)
119
+ except urllib.error.HTTPError as e:
120
+ status = int(getattr(e, "code", 0) or 0) or None
121
+ response_headers = {k.lower(): v for k, v in dict(getattr(e, "headers", {}) or {}).items()}
122
+ raw = (e.read() or b"").decode("utf-8", errors="replace")
123
+ except urllib.error.URLError as e:
124
+ if attempt >= self._get_retries or not _should_retry(method, None):
125
+ raise e
126
+ backoff_ms = 250 * (2**attempt)
127
+ time.sleep(backoff_ms / 1000.0)
128
+ attempt += 1
129
+ continue
130
+
131
+ parsed = _parse_json_if_possible(raw)
132
+
133
+ if status is None or not (200 <= status < 300):
134
+ message = None
135
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), str):
136
+ message = str(parsed["error"])
137
+ if not message:
138
+ message = f"HTTP {status}" if status is not None else "HTTP error"
139
+ err = create_http_error(
140
+ message=message,
141
+ status=int(status or 0),
142
+ method=method,
143
+ url=url,
144
+ headers=dict(response_headers),
145
+ body=parsed,
146
+ raw_body=raw or None,
147
+ )
148
+ if attempt < self._get_retries and _should_retry(method, err.status):
149
+ backoff_ms = 250 * (2**attempt)
150
+ time.sleep(backoff_ms / 1000.0)
151
+ attempt += 1
152
+ continue
153
+ raise err
154
+
155
+ return parsed
156
+
@@ -0,0 +1,13 @@
1
+ from .analytics import AnalyticsResource
2
+ from .api_keys import ApiKeysResource
3
+ from .messages import MessagesResource
4
+ from .numbers import NumbersResource
5
+ from .projects import ProjectsResource
6
+
7
+ __all__ = [
8
+ "MessagesResource",
9
+ "AnalyticsResource",
10
+ "ProjectsResource",
11
+ "ApiKeysResource",
12
+ "NumbersResource",
13
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from ..http import HttpClient
4
+ from ..types import DashboardStats
5
+
6
+
7
+ class AnalyticsResource:
8
+ def __init__(self, http: HttpClient):
9
+ self._http = http
10
+
11
+ def get_dashboard_stats(self, *, tz: str | None = None) -> DashboardStats:
12
+ query = None if tz is None else {"tz": tz}
13
+ return self._http.request_json(method="GET", path="/v1/analytics/dashboard", query=query)
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..http import HttpClient
6
+ from ..types import ApiKeyCreateInput, ApiKeyCreated, ApiKeyListItem
7
+
8
+
9
+ class ApiKeysResource:
10
+ def __init__(self, http: HttpClient):
11
+ self._http = http
12
+
13
+ def list(self) -> list[ApiKeyListItem]:
14
+ return self._http.request_json(method="GET", path="/v1/api-keys")
15
+
16
+ def create(self, input: ApiKeyCreateInput) -> ApiKeyCreated:
17
+ body: dict[str, Any] = {"name": input["name"]}
18
+ if "webhookId" in input:
19
+ body["webhookId"] = input["webhookId"]
20
+ if "whatsappInstanceId" in input:
21
+ body["whatsappInstanceId"] = input["whatsappInstanceId"]
22
+ if "retentionDays" in input:
23
+ body["retentionDays"] = input["retentionDays"]
24
+ return self._http.request_json(method="POST", path="/v1/api-keys", body=body)
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..http import HttpClient
6
+ from ..types import MessageDetails, OptInCheckResult, SendMessageAccepted, SendMessageInput
7
+
8
+
9
+ class MessagesResource:
10
+ def __init__(self, http: HttpClient):
11
+ self._http = http
12
+
13
+ def send(self, input: SendMessageInput) -> SendMessageAccepted:
14
+ body: dict[str, Any] = {
15
+ "templateId": input["templateId"],
16
+ "destinationNumber": input["destinationNumber"],
17
+ "variables": input.get("variables") or {},
18
+ }
19
+ if "deliverAt" in input:
20
+ body["deliverAt"] = input["deliverAt"]
21
+ if "deliverUntil" in input:
22
+ body["deliverUntil"] = input["deliverUntil"]
23
+ return self._http.request_json(method="POST", path="/v1/messages/send", body=body)
24
+
25
+ def get(self, id: str) -> MessageDetails:
26
+ return self._http.request_json(method="GET", path=f"/v1/messages/{id}")
27
+
28
+ def check_opt_in(self, destination_number: str) -> OptInCheckResult:
29
+ return self._http.request_json(
30
+ method="GET",
31
+ path="/v1/messages/opt-in",
32
+ query={"destinationNumber": destination_number},
33
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..http import HttpClient
6
+ from ..types import (
7
+ CreateNumberInput,
8
+ CreateNumberResult,
9
+ NumberConnectResult,
10
+ NumberStatusResult,
11
+ )
12
+
13
+
14
+ class NumbersResource:
15
+ def __init__(self, http: HttpClient):
16
+ self._http = http
17
+
18
+ def create(self, input: CreateNumberInput) -> CreateNumberResult:
19
+ body: dict[str, Any] = {
20
+ "name": input["name"],
21
+ "number": input["number"],
22
+ }
23
+ if "linkToApiKey" in input:
24
+ body["linkToApiKey"] = input["linkToApiKey"]
25
+ return self._http.request_json(method="POST", path="/v1/numbers", body=body)
26
+
27
+ def connect(self, id: str) -> NumberConnectResult:
28
+ return self._http.request_json(
29
+ method="GET",
30
+ path=f"/v1/numbers/{id}/connect",
31
+ )
32
+
33
+ def get_status(self, id: str) -> NumberStatusResult:
34
+ return self._http.request_json(
35
+ method="GET",
36
+ path=f"/v1/numbers/{id}/status",
37
+ )
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..http import HttpClient
6
+ from ..types import CreateProjectInput, Project
7
+
8
+
9
+ class ProjectsResource:
10
+ def __init__(self, http: HttpClient):
11
+ self._http = http
12
+
13
+ def list(self) -> list[Project]:
14
+ return self._http.request_json(method="GET", path="/v1/projects")
15
+
16
+ def create(self, input: CreateProjectInput) -> Project:
17
+ body: dict[str, Any] = {"name": input["name"]}
18
+ if "description" in input:
19
+ body["description"] = input["description"]
20
+ return self._http.request_json(method="POST", path="/v1/projects", body=body)
pilot_status/types.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, TypedDict
4
+
5
+ Environment = Literal["TEST", "LIVE"]
6
+
7
+ MessageStatus = Literal["QUEUED", "SENT", "DELIVERED", "FAILED", "READ"]
8
+
9
+
10
+ class SendMessageInput(TypedDict, total=False):
11
+ templateId: str
12
+ destinationNumber: str
13
+ groupId: str
14
+ variables: dict[str, str]
15
+ labels: list[str]
16
+ deliverAt: str
17
+ deliverUntil: str
18
+
19
+
20
+ class SendMessageAccepted(TypedDict):
21
+ id: str
22
+ correlationId: str
23
+ status: MessageStatus
24
+ createdAt: str
25
+ origin: str
26
+
27
+
28
+ class MessageDetails(TypedDict):
29
+ id: str
30
+ status: MessageStatus
31
+ correlationId: str
32
+ destinationNumber: str
33
+ template: str
34
+ createdAt: str
35
+ sentAt: str | None
36
+ deliveredAt: str | None
37
+ readAt: str | None
38
+ errorMessage: str | None
39
+
40
+
41
+ class DashboardDailyMessages(TypedDict):
42
+ date: str
43
+ count: int
44
+
45
+
46
+ class DashboardStatusDistribution(TypedDict):
47
+ queued: int
48
+ sent: int
49
+ delivered: int
50
+ failed: int
51
+ read: int
52
+
53
+
54
+ class DashboardStats(TypedDict):
55
+ totalSent: int
56
+ totalFailed: int
57
+ sentPercentChange: float
58
+ failedPercentChange: float
59
+ failureRate: float
60
+ dailyMessages: list[DashboardDailyMessages]
61
+ statusDistribution: DashboardStatusDistribution
62
+
63
+
64
+ OptInCheckReason = Literal[
65
+ "OPTIN_DISABLED",
66
+ "NON_DEFAULT_INSTANCE",
67
+ "NOT_LIVE",
68
+ "MISSING_PROJECT",
69
+ "INVALID_PHONE",
70
+ "NO_OPTIN",
71
+ ]
72
+
73
+
74
+ class OptInCheckResult(TypedDict):
75
+ authorized: bool
76
+ required: bool
77
+ enabled: bool
78
+ reason: OptInCheckReason | None
79
+ firstOptInAt: str | None
80
+ lastSeenAt: str | None
81
+
82
+
83
+ class Project(TypedDict):
84
+ id: str
85
+ name: str
86
+ slug: str
87
+ description: str
88
+ productionApproved: bool
89
+ createdAt: str
90
+
91
+
92
+ class CreateProjectInput(TypedDict, total=False):
93
+ name: str
94
+ description: str
95
+
96
+
97
+ class ApiKeyCreateInput(TypedDict, total=False):
98
+ name: str
99
+ webhookId: str | None
100
+ whatsappInstanceId: str | None
101
+ retentionDays: int
102
+
103
+
104
+ class ApiKeyCreated(TypedDict):
105
+ id: str
106
+ name: str
107
+ environment: Environment
108
+ key: str
109
+ keyPrefix: str
110
+ createdAt: str
111
+ webhookId: str | None
112
+ whatsappInstanceId: str | None
113
+ retentionDays: int
114
+
115
+
116
+ class ApiKeyLinkedWebhook(TypedDict):
117
+ id: str
118
+ name: str
119
+ url: str
120
+
121
+
122
+ class ApiKeyLinkedWhatsAppInstance(TypedDict):
123
+ id: str
124
+ instanceName: str
125
+ displayName: str
126
+ number: str
127
+ state: str
128
+
129
+
130
+ class ApiKeyListItem(TypedDict):
131
+ id: str
132
+ name: str
133
+ keyPrefix: str
134
+ environment: Environment
135
+ lastUsedAt: str | None
136
+ createdAt: str
137
+ webhookId: str | None
138
+ projectId: str | None
139
+ webhook: ApiKeyLinkedWebhook | None
140
+ whatsappInstanceId: str | None
141
+ whatsappInstance: ApiKeyLinkedWhatsAppInstance | None
142
+ retentionDays: int
143
+
144
+
145
+ class WhatsAppNumberInstance(TypedDict):
146
+ id: str
147
+ tenantId: str
148
+ instanceName: str
149
+ number: str
150
+ displayName: str
151
+ state: str
152
+ integration: str
153
+ createdAt: str
154
+ updatedAt: str
155
+ name: str
156
+ status: str
157
+ quality: Literal["GOOD", "UNKNOWN"]
158
+
159
+
160
+ class CreateNumberInput(TypedDict, total=False):
161
+ name: str
162
+ number: str
163
+ linkToApiKey: bool
164
+
165
+
166
+ class CreateNumberResult(TypedDict):
167
+ instance: WhatsAppNumberInstance
168
+ qrcodeBase64: str | None
169
+ pairingCode: str | None
170
+ linkedApiKeyId: str | None
171
+
172
+
173
+ class NumberConnectResult(TypedDict):
174
+ qrcodeBase64: str | None
175
+ pairingCode: str | None
176
+
177
+
178
+ class NumberStatusResult(TypedDict):
179
+ id: str
180
+ state: str
@@ -0,0 +1,19 @@
1
+ from .parse import is_customer_webhook_event, parse_customer_webhook
2
+ from .types import (
3
+ CustomerWebhookEvent,
4
+ MessageFailedEvent,
5
+ MessageReadEvent,
6
+ MessageReplyEvent,
7
+ MessageSentEvent,
8
+ )
9
+
10
+ __all__ = [
11
+ "CustomerWebhookEvent",
12
+ "MessageSentEvent",
13
+ "MessageReadEvent",
14
+ "MessageFailedEvent",
15
+ "MessageReplyEvent",
16
+ "parse_customer_webhook",
17
+ "is_customer_webhook_event",
18
+ ]
19
+