parcelwing 0.1.0__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.
parcelwing/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Official Python SDK for Parcel Wing."""
2
+
3
+ from .client import ParcelWing
4
+ from .errors import ParcelWingError, ParcelWingErrorType, is_parcelwing_error
5
+
6
+ __all__ = [
7
+ "ParcelWing",
8
+ "ParcelWingError",
9
+ "ParcelWingErrorType",
10
+ "is_parcelwing_error",
11
+ ]
parcelwing/_http.py ADDED
@@ -0,0 +1,176 @@
1
+ """Internal HTTP transport for the Parcel Wing SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, Mapping, Optional
7
+ from urllib.parse import urlencode
8
+
9
+ import httpx
10
+
11
+ from .errors import ParcelWingError, ParcelWingErrorType
12
+
13
+ SDK_VERSION = "0.1.0"
14
+ DEFAULT_BASE_URL = "https://parcelwing.com"
15
+
16
+
17
+ class HttpClient:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ api_key: str,
22
+ base_url: str = DEFAULT_BASE_URL,
23
+ timeout: float = 30.0,
24
+ headers: Optional[Mapping[str, str]] = None,
25
+ http_client: Optional[httpx.Client] = None,
26
+ ) -> None:
27
+ if not api_key or not api_key.strip():
28
+ raise ValueError("ParcelWing client requires a non-empty api_key.")
29
+
30
+ self.api_key = api_key.strip()
31
+ self.base_url = base_url.rstrip("/")
32
+ self.timeout = timeout
33
+ self.headers = dict(headers or {})
34
+ self._client = http_client
35
+ self._owns_client = http_client is None
36
+
37
+ if self._client is None:
38
+ self._client = httpx.Client(timeout=timeout)
39
+
40
+ def close(self) -> None:
41
+ if self._owns_client and self._client is not None:
42
+ self._client.close()
43
+
44
+ def __enter__(self) -> "HttpClient":
45
+ return self
46
+
47
+ def __exit__(self, *_exc: object) -> None:
48
+ self.close()
49
+
50
+ def request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ *,
55
+ json_body: Any = None,
56
+ params: Optional[Mapping[str, Any]] = None,
57
+ headers: Optional[Mapping[str, str]] = None,
58
+ ) -> Any:
59
+ if self._client is None:
60
+ raise RuntimeError("Parcel Wing HTTP client is closed.")
61
+
62
+ url = f"{self.base_url}{path}"
63
+ if params:
64
+ query = to_query_string(params)
65
+ if query:
66
+ url = f"{url}?{query}"
67
+
68
+ request_headers: Dict[str, str] = {
69
+ "Accept": "application/json",
70
+ "Authorization": f"Bearer {self.api_key}",
71
+ "X-ParcelWing-SDK": f"python/{SDK_VERSION}",
72
+ **self.headers,
73
+ **dict(headers or {}),
74
+ }
75
+ if json_body is not None:
76
+ request_headers["Content-Type"] = "application/json"
77
+
78
+ try:
79
+ response = self._client.request(
80
+ method,
81
+ url,
82
+ headers=request_headers,
83
+ json=json_body,
84
+ timeout=self.timeout,
85
+ )
86
+ except httpx.TimeoutException as exc:
87
+ raise ParcelWingError(
88
+ f"Request timed out after {self.timeout}s.",
89
+ status=408,
90
+ type="api_error",
91
+ code="request_timeout",
92
+ ) from exc
93
+ except httpx.HTTPError as exc:
94
+ raise ParcelWingError(
95
+ str(exc),
96
+ status=0,
97
+ type="api_error",
98
+ code="network_error",
99
+ ) from exc
100
+
101
+ text = response.text
102
+ parsed = _safe_json_parse(text)
103
+
104
+ if response.status_code < 200 or response.status_code >= 300:
105
+ raise _to_parcelwing_error(response.status_code, parsed, text)
106
+
107
+ return parsed
108
+
109
+
110
+ def to_query_string(params: Mapping[str, Any]) -> str:
111
+ clean: Dict[str, str] = {}
112
+ for key, value in params.items():
113
+ if value is None:
114
+ continue
115
+ if isinstance(value, bool):
116
+ clean[key] = "true" if value else "false"
117
+ else:
118
+ clean[key] = str(value)
119
+ return urlencode(clean)
120
+
121
+
122
+ def unwrap_resource(response: Mapping[str, Any]) -> Any:
123
+ return response.get("data")
124
+
125
+
126
+ def _safe_json_parse(value: str) -> Any:
127
+ if not value:
128
+ return None
129
+ try:
130
+ return json.loads(value)
131
+ except json.JSONDecodeError:
132
+ return None
133
+
134
+
135
+ def _to_parcelwing_error(status: int, parsed: Any, fallback_text: str) -> ParcelWingError:
136
+ if isinstance(parsed, Mapping) and isinstance(parsed.get("error"), Mapping):
137
+ error = parsed["error"]
138
+ message = str(error.get("message") or fallback_text or "Parcel Wing API error.")
139
+ error_type = str(error.get("type") or "api_error")
140
+ allowed_types = {
141
+ "authentication_error",
142
+ "validation_error",
143
+ "invalid_request_error",
144
+ "not_found_error",
145
+ "conflict_error",
146
+ "rate_limit_error",
147
+ "reputation_error",
148
+ "suppression_error",
149
+ "api_error",
150
+ }
151
+ if error_type not in allowed_types:
152
+ error_type = "api_error"
153
+
154
+ metadata = {
155
+ str(key): value
156
+ for key, value in error.items()
157
+ if key not in {"type", "code", "message", "details", "request_id"}
158
+ }
159
+ return ParcelWingError(
160
+ message,
161
+ status=status,
162
+ type=error_type, # type: ignore[arg-type]
163
+ code=error.get("code") if isinstance(error.get("code"), str) else None,
164
+ request_id=error.get("request_id")
165
+ if isinstance(error.get("request_id"), str)
166
+ else None,
167
+ details=error.get("details"),
168
+ metadata=metadata,
169
+ )
170
+
171
+ return ParcelWingError(
172
+ fallback_text or f"Parcel Wing API request failed with status {status}.",
173
+ status=status,
174
+ type="api_error",
175
+ code="http_error",
176
+ )
parcelwing/client.py ADDED
@@ -0,0 +1,51 @@
1
+ """Parcel Wing API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Mapping, Optional
6
+
7
+ import httpx
8
+
9
+ from ._http import DEFAULT_BASE_URL, HttpClient
10
+ from .resources import (
11
+ AutomationsResource,
12
+ ContactsResource,
13
+ EmailsResource,
14
+ SegmentsResource,
15
+ TopicsResource,
16
+ )
17
+
18
+
19
+ class ParcelWing:
20
+ """Client for the Parcel Wing API."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ api_key: str,
26
+ base_url: str = DEFAULT_BASE_URL,
27
+ timeout: float = 30.0,
28
+ headers: Optional[Mapping[str, str]] = None,
29
+ http_client: Optional[httpx.Client] = None,
30
+ ) -> None:
31
+ self.http = HttpClient(
32
+ api_key=api_key,
33
+ base_url=base_url,
34
+ timeout=timeout,
35
+ headers=headers,
36
+ http_client=http_client,
37
+ )
38
+ self.emails = EmailsResource(self.http)
39
+ self.contacts = ContactsResource(self.http)
40
+ self.segments = SegmentsResource(self.http)
41
+ self.topics = TopicsResource(self.http)
42
+ self.automations = AutomationsResource(self.http)
43
+
44
+ def close(self) -> None:
45
+ self.http.close()
46
+
47
+ def __enter__(self) -> "ParcelWing":
48
+ return self
49
+
50
+ def __exit__(self, *_exc: object) -> None:
51
+ self.close()
parcelwing/errors.py ADDED
@@ -0,0 +1,54 @@
1
+ """Parcel Wing SDK exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Literal, Optional
6
+
7
+ ParcelWingErrorType = Literal[
8
+ "authentication_error",
9
+ "validation_error",
10
+ "invalid_request_error",
11
+ "not_found_error",
12
+ "conflict_error",
13
+ "rate_limit_error",
14
+ "reputation_error",
15
+ "suppression_error",
16
+ "api_error",
17
+ ]
18
+
19
+
20
+ class ParcelWingError(Exception):
21
+ """Raised when a Parcel Wing API request fails."""
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ *,
27
+ status: int,
28
+ type: ParcelWingErrorType = "api_error",
29
+ code: Optional[str] = None,
30
+ request_id: Optional[str] = None,
31
+ details: Any = None,
32
+ metadata: Optional[Dict[str, Any]] = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status = status
37
+ self.type = type
38
+ self.code = code
39
+ self.request_id = request_id
40
+ self.details = details
41
+ self.metadata = metadata or {}
42
+
43
+ def __repr__(self) -> str:
44
+ return (
45
+ "ParcelWingError("
46
+ f"status={self.status!r}, type={self.type!r}, "
47
+ f"code={self.code!r}, message={self.message!r})"
48
+ )
49
+
50
+
51
+ def is_parcelwing_error(error: BaseException) -> bool:
52
+ """Return True when *error* is a ParcelWingError."""
53
+
54
+ return isinstance(error, ParcelWingError)
parcelwing/py.typed ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ from .automations import AutomationsResource
2
+ from .contacts import ContactsResource
3
+ from .emails import EmailsResource
4
+ from .segments import SegmentsResource
5
+ from .topics import TopicsResource
6
+
7
+ __all__ = [
8
+ "AutomationsResource",
9
+ "ContactsResource",
10
+ "EmailsResource",
11
+ "SegmentsResource",
12
+ "TopicsResource",
13
+ ]
@@ -0,0 +1,18 @@
1
+ """Automation events resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Mapping
6
+
7
+ from .._http import HttpClient
8
+
9
+
10
+ class AutomationsResource:
11
+ def __init__(self, http: HttpClient) -> None:
12
+ self._http = http
13
+
14
+ def track(self, event: Mapping[str, Any]) -> Dict[str, Any]:
15
+ response = self._http.request(
16
+ "POST", "/api/automations/events", json_body=dict(event)
17
+ )
18
+ return dict(response.get("data", {}))
@@ -0,0 +1,47 @@
1
+ """Contacts resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Mapping, Sequence, Union
6
+ from urllib.parse import quote
7
+
8
+ from .._http import HttpClient
9
+
10
+
11
+ class ContactsResource:
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def list(self, **params: Any) -> Dict[str, Any]:
16
+ return dict(self._http.request("GET", "/api/contacts", params=params))
17
+
18
+ def create(
19
+ self, contact: Union[Mapping[str, Any], Sequence[Mapping[str, Any]]]
20
+ ) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
21
+ body: Any
22
+ if isinstance(contact, Mapping):
23
+ body = dict(contact)
24
+ else:
25
+ body = [dict(item) for item in contact]
26
+
27
+ response = self._http.request("POST", "/api/contacts", json_body=body)
28
+ return response.get("data")
29
+
30
+ def get(self, contact_id: str) -> Dict[str, Any]:
31
+ response = self._http.request(
32
+ "GET", f"/api/contacts/{quote(contact_id, safe='')}"
33
+ )
34
+ return dict(response.get("data", {}))
35
+
36
+ def update(self, contact_id: str, contact: Mapping[str, Any]) -> Dict[str, Any]:
37
+ response = self._http.request(
38
+ "PATCH",
39
+ f"/api/contacts/{quote(contact_id, safe='')}",
40
+ json_body=dict(contact),
41
+ )
42
+ return dict(response.get("data", {}))
43
+
44
+ def delete(self, contact_id: str) -> Dict[str, Any]:
45
+ return dict(
46
+ self._http.request("DELETE", f"/api/contacts/{quote(contact_id, safe='')}")
47
+ )
@@ -0,0 +1,65 @@
1
+ """Email sending resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
6
+
7
+ from .._http import HttpClient
8
+
9
+ EmailRecipient = Union[str, Sequence[str]]
10
+
11
+
12
+ class EmailsResource:
13
+ def __init__(self, http: HttpClient) -> None:
14
+ self._http = http
15
+
16
+ def send(
17
+ self,
18
+ email: Optional[Mapping[str, Any]] = None,
19
+ *,
20
+ from_: Optional[str] = None,
21
+ to: Optional[EmailRecipient] = None,
22
+ subject: Optional[str] = None,
23
+ text: Optional[str] = None,
24
+ html: Optional[str] = None,
25
+ reply_to: Optional[str] = None,
26
+ tags: Optional[Mapping[str, str]] = None,
27
+ template_id: Optional[str] = None,
28
+ template_alias: Optional[str] = None,
29
+ template_params: Optional[Mapping[str, Any]] = None,
30
+ ) -> List[Dict[str, Any]]:
31
+ """Queue one email request.
32
+
33
+ Pass either a raw request dict using API field names, or keyword arguments.
34
+ Because ``from`` is a Python keyword, use ``from_`` with keyword arguments.
35
+ """
36
+
37
+ payload: Dict[str, Any]
38
+ if email is not None:
39
+ payload = dict(email)
40
+ else:
41
+ payload = {}
42
+
43
+ if from_ is not None:
44
+ payload["from"] = from_
45
+ if to is not None:
46
+ payload["to"] = list(to) if not isinstance(to, str) else to
47
+ if subject is not None:
48
+ payload["subject"] = subject
49
+ if text is not None:
50
+ payload["text"] = text
51
+ if html is not None:
52
+ payload["html"] = html
53
+ if reply_to is not None:
54
+ payload["reply_to"] = reply_to
55
+ if tags is not None:
56
+ payload["tags"] = dict(tags)
57
+ if template_id is not None:
58
+ payload["template_id"] = template_id
59
+ if template_alias is not None:
60
+ payload["template_alias"] = template_alias
61
+ if template_params is not None:
62
+ payload["template_params"] = dict(template_params)
63
+
64
+ response = self._http.request("POST", "/api/emails", json_body=payload)
65
+ return list(response.get("data", []))
@@ -0,0 +1,39 @@
1
+ """Segments resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Mapping
6
+ from urllib.parse import quote
7
+
8
+ from .._http import HttpClient
9
+
10
+
11
+ class SegmentsResource:
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def list(self, **params: Any) -> Dict[str, Any]:
16
+ return dict(self._http.request("GET", "/api/segments", params=params))
17
+
18
+ def create(self, segment: Mapping[str, Any]) -> Dict[str, Any]:
19
+ response = self._http.request("POST", "/api/segments", json_body=dict(segment))
20
+ return dict(response.get("data", {}))
21
+
22
+ def get(self, segment_id: str) -> Dict[str, Any]:
23
+ response = self._http.request(
24
+ "GET", f"/api/segments/{quote(segment_id, safe='')}"
25
+ )
26
+ return dict(response.get("data", {}))
27
+
28
+ def update(self, segment_id: str, segment: Mapping[str, Any]) -> Dict[str, Any]:
29
+ response = self._http.request(
30
+ "PATCH",
31
+ f"/api/segments/{quote(segment_id, safe='')}",
32
+ json_body=dict(segment),
33
+ )
34
+ return dict(response.get("data", {}))
35
+
36
+ def delete(self, segment_id: str) -> Dict[str, Any]:
37
+ return dict(
38
+ self._http.request("DELETE", f"/api/segments/{quote(segment_id, safe='')}")
39
+ )
@@ -0,0 +1,37 @@
1
+ """Topics resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Mapping
6
+ from urllib.parse import quote
7
+
8
+ from .._http import HttpClient
9
+
10
+
11
+ class TopicsResource:
12
+ def __init__(self, http: HttpClient) -> None:
13
+ self._http = http
14
+
15
+ def list(self, **params: Any) -> Dict[str, Any]:
16
+ return dict(self._http.request("GET", "/api/topics", params=params))
17
+
18
+ def create(self, topic: Mapping[str, Any]) -> Dict[str, Any]:
19
+ response = self._http.request("POST", "/api/topics", json_body=dict(topic))
20
+ return dict(response.get("data", {}))
21
+
22
+ def get(self, topic_id: str) -> Dict[str, Any]:
23
+ response = self._http.request("GET", f"/api/topics/{quote(topic_id, safe='')}")
24
+ return dict(response.get("data", {}))
25
+
26
+ def update(self, topic_id: str, topic: Mapping[str, Any]) -> Dict[str, Any]:
27
+ response = self._http.request(
28
+ "PATCH",
29
+ f"/api/topics/{quote(topic_id, safe='')}",
30
+ json_body=dict(topic),
31
+ )
32
+ return dict(response.get("data", {}))
33
+
34
+ def delete(self, topic_id: str) -> Dict[str, Any]:
35
+ return dict(
36
+ self._http.request("DELETE", f"/api/topics/{quote(topic_id, safe='')}")
37
+ )
parcelwing/types.py ADDED
@@ -0,0 +1,245 @@
1
+ """Type definitions for the Parcel Wing API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
6
+
7
+ NullablePrimitive = Union[str, int, float, bool, None]
8
+ JsonObject = Dict[str, Any]
9
+
10
+
11
+ class Pagination(TypedDict):
12
+ page: int
13
+ limit: int
14
+ total: int
15
+ total_pages: int
16
+ has_next: bool
17
+ has_prev: bool
18
+
19
+
20
+ class ListResponse(TypedDict, total=False):
21
+ object: Literal["list"]
22
+ data: List[Any]
23
+ pagination: Pagination
24
+
25
+
26
+ class DeletionResponse(TypedDict):
27
+ object: str
28
+ id: str
29
+ deleted: Literal[True]
30
+
31
+
32
+ class ApiErrorPayload(TypedDict, total=False):
33
+ type: str
34
+ code: str
35
+ message: str
36
+ details: Any
37
+ request_id: str
38
+
39
+
40
+ ContactStatus = Literal[
41
+ "active", "unsubscribed", "bounced", "spam_complaint", "inactive"
42
+ ]
43
+
44
+
45
+ class Contact(TypedDict, total=False):
46
+ object: Literal["contact"]
47
+ id: str
48
+ email: str
49
+ first_name: Optional[str]
50
+ last_name: Optional[str]
51
+ full_name: Optional[str]
52
+ attributes: Dict[str, NullablePrimitive]
53
+ status: ContactStatus
54
+ source: Optional[str]
55
+ external_id: Optional[str]
56
+ metadata: JsonObject
57
+ created_at: str
58
+ updated_at: str
59
+ subscribed_at: Optional[str]
60
+ unsubscribed_at: Optional[str]
61
+
62
+
63
+ class ContactCreateRequest(TypedDict, total=False):
64
+ email: str
65
+ first_name: str
66
+ last_name: str
67
+ attributes: Dict[str, NullablePrimitive]
68
+ status: ContactStatus
69
+ external_id: str
70
+ metadata: JsonObject
71
+
72
+
73
+ ContactUpdateRequest = ContactCreateRequest
74
+
75
+
76
+ class ContactListParams(TypedDict, total=False):
77
+ page: int
78
+ limit: int
79
+ status: ContactStatus
80
+ search: str
81
+ external_id: str
82
+ sort_by: Literal["created_at", "updated_at", "email", "first_name", "last_name"]
83
+ sort_order: Literal["asc", "desc"]
84
+
85
+
86
+ class BatchCreateContactsResult(TypedDict):
87
+ created: List[Contact]
88
+ failed: List[Dict[str, str]]
89
+
90
+
91
+ SegmentFilterField = Literal[
92
+ "email",
93
+ "first_name",
94
+ "last_name",
95
+ "full_name",
96
+ "status",
97
+ "source",
98
+ "external_id",
99
+ "created_at",
100
+ "updated_at",
101
+ "subscribed_at",
102
+ "unsubscribed_at",
103
+ "attribute",
104
+ ]
105
+ SegmentFilterOperator = Literal[
106
+ "equals",
107
+ "not_equals",
108
+ "contains",
109
+ "not_contains",
110
+ "starts_with",
111
+ "ends_with",
112
+ "is_empty",
113
+ "is_not_empty",
114
+ "before",
115
+ "after",
116
+ "on_or_before",
117
+ "on_or_after",
118
+ ]
119
+
120
+
121
+ class SegmentFilterCondition(TypedDict, total=False):
122
+ id: str
123
+ field: SegmentFilterField
124
+ operator: SegmentFilterOperator
125
+ value: Union[str, int, float, bool]
126
+ attribute_key: str
127
+
128
+
129
+ class SegmentFilterCriteria(TypedDict):
130
+ version: Literal[1]
131
+ match: Literal["all", "any"]
132
+ conditions: List[SegmentFilterCondition]
133
+
134
+
135
+ class Segment(TypedDict, total=False):
136
+ object: Literal["segment"]
137
+ id: str
138
+ name: str
139
+ description: Optional[str]
140
+ filter_criteria: SegmentFilterCriteria
141
+ type: Optional[str]
142
+ contact_count: int
143
+ is_active: bool
144
+ metadata: Optional[JsonObject]
145
+ created_at: str
146
+ updated_at: str
147
+
148
+
149
+ class SegmentCreateRequest(TypedDict, total=False):
150
+ name: str
151
+ description: str
152
+ filter_criteria: SegmentFilterCriteria
153
+ is_active: bool
154
+
155
+
156
+ SegmentUpdateRequest = SegmentCreateRequest
157
+
158
+
159
+ class SegmentListParams(TypedDict, total=False):
160
+ page: int
161
+ limit: int
162
+ search: str
163
+ active: bool
164
+ include_counts: bool
165
+ sort_by: Literal["created_at", "updated_at", "name"]
166
+ sort_order: Literal["asc", "desc"]
167
+
168
+
169
+ TopicDefaultSubscription = Literal["opt_in", "opt_out"]
170
+ TopicVisibility = Literal["public", "private"]
171
+
172
+
173
+ class Topic(TypedDict, total=False):
174
+ object: Literal["topic"]
175
+ id: str
176
+ name: str
177
+ description: Optional[str]
178
+ default_subscription: TopicDefaultSubscription
179
+ visibility: TopicVisibility
180
+ is_active: bool
181
+ subscriber_count: int
182
+ explicit_subscriber_count: int
183
+ explicit_unsubscriber_count: int
184
+ created_at: str
185
+ updated_at: str
186
+
187
+
188
+ class TopicCreateRequest(TypedDict, total=False):
189
+ name: str
190
+ description: str
191
+ default_subscription: TopicDefaultSubscription
192
+ visibility: TopicVisibility
193
+ is_active: bool
194
+
195
+
196
+ class TopicUpdateRequest(TypedDict, total=False):
197
+ name: str
198
+ description: str
199
+ visibility: TopicVisibility
200
+ is_active: bool
201
+
202
+
203
+ class TopicListParams(TypedDict, total=False):
204
+ page: int
205
+ limit: int
206
+ search: str
207
+ active: bool
208
+ visibility: TopicVisibility
209
+ sort_by: Literal["created_at", "updated_at", "name"]
210
+ sort_order: Literal["asc", "desc"]
211
+
212
+
213
+ class EmailSendRequest(TypedDict, total=False):
214
+ from_: str
215
+ to: Union[str, List[str]]
216
+ subject: str
217
+ text: str
218
+ html: str
219
+ reply_to: str
220
+ tags: Dict[str, str]
221
+ template_id: str
222
+ template_alias: str
223
+ template_params: Dict[str, NullablePrimitive]
224
+
225
+
226
+ class QueuedEmail(TypedDict):
227
+ object: Literal["email"]
228
+ id: str
229
+ to: str
230
+ status: Literal["queued"]
231
+
232
+
233
+ class AutomationTrackRequest(TypedDict, total=False):
234
+ event_name: str
235
+ contact_id: Optional[str]
236
+ payload: Dict[str, Any]
237
+ event_id: str
238
+
239
+
240
+ class AutomationEvent(TypedDict, total=False):
241
+ object: Literal["automation_event"]
242
+ event_id: str
243
+ event_name: str
244
+ contact_id: Optional[str]
245
+ queued_runs: int
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: parcelwing
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Parcel Wing API.
5
+ Project-URL: Homepage, https://parcelwing.com
6
+ Project-URL: Repository, https://github.com/parcelwing/parcelwing-python
7
+ Project-URL: Issues, https://github.com/parcelwing/parcelwing-python/issues
8
+ Author: Parcel Wing
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: email,marketing-email,parcel-wing,python,sdk,transactional-email
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications :: Email
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx<1,>=0.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.8; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Parcel Wing Python SDK
32
+
33
+ The official Python SDK for the Parcel Wing API.
34
+
35
+ It is designed for a fast, predictable developer experience:
36
+
37
+ - resource clients for emails, contacts, segments, topics, and automations
38
+ - consistent `ParcelWingError` exceptions
39
+ - typed package metadata and exported type hints
40
+ - small dependency surface built on `httpx`
41
+ - works with the same public API contract used by Parcel Wing itself
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install parcelwing
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ First you'll need an API key. If you don't have one, sign up and create one at https://parcelwing.com/signup. It's free, with no credit card required.
52
+
53
+ ```python
54
+ import os
55
+
56
+ from parcelwing import ParcelWing
57
+
58
+ parcel_wing = ParcelWing(api_key=os.environ["PARCEL_WING_API_KEY"])
59
+
60
+ emails = parcel_wing.emails.send(
61
+ from_="Acme <hello@yourdomain.com>",
62
+ to="person@example.com",
63
+ subject="Hello from Parcel Wing",
64
+ text="It works.",
65
+ )
66
+
67
+ print(emails[0]["id"])
68
+ ```
69
+
70
+ `from` is a Python keyword, so the keyword-argument API uses `from_`. You can also pass a raw API dictionary if you prefer exact API field names:
71
+
72
+ ```python
73
+ emails = parcel_wing.emails.send({
74
+ "from": "Acme <hello@yourdomain.com>",
75
+ "to": "person@example.com",
76
+ "subject": "Hello from Parcel Wing",
77
+ "text": "It works.",
78
+ })
79
+ ```
80
+
81
+ ## Using templates
82
+
83
+ ```python
84
+ emails = parcel_wing.emails.send(
85
+ from_="Acme <hello@yourdomain.com>",
86
+ to="person@example.com",
87
+ template_alias="welcome_email",
88
+ template_params={
89
+ "first_name": "John",
90
+ },
91
+ )
92
+ ```
93
+
94
+ ## Contacts
95
+
96
+ ```python
97
+ contact = parcel_wing.contacts.create({
98
+ "email": "person@example.com",
99
+ "first_name": "John",
100
+ "attributes": {
101
+ "plan": "pro",
102
+ },
103
+ })
104
+
105
+ page = parcel_wing.contacts.list(page=1, limit=20)
106
+
107
+ print(len(page["data"]), page.get("pagination", {}).get("total"))
108
+ ```
109
+
110
+ Batch create contacts:
111
+
112
+ ```python
113
+ result = parcel_wing.contacts.create([
114
+ {"email": "one@example.com", "first_name": "One"},
115
+ {"email": "two@example.com", "first_name": "Two"},
116
+ ])
117
+
118
+ print(result["created"])
119
+ print(result["failed"])
120
+ ```
121
+
122
+ ## Segments
123
+
124
+ ```python
125
+ segment = parcel_wing.segments.create({
126
+ "name": "Pro plan users",
127
+ "filter_criteria": {
128
+ "version": 1,
129
+ "match": "all",
130
+ "conditions": [
131
+ {
132
+ "field": "attribute",
133
+ "attribute_key": "plan",
134
+ "operator": "equals",
135
+ "value": "pro",
136
+ },
137
+ ],
138
+ },
139
+ })
140
+ ```
141
+
142
+ ## Topics
143
+
144
+ ```python
145
+ topic = parcel_wing.topics.create({
146
+ "name": "Product Updates",
147
+ "description": "Feature launches and release notes.",
148
+ "default_subscription": "opt_in",
149
+ "visibility": "public",
150
+ })
151
+ ```
152
+
153
+ ## Automation events
154
+
155
+ ```python
156
+ parcel_wing.automations.track({
157
+ "event_name": "user.completed_onboarding",
158
+ "contact_id": "6d9dc8f7-c44e-4f2d-8a4e-d04f32f1744f",
159
+ "payload": {
160
+ "plan": "flight",
161
+ },
162
+ })
163
+ ```
164
+
165
+ ## Error handling
166
+
167
+ ```python
168
+ from parcelwing import ParcelWingError
169
+
170
+ try:
171
+ parcel_wing.emails.send(
172
+ from_="Acme <hello@yourdomain.com>",
173
+ to="person@example.com",
174
+ subject="Hello",
175
+ text="Hi there",
176
+ )
177
+ except ParcelWingError as error:
178
+ print(error.status, error.type, error.code, error.request_id)
179
+ print(error.details)
180
+ ```
181
+
182
+ ## Configuration
183
+
184
+ ```python
185
+ parcel_wing = ParcelWing(
186
+ api_key=os.environ["PARCEL_WING_API_KEY"],
187
+ base_url="https://parcelwing.com",
188
+ timeout=30.0,
189
+ )
190
+ ```
191
+
192
+ Use the client as a context manager to close the underlying HTTP connection pool automatically:
193
+
194
+ ```python
195
+ with ParcelWing(api_key=os.environ["PARCEL_WING_API_KEY"]) as parcel_wing:
196
+ emails = parcel_wing.emails.send(
197
+ from_="Acme <hello@yourdomain.com>",
198
+ to="person@example.com",
199
+ subject="Hello",
200
+ text="It works.",
201
+ )
202
+ ```
203
+
204
+ ## Local development
205
+
206
+ ```bash
207
+ python -m venv .venv
208
+ source .venv/bin/activate
209
+ pip install -e ".[dev]"
210
+ pytest
211
+ ruff check .
212
+ mypy src/parcelwing
213
+ ```
214
+
215
+ ## Publishing
216
+
217
+ ```bash
218
+ python -m pip install build twine
219
+ python -m build
220
+ twine upload dist/*
221
+ ```
@@ -0,0 +1,16 @@
1
+ parcelwing/__init__.py,sha256=kbRrVfQSHzGO8W-GhrhUFJZvi0LXWwUK1yytsJLTpwo,263
2
+ parcelwing/_http.py,sha256=mf64ArNP_GUpD9wnUSalVMsPhnktvDMQhmkvzi2SjYY,5314
3
+ parcelwing/client.py,sha256=yXolYUijbfErKqmm6wEm3rmeg8Fv_fxjvwINmmVSOsg,1266
4
+ parcelwing/errors.py,sha256=G2V2EuK9JUz6RD4rWtwz0R0kzfQJ5iicytiybuS03-g,1392
5
+ parcelwing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ parcelwing/types.py,sha256=vb1l56UTjqb5LlCC-74I6tcfDLsCV-BIr13Jgba9Huk,5243
7
+ parcelwing/resources/__init__.py,sha256=ZoOtWbQO-lu1_B5HW9iB0HsgizaM5DXLzX9lavs9e2I,327
8
+ parcelwing/resources/automations.py,sha256=5pHuuqr_mFJbjf3H6vRWxT4-4ezHVTUk1_sWcolg6LA,476
9
+ parcelwing/resources/contacts.py,sha256=azN3T3lvxEoEyGytK7u-CZ5c85_S3uwsNzoUG3VZNKI,1533
10
+ parcelwing/resources/emails.py,sha256=vQhar2lNwdW7mM6NqkP3gKps93rftM4atxc9_iSBALo,2144
11
+ parcelwing/resources/segments.py,sha256=p9ioLihAYvw3AEHvWXdYBnUm-oZMHMqiajOfAvpxIW4,1291
12
+ parcelwing/resources/topics.py,sha256=fe9rr4L1YI4owtcQXdaLb8PCY64y6yBU2MaMOzFOlxY,1235
13
+ parcelwing-0.1.0.dist-info/METADATA,sha256=wGQKNMa204vDnjwwaRkRemQzgC3iK6MvFy1oQcVZTuo,5238
14
+ parcelwing-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ parcelwing-0.1.0.dist-info/licenses/LICENSE,sha256=HLzFoQHltRqtbycfmymCfS1PZvXDeOF0gRdPK6jF0wU,1068
16
+ parcelwing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parcel Wing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.