reloop-email 0.2.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,35 @@
1
+ name: Publish Python Package
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ paths:
7
+ - 'reloop_email/**'
8
+ - 'pyproject.toml'
9
+ - '.github/workflows/publish.yml'
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build-and-publish:
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ id-token: write
17
+ contents: read
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.10'
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install hatch
30
+
31
+ - name: Build package
32
+ run: hatch build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ # Python bytecode and envs
2
+ __pycache__/
3
+ tests/__pycache__/
4
+ reloop_email/__pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+ .Python
9
+ env/
10
+ venv/
11
+ .venv/
12
+ pip-log.txt
13
+ *.egg-info/
14
+ .eggs/
15
+ build/
16
+ dist/
17
+ __pycache__
18
+
19
+ # IDE and OS
20
+ .DS_Store
21
+ .idea/
22
+ .vscode/
23
+ __pycache__
24
+ *.log
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.4
2
+ Name: reloop-email
3
+ Version: 0.2.0
4
+ Summary: Reloop Python SDK
5
+ Project-URL: Homepage, https://reloop.sh
6
+ Project-URL: Repository, https://github.com/reloop-labs/reloop-python
7
+ Author: Reloop Labs
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx>=0.24.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Reloop Python SDK
13
+
14
+ The official Python SDK for [Reloop](https://reloop.sh), modeled after the Stripe Python SDK with snake_case parameters and typed resource responses.
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.9 or higher
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install reloop-email
24
+ ```
25
+
26
+ ## Getting Started
27
+
28
+ ```python
29
+ from reloop_email import Reloop
30
+
31
+ reloop = Reloop(api_key="re_123456789")
32
+ # or
33
+ reloop = Reloop.client("re_123456789")
34
+ ```
35
+
36
+ ## API Keys
37
+
38
+ ```python
39
+ reloop = Reloop(api_key="rl_123456789")
40
+
41
+ reloop.api_keys.list(page=1, limit=10)
42
+
43
+ reloop.api_keys.create(
44
+ name="Production key",
45
+ enabled=True,
46
+ rate_limit_enabled=True,
47
+ )
48
+
49
+ reloop.api_keys.get("key_123456789")
50
+ reloop.api_keys.update("key_123456789", name="Renamed key")
51
+ reloop.api_keys.rotate("key_123456789")
52
+ reloop.api_keys.disable("key_123456789")
53
+ reloop.api_keys.pause("key_123456789")
54
+ reloop.api_keys.enable("key_123456789")
55
+ reloop.api_keys.delete("key_123456789")
56
+ ```
57
+
58
+ ## Domains
59
+
60
+ Add, verify, and manage sending domains. Request parameters use snake_case; responses expose snake_case attributes.
61
+
62
+ ```python
63
+ reloop = Reloop(api_key="rl_123456789")
64
+
65
+ created = reloop.domain.create(
66
+ domain="send.example.com",
67
+ custom_return_path="inbound",
68
+ click_tracking=True,
69
+ open_tracking=True,
70
+ tls="opportunistic",
71
+ sending_email=True,
72
+ receiving_email=True,
73
+ )
74
+
75
+ domains = reloop.domain.list(page=1, limit=10, status="active")
76
+ one = reloop.domain.get("domain_123456789")
77
+
78
+ reloop.domain.update(
79
+ "domain_123456789",
80
+ click_tracking=False,
81
+ sending_email=True,
82
+ )
83
+
84
+ reloop.domain.verify("domain_123456789")
85
+
86
+ reloop.domain.forward_dns("domain_123456789", email="admin@example.com")
87
+
88
+ nameservers = reloop.domain.get_nameservers("domain_123456789")
89
+ print(nameservers.dns_provider, nameservers.nameservers)
90
+
91
+ reloop.domain.delete("domain_123456789")
92
+ ```
93
+
94
+ ## Contacts
95
+
96
+ Manage contacts, custom properties, groups, and channels. Methods accept snake_case keyword arguments and return resource objects with snake_case attributes.
97
+
98
+ ### Create a contact
99
+
100
+ ```python
101
+ reloop = Reloop(api_key="re_123456789")
102
+
103
+ contact = reloop.contacts.create(
104
+ email="steve.wozniak@gmail.com",
105
+ first_name="Steve",
106
+ last_name="Wozniak",
107
+ unsubscribed=False,
108
+ )
109
+
110
+ print(contact.email)
111
+ print(contact.first_name)
112
+ ```
113
+
114
+ ### List and update contacts
115
+
116
+ ```python
117
+ contacts = reloop.contacts.list(page=1, limit=10)
118
+ print(contacts.contacts, contacts.total)
119
+
120
+ reloop.contacts.update(
121
+ "cont_123456789",
122
+ first_name="Steve",
123
+ unsubscribed=False,
124
+ )
125
+ ```
126
+
127
+ ### Groups and channels
128
+
129
+ ```python
130
+ reloop.contacts.groups.add_contact(
131
+ "grp_123456789",
132
+ contact_id="cont_123456789",
133
+ )
134
+
135
+ reloop.contacts.channels.create(
136
+ name="Product Updates",
137
+ default_subscription="opt_in",
138
+ )
139
+ ```
140
+
141
+ ## Error Handling
142
+
143
+ ```python
144
+ from reloop_email import Reloop, ReloopApiError
145
+
146
+ reloop = Reloop(api_key="re_123456789")
147
+
148
+ try:
149
+ reloop.contacts.get("cont_invalid")
150
+ except ReloopApiError as error:
151
+ print(error.status_code, error.body)
152
+ ```
153
+
154
+ ## Context Manager
155
+
156
+ ```python
157
+ with Reloop(api_key="re_123456789") as reloop:
158
+ reloop.contacts.list(limit=10)
159
+ ```
@@ -0,0 +1,148 @@
1
+ # Reloop Python SDK
2
+
3
+ The official Python SDK for [Reloop](https://reloop.sh), modeled after the Stripe Python SDK with snake_case parameters and typed resource responses.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.9 or higher
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install reloop-email
13
+ ```
14
+
15
+ ## Getting Started
16
+
17
+ ```python
18
+ from reloop_email import Reloop
19
+
20
+ reloop = Reloop(api_key="re_123456789")
21
+ # or
22
+ reloop = Reloop.client("re_123456789")
23
+ ```
24
+
25
+ ## API Keys
26
+
27
+ ```python
28
+ reloop = Reloop(api_key="rl_123456789")
29
+
30
+ reloop.api_keys.list(page=1, limit=10)
31
+
32
+ reloop.api_keys.create(
33
+ name="Production key",
34
+ enabled=True,
35
+ rate_limit_enabled=True,
36
+ )
37
+
38
+ reloop.api_keys.get("key_123456789")
39
+ reloop.api_keys.update("key_123456789", name="Renamed key")
40
+ reloop.api_keys.rotate("key_123456789")
41
+ reloop.api_keys.disable("key_123456789")
42
+ reloop.api_keys.pause("key_123456789")
43
+ reloop.api_keys.enable("key_123456789")
44
+ reloop.api_keys.delete("key_123456789")
45
+ ```
46
+
47
+ ## Domains
48
+
49
+ Add, verify, and manage sending domains. Request parameters use snake_case; responses expose snake_case attributes.
50
+
51
+ ```python
52
+ reloop = Reloop(api_key="rl_123456789")
53
+
54
+ created = reloop.domain.create(
55
+ domain="send.example.com",
56
+ custom_return_path="inbound",
57
+ click_tracking=True,
58
+ open_tracking=True,
59
+ tls="opportunistic",
60
+ sending_email=True,
61
+ receiving_email=True,
62
+ )
63
+
64
+ domains = reloop.domain.list(page=1, limit=10, status="active")
65
+ one = reloop.domain.get("domain_123456789")
66
+
67
+ reloop.domain.update(
68
+ "domain_123456789",
69
+ click_tracking=False,
70
+ sending_email=True,
71
+ )
72
+
73
+ reloop.domain.verify("domain_123456789")
74
+
75
+ reloop.domain.forward_dns("domain_123456789", email="admin@example.com")
76
+
77
+ nameservers = reloop.domain.get_nameservers("domain_123456789")
78
+ print(nameservers.dns_provider, nameservers.nameservers)
79
+
80
+ reloop.domain.delete("domain_123456789")
81
+ ```
82
+
83
+ ## Contacts
84
+
85
+ Manage contacts, custom properties, groups, and channels. Methods accept snake_case keyword arguments and return resource objects with snake_case attributes.
86
+
87
+ ### Create a contact
88
+
89
+ ```python
90
+ reloop = Reloop(api_key="re_123456789")
91
+
92
+ contact = reloop.contacts.create(
93
+ email="steve.wozniak@gmail.com",
94
+ first_name="Steve",
95
+ last_name="Wozniak",
96
+ unsubscribed=False,
97
+ )
98
+
99
+ print(contact.email)
100
+ print(contact.first_name)
101
+ ```
102
+
103
+ ### List and update contacts
104
+
105
+ ```python
106
+ contacts = reloop.contacts.list(page=1, limit=10)
107
+ print(contacts.contacts, contacts.total)
108
+
109
+ reloop.contacts.update(
110
+ "cont_123456789",
111
+ first_name="Steve",
112
+ unsubscribed=False,
113
+ )
114
+ ```
115
+
116
+ ### Groups and channels
117
+
118
+ ```python
119
+ reloop.contacts.groups.add_contact(
120
+ "grp_123456789",
121
+ contact_id="cont_123456789",
122
+ )
123
+
124
+ reloop.contacts.channels.create(
125
+ name="Product Updates",
126
+ default_subscription="opt_in",
127
+ )
128
+ ```
129
+
130
+ ## Error Handling
131
+
132
+ ```python
133
+ from reloop_email import Reloop, ReloopApiError
134
+
135
+ reloop = Reloop(api_key="re_123456789")
136
+
137
+ try:
138
+ reloop.contacts.get("cont_invalid")
139
+ except ReloopApiError as error:
140
+ print(error.status_code, error.body)
141
+ ```
142
+
143
+ ## Context Manager
144
+
145
+ ```python
146
+ with Reloop(api_key="re_123456789") as reloop:
147
+ reloop.contacts.list(limit=10)
148
+ ```
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reloop-email"
7
+ version = "0.2.0"
8
+ description = "Reloop Python SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{ name = "Reloop Labs" }]
12
+ dependencies = [
13
+ "httpx>=0.24.0",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://reloop.sh"
18
+ Repository = "https://github.com/reloop-labs/reloop-python"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["reloop_email"]
@@ -0,0 +1,12 @@
1
+ from .errors import ReloopApiError, ReloopError, ReloopNetworkError
2
+ from .reloop import Reloop
3
+
4
+ ReloopClient = Reloop
5
+
6
+ __all__ = [
7
+ "Reloop",
8
+ "ReloopClient",
9
+ "ReloopError",
10
+ "ReloopApiError",
11
+ "ReloopNetworkError",
12
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ from .errors import ReloopApiError, ReloopNetworkError
8
+
9
+
10
+ class HTTPClient:
11
+ """Internal HTTP transport for the Reloop SDK."""
12
+
13
+ def __init__(self, api_key: str, base_url: str = "https://reloop.sh") -> None:
14
+ if not api_key:
15
+ raise ValueError("Reloop SDK requires an api_key.")
16
+
17
+ self.api_key = api_key
18
+ self.base_url = base_url.rstrip("/")
19
+ self._http = httpx.Client(
20
+ base_url=self.base_url,
21
+ headers={
22
+ "x-api-key": self.api_key,
23
+ "Content-Type": "application/json",
24
+ "Accept": "application/json",
25
+ },
26
+ timeout=30.0,
27
+ )
28
+
29
+ def request(
30
+ self,
31
+ method: str,
32
+ path: str,
33
+ *,
34
+ params: Optional[dict[str, Any]] = None,
35
+ json: Optional[dict[str, Any]] = None,
36
+ ) -> dict[str, Any]:
37
+ try:
38
+ response = self._http.request(method, path, params=params, json=json)
39
+ except httpx.RequestError as exc:
40
+ raise ReloopNetworkError(f"Reloop network error: {exc}") from exc
41
+
42
+ if response.status_code >= 400:
43
+ body: Any = None
44
+ try:
45
+ body = response.json()
46
+ except ValueError:
47
+ body = response.text
48
+
49
+ message = response.reason_phrase
50
+ if isinstance(body, dict) and body.get("message"):
51
+ message = str(body["message"])
52
+
53
+ raise ReloopApiError(
54
+ f"Reloop API error ({response.status_code}): {message}",
55
+ status_code=response.status_code,
56
+ body=body,
57
+ )
58
+
59
+ if response.status_code == 204 or not response.content:
60
+ return {}
61
+
62
+ data = response.json()
63
+ if not isinstance(data, dict):
64
+ return {"data": data}
65
+ return data
66
+
67
+ def close(self) -> None:
68
+ self._http.close()
69
+
70
+ def __enter__(self) -> "HTTPClient":
71
+ return self
72
+
73
+ def __exit__(self, *args: object) -> None:
74
+ self.close()
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ REQUEST_KEY_MAP = {
7
+ "first_name": "firstName",
8
+ "last_name": "lastName",
9
+ "group_ids": "groupIds",
10
+ "group_id": "groupId",
11
+ "fallback_value": "fallbackValue",
12
+ "default_subscription": "defaultSubscription",
13
+ "channel_id": "channelId",
14
+ "property_name": "propertyName",
15
+ "property_type": "propertyType",
16
+ "contact_id": "contactId",
17
+ "rate_limit_enabled": "rateLimitEnabled",
18
+ "user_id": "userId",
19
+ }
20
+
21
+
22
+ def for_request(parameters: dict[str, Any]) -> dict[str, Any]:
23
+ normalized: dict[str, Any] = {}
24
+
25
+ for key, value in parameters.items():
26
+ if key == "unsubscribed":
27
+ if "status" not in parameters:
28
+ normalized["status"] = "unsubscribed" if value else "subscribed"
29
+ continue
30
+
31
+ api_key = REQUEST_KEY_MAP.get(key, _to_camel_case(key))
32
+ normalized[api_key] = _normalize_value(value, is_request=True)
33
+
34
+ return {key: value for key, value in normalized.items() if value is not None}
35
+
36
+
37
+ def for_query(options: dict[str, Any]) -> dict[str, Any]:
38
+ return for_request(options)
39
+
40
+
41
+ def for_snake_request(parameters: dict[str, Any]) -> dict[str, Any]:
42
+ """Pass parameters through without camelCase conversion (domain API)."""
43
+ return {key: value for key, value in parameters.items() if value is not None}
44
+
45
+
46
+ def for_response(data: dict[str, Any]) -> dict[str, Any]:
47
+ normalized: dict[str, Any] = {}
48
+
49
+ for key, value in data.items():
50
+ normalized[_to_snake_case(key)] = _normalize_value(value, is_request=False)
51
+
52
+ return normalized
53
+
54
+
55
+ def _normalize_value(value: Any, *, is_request: bool) -> Any:
56
+ if not isinstance(value, dict):
57
+ return value
58
+
59
+ if _is_list(value):
60
+ return [
61
+ _normalize_value(item, is_request=is_request) if isinstance(item, dict) else item
62
+ for item in value
63
+ ]
64
+
65
+ return for_request(value) if is_request else for_response(value)
66
+
67
+
68
+ def _is_list(value: dict[str, Any]) -> bool:
69
+ if not value:
70
+ return True
71
+ return list(value.keys()) == list(range(len(value)))
72
+
73
+
74
+ def _to_camel_case(key: str) -> str:
75
+ if key in REQUEST_KEY_MAP:
76
+ return REQUEST_KEY_MAP[key]
77
+ if "_" not in key:
78
+ return key
79
+ parts = key.split("_")
80
+ return parts[0] + "".join(part.capitalize() for part in parts[1:])
81
+
82
+
83
+ def _to_snake_case(key: str) -> str:
84
+ if "_" in key:
85
+ return key
86
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key).lower()
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterator, Mapping
4
+
5
+
6
+ class Resource(Mapping[str, Any]):
7
+ """StripeObject-style resource with snake_case attribute access."""
8
+
9
+ _repr_attr = "id"
10
+
11
+ def __init__(self, **attributes: Any) -> None:
12
+ self._values = dict(attributes)
13
+ for key, value in attributes.items():
14
+ object.__setattr__(self, key, value)
15
+
16
+ @classmethod
17
+ def from_dict(cls, data: dict[str, Any]) -> Resource:
18
+ return cls(**data)
19
+
20
+ def __getitem__(self, key: str) -> Any:
21
+ return self._values[key]
22
+
23
+ def __iter__(self) -> Iterator[str]:
24
+ return iter(self._values)
25
+
26
+ def __len__(self) -> int:
27
+ return len(self._values)
28
+
29
+ def __repr__(self) -> str:
30
+ ident = getattr(self, self._repr_attr, None)
31
+ class_name = self.__class__.__name__
32
+ if ident is not None:
33
+ return f"<{class_name} {self._repr_attr}={ident!r}>"
34
+ return f"<{class_name}>"
35
+
36
+
37
+ class ApiKey(Resource):
38
+ pass
39
+
40
+
41
+ class ApiKeyList(Resource):
42
+ pass
43
+
44
+
45
+ class Contact(Resource):
46
+ pass
47
+
48
+
49
+ class ContactList(Resource):
50
+ pass
51
+
52
+
53
+ class ContactProperty(Resource):
54
+ pass
55
+
56
+
57
+ class PropertyList(Resource):
58
+ pass
59
+
60
+
61
+ class ContactGroup(Resource):
62
+ pass
63
+
64
+
65
+ class GroupList(Resource):
66
+ pass
67
+
68
+
69
+ class ContactChannel(Resource):
70
+ pass
71
+
72
+
73
+ class ChannelList(Resource):
74
+ pass
75
+
76
+
77
+ class DnsRecord(Resource):
78
+ pass
79
+
80
+
81
+ class Domain(Resource):
82
+ pass
83
+
84
+
85
+ class DomainList(Resource):
86
+ pass
87
+
88
+
89
+ class DomainStatus(Resource):
90
+ pass
91
+
92
+
93
+ class DomainNameservers(Resource):
94
+ pass
95
+
96
+
97
+ class ForwardDnsResponse(Resource):
98
+ pass