sendly 3.33.0__tar.gz → 3.35.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.
- {sendly-3.33.0 → sendly-3.35.0}/CHANGELOG.md +7 -0
- {sendly-3.33.0 → sendly-3.35.0}/PKG-INFO +70 -1
- {sendly-3.33.0 → sendly-3.35.0}/README.md +69 -0
- {sendly-3.33.0 → sendly-3.35.0}/pyproject.toml +1 -1
- {sendly-3.33.0 → sendly-3.35.0}/sendly/__init__.py +25 -1
- {sendly-3.33.0 → sendly-3.35.0}/sendly/client.py +3 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/__init__.py +3 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/campaigns.py +6 -6
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/contacts.py +16 -16
- sendly-3.35.0/sendly/resources/numbers.py +196 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/templates.py +10 -10
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/webhooks.py +4 -4
- {sendly-3.33.0 → sendly-3.35.0}/sendly/types.py +171 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/http.py +1 -1
- sendly-3.35.0/tests/test_http_body_kwarg.py +160 -0
- sendly-3.35.0/tests/test_numbers.py +346 -0
- {sendly-3.33.0 → sendly-3.35.0}/.github/header.svg +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/.gitignore +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/examples/async_example.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/examples/error_handling.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/examples/send_sms.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/errors.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/account.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/conversations.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/drafts.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/enterprise.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/labels.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/media.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/messages.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/rules.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/resources/verify.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/__init__.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/utils/validation.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/sendly/webhooks.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/conftest.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_async_client.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_client.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_errors.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages.py.bak +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages_batch.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_messages_schedule.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_validation.py +0 -0
- {sendly-3.33.0 → sendly-3.35.0}/tests/test_webhooks.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# sendly (Python)
|
|
2
2
|
|
|
3
|
+
## 3.33.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- **Fixed a runtime `TypeError` on every write method across `contacts`, `campaigns`, `templates`, and `webhooks`.** Those resources called the internal HTTP client with a `json=` keyword, but `HttpClient.request()` / `AsyncHttpClient.request()` only accept `body=`. Every affected call site now passes `body=`, so creates/updates (e.g. `contacts.import_contacts`, `contacts.lookup`, `contacts.bulk_mark_valid`, `campaigns.create`, `templates.create`, `webhooks.create`) send their payloads correctly instead of raising.
|
|
8
|
+
- Reconciled the version to a single source of truth at `3.33.0`: `pyproject.toml`, `sendly.__version__`, and the `User-Agent` header (`SDK_VERSION` in `utils/http.py`, previously `1.0.5`) now all report `sendly-python/3.33.0`.
|
|
9
|
+
|
|
3
10
|
## 3.32.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sendly
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.35.0
|
|
4
4
|
Summary: Official Sendly Python SDK for SMS messaging
|
|
5
5
|
Project-URL: Homepage, https://sendly.live
|
|
6
6
|
Project-URL: Documentation, https://sendly.live/docs
|
|
@@ -406,6 +406,57 @@ print(f"New key: {result['key']}") # Only shown once!
|
|
|
406
406
|
client.account.revoke_api_key('key_xxx')
|
|
407
407
|
```
|
|
408
408
|
|
|
409
|
+
## Phone Numbers
|
|
410
|
+
|
|
411
|
+
Search for and buy phone numbers. Prices on available numbers are already
|
|
412
|
+
customer-priced. Requires an API key with the `numbers:read` / `numbers:write`
|
|
413
|
+
scopes.
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
# List the countries where numbers are available
|
|
417
|
+
countries = client.numbers.list_countries()
|
|
418
|
+
for country in countries.countries:
|
|
419
|
+
print(f'{country.code} {country.name}: {country.number_types}')
|
|
420
|
+
|
|
421
|
+
# Search for available numbers (already customer-priced)
|
|
422
|
+
available = client.numbers.list_available(country='GB', type='mobile')
|
|
423
|
+
for num in available.numbers:
|
|
424
|
+
print(f'{num.phone_number} — {num.monthly_cost} {num.currency}')
|
|
425
|
+
|
|
426
|
+
# Optionally filter by a digit pattern
|
|
427
|
+
available = client.numbers.list_available(country='GB', type='mobile', contains='777')
|
|
428
|
+
|
|
429
|
+
# List the numbers you already own
|
|
430
|
+
owned = client.numbers.list()
|
|
431
|
+
for num in owned.numbers:
|
|
432
|
+
print(f'{num.phone_number} ({num.status})')
|
|
433
|
+
|
|
434
|
+
# Buy a number
|
|
435
|
+
chosen = available.numbers[0]
|
|
436
|
+
result = client.numbers.buy(
|
|
437
|
+
phone_number=chosen.phone_number,
|
|
438
|
+
country_code='GB',
|
|
439
|
+
phone_number_type='mobile',
|
|
440
|
+
monthly_cost=chosen.monthly_cost,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if result.status == 'provisioning':
|
|
444
|
+
print(f'Provisioning {result.number.phone_number}')
|
|
445
|
+
elif result.status in ('documents_required', 'payment_required'):
|
|
446
|
+
# Hand the user the hosted Sendly page + code, wait for them to finish,
|
|
447
|
+
# then call buy() again with the same arguments plus action_code set to
|
|
448
|
+
# the completed action's code.
|
|
449
|
+
print(f'Open {result.action.url} and enter code {result.action.code}')
|
|
450
|
+
# ...later, after the user completes the page...
|
|
451
|
+
result = client.numbers.buy(
|
|
452
|
+
phone_number=chosen.phone_number,
|
|
453
|
+
country_code='GB',
|
|
454
|
+
phone_number_type='mobile',
|
|
455
|
+
monthly_cost=chosen.monthly_cost,
|
|
456
|
+
action_code=result.action.code,
|
|
457
|
+
)
|
|
458
|
+
```
|
|
459
|
+
|
|
409
460
|
## Error Handling
|
|
410
461
|
|
|
411
462
|
The SDK provides typed exception classes:
|
|
@@ -594,6 +645,24 @@ List sent messages.
|
|
|
594
645
|
|
|
595
646
|
Get a specific message by ID.
|
|
596
647
|
|
|
648
|
+
### `client.numbers`
|
|
649
|
+
|
|
650
|
+
#### `list_countries() -> NumberCountriesResponse`
|
|
651
|
+
|
|
652
|
+
List the countries where numbers can be searched and purchased.
|
|
653
|
+
|
|
654
|
+
#### `list_available(country, type, contains=None) -> AvailableNumbersResponse`
|
|
655
|
+
|
|
656
|
+
Search for available numbers, already customer-priced.
|
|
657
|
+
|
|
658
|
+
#### `list() -> OwnedNumbersResponse`
|
|
659
|
+
|
|
660
|
+
List the numbers the account already owns.
|
|
661
|
+
|
|
662
|
+
#### `buy(phone_number, country_code, phone_number_type, monthly_cost, action_code=None) -> BuyNumberResponse`
|
|
663
|
+
|
|
664
|
+
Buy a number. Returns `status` of `provisioning`, `documents_required`, or `payment_required`; when documents/payment are required, `action` carries a hosted page URL + code to hand to the user before re-calling with `action_code`.
|
|
665
|
+
|
|
597
666
|
## Enterprise
|
|
598
667
|
|
|
599
668
|
The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
|
|
@@ -371,6 +371,57 @@ print(f"New key: {result['key']}") # Only shown once!
|
|
|
371
371
|
client.account.revoke_api_key('key_xxx')
|
|
372
372
|
```
|
|
373
373
|
|
|
374
|
+
## Phone Numbers
|
|
375
|
+
|
|
376
|
+
Search for and buy phone numbers. Prices on available numbers are already
|
|
377
|
+
customer-priced. Requires an API key with the `numbers:read` / `numbers:write`
|
|
378
|
+
scopes.
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
# List the countries where numbers are available
|
|
382
|
+
countries = client.numbers.list_countries()
|
|
383
|
+
for country in countries.countries:
|
|
384
|
+
print(f'{country.code} {country.name}: {country.number_types}')
|
|
385
|
+
|
|
386
|
+
# Search for available numbers (already customer-priced)
|
|
387
|
+
available = client.numbers.list_available(country='GB', type='mobile')
|
|
388
|
+
for num in available.numbers:
|
|
389
|
+
print(f'{num.phone_number} — {num.monthly_cost} {num.currency}')
|
|
390
|
+
|
|
391
|
+
# Optionally filter by a digit pattern
|
|
392
|
+
available = client.numbers.list_available(country='GB', type='mobile', contains='777')
|
|
393
|
+
|
|
394
|
+
# List the numbers you already own
|
|
395
|
+
owned = client.numbers.list()
|
|
396
|
+
for num in owned.numbers:
|
|
397
|
+
print(f'{num.phone_number} ({num.status})')
|
|
398
|
+
|
|
399
|
+
# Buy a number
|
|
400
|
+
chosen = available.numbers[0]
|
|
401
|
+
result = client.numbers.buy(
|
|
402
|
+
phone_number=chosen.phone_number,
|
|
403
|
+
country_code='GB',
|
|
404
|
+
phone_number_type='mobile',
|
|
405
|
+
monthly_cost=chosen.monthly_cost,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if result.status == 'provisioning':
|
|
409
|
+
print(f'Provisioning {result.number.phone_number}')
|
|
410
|
+
elif result.status in ('documents_required', 'payment_required'):
|
|
411
|
+
# Hand the user the hosted Sendly page + code, wait for them to finish,
|
|
412
|
+
# then call buy() again with the same arguments plus action_code set to
|
|
413
|
+
# the completed action's code.
|
|
414
|
+
print(f'Open {result.action.url} and enter code {result.action.code}')
|
|
415
|
+
# ...later, after the user completes the page...
|
|
416
|
+
result = client.numbers.buy(
|
|
417
|
+
phone_number=chosen.phone_number,
|
|
418
|
+
country_code='GB',
|
|
419
|
+
phone_number_type='mobile',
|
|
420
|
+
monthly_cost=chosen.monthly_cost,
|
|
421
|
+
action_code=result.action.code,
|
|
422
|
+
)
|
|
423
|
+
```
|
|
424
|
+
|
|
374
425
|
## Error Handling
|
|
375
426
|
|
|
376
427
|
The SDK provides typed exception classes:
|
|
@@ -559,6 +610,24 @@ List sent messages.
|
|
|
559
610
|
|
|
560
611
|
Get a specific message by ID.
|
|
561
612
|
|
|
613
|
+
### `client.numbers`
|
|
614
|
+
|
|
615
|
+
#### `list_countries() -> NumberCountriesResponse`
|
|
616
|
+
|
|
617
|
+
List the countries where numbers can be searched and purchased.
|
|
618
|
+
|
|
619
|
+
#### `list_available(country, type, contains=None) -> AvailableNumbersResponse`
|
|
620
|
+
|
|
621
|
+
Search for available numbers, already customer-priced.
|
|
622
|
+
|
|
623
|
+
#### `list() -> OwnedNumbersResponse`
|
|
624
|
+
|
|
625
|
+
List the numbers the account already owns.
|
|
626
|
+
|
|
627
|
+
#### `buy(phone_number, country_code, phone_number_type, monthly_cost, action_code=None) -> BuyNumberResponse`
|
|
628
|
+
|
|
629
|
+
Buy a number. Returns `status` of `provisioning`, `documents_required`, or `payment_required`; when documents/payment are required, `action` carries a hosted page URL + code to hand to the user before re-calling with `action_code`.
|
|
630
|
+
|
|
562
631
|
## Enterprise
|
|
563
632
|
|
|
564
633
|
The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
|
|
@@ -29,7 +29,7 @@ Async Example:
|
|
|
29
29
|
>>> asyncio.run(main())
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "3.
|
|
32
|
+
__version__ = "3.33.0"
|
|
33
33
|
|
|
34
34
|
# Main clients
|
|
35
35
|
from .client import AsyncSendly, Sendly
|
|
@@ -52,6 +52,7 @@ from .resources.business_upgrade import (
|
|
|
52
52
|
BusinessUpgradeResource,
|
|
53
53
|
)
|
|
54
54
|
from .resources.media import AsyncMediaResource, MediaResource
|
|
55
|
+
from .resources.numbers import AsyncNumbersResource, NumbersResource
|
|
55
56
|
|
|
56
57
|
# Types
|
|
57
58
|
from .types import (
|
|
@@ -115,6 +116,16 @@ from .types import (
|
|
|
115
116
|
MessageAnalyticsDataPoint,
|
|
116
117
|
MessageListResponse,
|
|
117
118
|
MessageStatus,
|
|
119
|
+
# Numbers types
|
|
120
|
+
AvailableNumber,
|
|
121
|
+
AvailableNumbersResponse,
|
|
122
|
+
BuyNumberRef,
|
|
123
|
+
BuyNumberResponse,
|
|
124
|
+
NumberAction,
|
|
125
|
+
NumberCountriesResponse,
|
|
126
|
+
NumberCountry,
|
|
127
|
+
OwnedNumber,
|
|
128
|
+
OwnedNumbersResponse,
|
|
118
129
|
OptInPage,
|
|
119
130
|
PricingTier,
|
|
120
131
|
QuotaSettings,
|
|
@@ -251,9 +262,22 @@ __all__ = [
|
|
|
251
262
|
"CreateLabelRequest",
|
|
252
263
|
"Label",
|
|
253
264
|
"LabelListResponse",
|
|
265
|
+
# Numbers types
|
|
266
|
+
"AvailableNumber",
|
|
267
|
+
"AvailableNumbersResponse",
|
|
268
|
+
"BuyNumberRef",
|
|
269
|
+
"BuyNumberResponse",
|
|
270
|
+
"NumberAction",
|
|
271
|
+
"NumberCountriesResponse",
|
|
272
|
+
"NumberCountry",
|
|
273
|
+
"OwnedNumber",
|
|
274
|
+
"OwnedNumbersResponse",
|
|
254
275
|
# Media resources
|
|
255
276
|
"MediaResource",
|
|
256
277
|
"AsyncMediaResource",
|
|
278
|
+
# Numbers resources
|
|
279
|
+
"NumbersResource",
|
|
280
|
+
"AsyncNumbersResource",
|
|
257
281
|
# Business upgrade resources
|
|
258
282
|
"BusinessUpgradeResource",
|
|
259
283
|
"AsyncBusinessUpgradeResource",
|
|
@@ -19,6 +19,7 @@ from .resources.enterprise import AsyncEnterpriseResource, EnterpriseResource
|
|
|
19
19
|
from .resources.labels import AsyncLabelsResource, LabelsResource
|
|
20
20
|
from .resources.media import AsyncMediaResource, MediaResource
|
|
21
21
|
from .resources.messages import AsyncMessagesResource, MessagesResource
|
|
22
|
+
from .resources.numbers import AsyncNumbersResource, NumbersResource
|
|
22
23
|
from .resources.rules import AsyncRulesResource, RulesResource
|
|
23
24
|
from .resources.templates import AsyncTemplatesResource, TemplatesResource
|
|
24
25
|
from .resources.verify import AsyncVerifyResource, VerifyResource
|
|
@@ -117,6 +118,7 @@ class Sendly:
|
|
|
117
118
|
self.campaigns = CampaignsResource(self._http)
|
|
118
119
|
self.contacts = ContactsResource(self._http)
|
|
119
120
|
self.media = MediaResource(self._http)
|
|
121
|
+
self.numbers = NumbersResource(self._http)
|
|
120
122
|
self.enterprise = EnterpriseResource(self._http)
|
|
121
123
|
self.labels = LabelsResource(self._http)
|
|
122
124
|
self.drafts = DraftsResource(self._http)
|
|
@@ -255,6 +257,7 @@ class AsyncSendly:
|
|
|
255
257
|
self.campaigns = AsyncCampaignsResource(self._http)
|
|
256
258
|
self.contacts = AsyncContactsResource(self._http)
|
|
257
259
|
self.media = AsyncMediaResource(self._http)
|
|
260
|
+
self.numbers = AsyncNumbersResource(self._http)
|
|
258
261
|
self.enterprise = AsyncEnterpriseResource(self._http)
|
|
259
262
|
self.labels = AsyncLabelsResource(self._http)
|
|
260
263
|
self.drafts = AsyncDraftsResource(self._http)
|
|
@@ -8,6 +8,7 @@ from .enterprise import AsyncEnterpriseResource, EnterpriseResource
|
|
|
8
8
|
from .labels import AsyncLabelsResource, LabelsResource
|
|
9
9
|
from .media import AsyncMediaResource, MediaResource
|
|
10
10
|
from .messages import AsyncMessagesResource, MessagesResource
|
|
11
|
+
from .numbers import AsyncNumbersResource, NumbersResource
|
|
11
12
|
from .rules import AsyncRulesResource, RulesResource
|
|
12
13
|
from .templates import AsyncTemplatesResource, TemplatesResource
|
|
13
14
|
from .verify import AsyncVerifyResource, VerifyResource
|
|
@@ -29,6 +30,8 @@ __all__ = [
|
|
|
29
30
|
"AsyncMediaResource",
|
|
30
31
|
"MessagesResource",
|
|
31
32
|
"AsyncMessagesResource",
|
|
33
|
+
"NumbersResource",
|
|
34
|
+
"AsyncNumbersResource",
|
|
32
35
|
"VerifyResource",
|
|
33
36
|
"AsyncVerifyResource",
|
|
34
37
|
"TemplatesResource",
|
|
@@ -54,7 +54,7 @@ class CampaignsResource:
|
|
|
54
54
|
if template_id:
|
|
55
55
|
body["templateId"] = template_id
|
|
56
56
|
|
|
57
|
-
data = self._http.request("POST", "/campaigns",
|
|
57
|
+
data = self._http.request("POST", "/campaigns", body=body)
|
|
58
58
|
return self._transform_campaign(data)
|
|
59
59
|
|
|
60
60
|
def list(
|
|
@@ -114,7 +114,7 @@ class CampaignsResource:
|
|
|
114
114
|
if contact_list_ids is not None:
|
|
115
115
|
body["contactListIds"] = contact_list_ids
|
|
116
116
|
|
|
117
|
-
data = self._http.request("PATCH", f"/campaigns/{campaign_id}",
|
|
117
|
+
data = self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
|
|
118
118
|
return self._transform_campaign(data)
|
|
119
119
|
|
|
120
120
|
def delete(self, campaign_id: str) -> None:
|
|
@@ -159,7 +159,7 @@ class CampaignsResource:
|
|
|
159
159
|
if timezone:
|
|
160
160
|
body["timezone"] = timezone
|
|
161
161
|
|
|
162
|
-
data = self._http.request("POST", f"/campaigns/{campaign_id}/schedule",
|
|
162
|
+
data = self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
|
|
163
163
|
return self._transform_campaign(data)
|
|
164
164
|
|
|
165
165
|
def cancel(self, campaign_id: str) -> Campaign:
|
|
@@ -217,7 +217,7 @@ class AsyncCampaignsResource:
|
|
|
217
217
|
if template_id:
|
|
218
218
|
body["templateId"] = template_id
|
|
219
219
|
|
|
220
|
-
data = await self._http.request("POST", "/campaigns",
|
|
220
|
+
data = await self._http.request("POST", "/campaigns", body=body)
|
|
221
221
|
return self._transform_campaign(data)
|
|
222
222
|
|
|
223
223
|
async def list(
|
|
@@ -268,7 +268,7 @@ class AsyncCampaignsResource:
|
|
|
268
268
|
if contact_list_ids is not None:
|
|
269
269
|
body["contactListIds"] = contact_list_ids
|
|
270
270
|
|
|
271
|
-
data = await self._http.request("PATCH", f"/campaigns/{campaign_id}",
|
|
271
|
+
data = await self._http.request("PATCH", f"/campaigns/{campaign_id}", body=body)
|
|
272
272
|
return self._transform_campaign(data)
|
|
273
273
|
|
|
274
274
|
async def delete(self, campaign_id: str) -> None:
|
|
@@ -304,7 +304,7 @@ class AsyncCampaignsResource:
|
|
|
304
304
|
if timezone:
|
|
305
305
|
body["timezone"] = timezone
|
|
306
306
|
|
|
307
|
-
data = await self._http.request("POST", f"/campaigns/{campaign_id}/schedule",
|
|
307
|
+
data = await self._http.request("POST", f"/campaigns/{campaign_id}/schedule", body=body)
|
|
308
308
|
return self._transform_campaign(data)
|
|
309
309
|
|
|
310
310
|
async def cancel(self, campaign_id: str) -> Campaign:
|
|
@@ -51,7 +51,7 @@ class ContactListsResource:
|
|
|
51
51
|
if description:
|
|
52
52
|
body["description"] = description
|
|
53
53
|
|
|
54
|
-
data = self._http.request("POST", "/contact-lists",
|
|
54
|
+
data = self._http.request("POST", "/contact-lists", body=body)
|
|
55
55
|
return self._transform_list(data)
|
|
56
56
|
|
|
57
57
|
def update(
|
|
@@ -68,7 +68,7 @@ class ContactListsResource:
|
|
|
68
68
|
if description is not None:
|
|
69
69
|
body["description"] = description
|
|
70
70
|
|
|
71
|
-
data = self._http.request("PATCH", f"/contact-lists/{list_id}",
|
|
71
|
+
data = self._http.request("PATCH", f"/contact-lists/{list_id}", body=body)
|
|
72
72
|
return self._transform_list(data)
|
|
73
73
|
|
|
74
74
|
def delete(self, list_id: str) -> None:
|
|
@@ -84,7 +84,7 @@ class ContactListsResource:
|
|
|
84
84
|
data = self._http.request(
|
|
85
85
|
"POST",
|
|
86
86
|
f"/contact-lists/{list_id}/contacts",
|
|
87
|
-
|
|
87
|
+
body={"contact_ids": contact_ids},
|
|
88
88
|
)
|
|
89
89
|
return {"added_count": data["added_count"]}
|
|
90
90
|
|
|
@@ -194,7 +194,7 @@ class ContactsResource:
|
|
|
194
194
|
if metadata:
|
|
195
195
|
body["metadata"] = metadata
|
|
196
196
|
|
|
197
|
-
data = self._http.request("POST", "/contacts",
|
|
197
|
+
data = self._http.request("POST", "/contacts", body=body)
|
|
198
198
|
return self._transform_contact(data)
|
|
199
199
|
|
|
200
200
|
def update(
|
|
@@ -214,7 +214,7 @@ class ContactsResource:
|
|
|
214
214
|
if metadata is not None:
|
|
215
215
|
body["metadata"] = metadata
|
|
216
216
|
|
|
217
|
-
data = self._http.request("PATCH", f"/contacts/{contact_id}",
|
|
217
|
+
data = self._http.request("PATCH", f"/contacts/{contact_id}", body=body)
|
|
218
218
|
return self._transform_contact(data)
|
|
219
219
|
|
|
220
220
|
def delete(self, contact_id: str) -> None:
|
|
@@ -252,7 +252,7 @@ class ContactsResource:
|
|
|
252
252
|
if ids and list_id:
|
|
253
253
|
raise ValueError("bulk_mark_valid accepts 'ids' OR 'list_id', not both")
|
|
254
254
|
body: Dict[str, Any] = {"ids": ids} if ids else {"listId": list_id}
|
|
255
|
-
data = self._http.request("POST", "/contacts/bulk-mark-valid",
|
|
255
|
+
data = self._http.request("POST", "/contacts/bulk-mark-valid", body=body)
|
|
256
256
|
return BulkMarkValidResponse(cleared=data.get("cleared", 0))
|
|
257
257
|
|
|
258
258
|
def check_numbers(
|
|
@@ -279,7 +279,7 @@ class ContactsResource:
|
|
|
279
279
|
return self._http.request(
|
|
280
280
|
"POST",
|
|
281
281
|
"/contacts/lookup",
|
|
282
|
-
|
|
282
|
+
body={"listId": list_id, "force": force},
|
|
283
283
|
)
|
|
284
284
|
|
|
285
285
|
def import_contacts(
|
|
@@ -304,7 +304,7 @@ class ContactsResource:
|
|
|
304
304
|
if opted_in_at:
|
|
305
305
|
body["optedInAt"] = opted_in_at
|
|
306
306
|
|
|
307
|
-
data = self._http.request("POST", "/contacts/import",
|
|
307
|
+
data = self._http.request("POST", "/contacts/import", body=body)
|
|
308
308
|
return ImportContactsResponse(
|
|
309
309
|
imported=data["imported"],
|
|
310
310
|
skipped_duplicates=data["skippedDuplicates"],
|
|
@@ -379,7 +379,7 @@ class AsyncContactListsResource:
|
|
|
379
379
|
if description:
|
|
380
380
|
body["description"] = description
|
|
381
381
|
|
|
382
|
-
data = await self._http.request("POST", "/contact-lists",
|
|
382
|
+
data = await self._http.request("POST", "/contact-lists", body=body)
|
|
383
383
|
return self._transform_list(data)
|
|
384
384
|
|
|
385
385
|
async def update(
|
|
@@ -396,7 +396,7 @@ class AsyncContactListsResource:
|
|
|
396
396
|
if description is not None:
|
|
397
397
|
body["description"] = description
|
|
398
398
|
|
|
399
|
-
data = await self._http.request("PATCH", f"/contact-lists/{list_id}",
|
|
399
|
+
data = await self._http.request("PATCH", f"/contact-lists/{list_id}", body=body)
|
|
400
400
|
return self._transform_list(data)
|
|
401
401
|
|
|
402
402
|
async def delete(self, list_id: str) -> None:
|
|
@@ -408,7 +408,7 @@ class AsyncContactListsResource:
|
|
|
408
408
|
data = await self._http.request(
|
|
409
409
|
"POST",
|
|
410
410
|
f"/contact-lists/{list_id}/contacts",
|
|
411
|
-
|
|
411
|
+
body={"contact_ids": contact_ids},
|
|
412
412
|
)
|
|
413
413
|
return {"added_count": data["added_count"]}
|
|
414
414
|
|
|
@@ -495,7 +495,7 @@ class AsyncContactsResource:
|
|
|
495
495
|
if metadata:
|
|
496
496
|
body["metadata"] = metadata
|
|
497
497
|
|
|
498
|
-
data = await self._http.request("POST", "/contacts",
|
|
498
|
+
data = await self._http.request("POST", "/contacts", body=body)
|
|
499
499
|
return self._transform_contact(data)
|
|
500
500
|
|
|
501
501
|
async def update(
|
|
@@ -515,7 +515,7 @@ class AsyncContactsResource:
|
|
|
515
515
|
if metadata is not None:
|
|
516
516
|
body["metadata"] = metadata
|
|
517
517
|
|
|
518
|
-
data = await self._http.request("PATCH", f"/contacts/{contact_id}",
|
|
518
|
+
data = await self._http.request("PATCH", f"/contacts/{contact_id}", body=body)
|
|
519
519
|
return self._transform_contact(data)
|
|
520
520
|
|
|
521
521
|
async def delete(self, contact_id: str) -> None:
|
|
@@ -544,7 +544,7 @@ class AsyncContactsResource:
|
|
|
544
544
|
raise ValueError("bulk_mark_valid accepts 'ids' OR 'list_id', not both")
|
|
545
545
|
body: Dict[str, Any] = {"ids": ids} if ids else {"listId": list_id}
|
|
546
546
|
data = await self._http.request(
|
|
547
|
-
"POST", "/contacts/bulk-mark-valid",
|
|
547
|
+
"POST", "/contacts/bulk-mark-valid", body=body
|
|
548
548
|
)
|
|
549
549
|
return BulkMarkValidResponse(cleared=data.get("cleared", 0))
|
|
550
550
|
|
|
@@ -562,7 +562,7 @@ class AsyncContactsResource:
|
|
|
562
562
|
return await self._http.request(
|
|
563
563
|
"POST",
|
|
564
564
|
"/contacts/lookup",
|
|
565
|
-
|
|
565
|
+
body={"listId": list_id, "force": force},
|
|
566
566
|
)
|
|
567
567
|
|
|
568
568
|
async def import_contacts(
|
|
@@ -587,7 +587,7 @@ class AsyncContactsResource:
|
|
|
587
587
|
if opted_in_at:
|
|
588
588
|
body["optedInAt"] = opted_in_at
|
|
589
589
|
|
|
590
|
-
data = await self._http.request("POST", "/contacts/import",
|
|
590
|
+
data = await self._http.request("POST", "/contacts/import", body=body)
|
|
591
591
|
return ImportContactsResponse(
|
|
592
592
|
imported=data["imported"],
|
|
593
593
|
skipped_duplicates=data["skippedDuplicates"],
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Numbers Resource - Search, list, and buy phone numbers
|
|
3
|
+
|
|
4
|
+
Exposes the public numbers API: discover the countries Sendly sells numbers
|
|
5
|
+
in, search for available numbers, list the numbers you already own, and buy a
|
|
6
|
+
number. Pricing on available numbers is already customer-priced.
|
|
7
|
+
|
|
8
|
+
When a purchase needs documents or payment, ``buy()`` returns a response whose
|
|
9
|
+
``status`` is ``documents_required`` or ``payment_required`` and whose
|
|
10
|
+
``action`` carries a hosted Sendly page (``url``) plus a short ``code``. Hand
|
|
11
|
+
the user the URL + code; once they complete the page, call ``buy()`` again with
|
|
12
|
+
the SAME arguments PLUS ``action_code`` set to that code. Polling the action to
|
|
13
|
+
completion is the CLI's job, not the SDK's.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
19
|
+
|
|
20
|
+
from ..errors import SendlyError
|
|
21
|
+
from ..types import (
|
|
22
|
+
AvailableNumbersResponse,
|
|
23
|
+
BuyNumberResponse,
|
|
24
|
+
NumberCountriesResponse,
|
|
25
|
+
OwnedNumbersResponse,
|
|
26
|
+
)
|
|
27
|
+
from ..utils.http import AsyncHttpClient, HttpClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NumbersResource:
|
|
31
|
+
"""Numbers API resource (sync)
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> countries = client.numbers.list_countries()
|
|
35
|
+
>>> available = client.numbers.list_available(country='GB', type='mobile')
|
|
36
|
+
>>> result = client.numbers.buy(
|
|
37
|
+
... phone_number=available.numbers[0].phone_number,
|
|
38
|
+
... country_code='GB',
|
|
39
|
+
... phone_number_type='mobile',
|
|
40
|
+
... monthly_cost=available.numbers[0].monthly_cost,
|
|
41
|
+
... )
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, http: HttpClient):
|
|
45
|
+
self._http = http
|
|
46
|
+
|
|
47
|
+
def list_countries(self) -> NumberCountriesResponse:
|
|
48
|
+
"""List the countries where numbers can be searched and purchased."""
|
|
49
|
+
data = self._http.request(method="GET", path="/numbers/countries")
|
|
50
|
+
try:
|
|
51
|
+
return NumberCountriesResponse(**data)
|
|
52
|
+
except PydanticValidationError as e:
|
|
53
|
+
raise _invalid_response(e) from e
|
|
54
|
+
|
|
55
|
+
def list_available(
|
|
56
|
+
self,
|
|
57
|
+
country: str,
|
|
58
|
+
type: str,
|
|
59
|
+
contains: Optional[str] = None,
|
|
60
|
+
) -> AvailableNumbersResponse:
|
|
61
|
+
"""Search for available numbers, already priced for the customer.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
country: ISO 3166-1 alpha-2 country code (e.g. ``GB``).
|
|
65
|
+
type: Number type to search for (e.g. ``mobile``).
|
|
66
|
+
contains: Optional digit pattern the number should contain.
|
|
67
|
+
"""
|
|
68
|
+
params: Dict[str, Any] = {"country": country, "type": type}
|
|
69
|
+
if contains is not None:
|
|
70
|
+
params["contains"] = contains
|
|
71
|
+
|
|
72
|
+
data = self._http.request(method="GET", path="/numbers/available", params=params)
|
|
73
|
+
try:
|
|
74
|
+
return AvailableNumbersResponse(**data)
|
|
75
|
+
except PydanticValidationError as e:
|
|
76
|
+
raise _invalid_response(e) from e
|
|
77
|
+
|
|
78
|
+
def list(self) -> OwnedNumbersResponse:
|
|
79
|
+
"""List the numbers the account already owns."""
|
|
80
|
+
data = self._http.request(method="GET", path="/numbers")
|
|
81
|
+
try:
|
|
82
|
+
return OwnedNumbersResponse(**data)
|
|
83
|
+
except PydanticValidationError as e:
|
|
84
|
+
raise _invalid_response(e) from e
|
|
85
|
+
|
|
86
|
+
def buy(
|
|
87
|
+
self,
|
|
88
|
+
phone_number: str,
|
|
89
|
+
country_code: str,
|
|
90
|
+
phone_number_type: str,
|
|
91
|
+
monthly_cost: str,
|
|
92
|
+
action_code: Optional[str] = None,
|
|
93
|
+
) -> BuyNumberResponse:
|
|
94
|
+
"""Buy a number.
|
|
95
|
+
|
|
96
|
+
Returns a response whose ``status`` is ``provisioning``,
|
|
97
|
+
``documents_required``, or ``payment_required``. When documents or
|
|
98
|
+
payment are required, the ``action`` field carries a hosted Sendly page
|
|
99
|
+
URL + a short code; hand them to the user, then call ``buy()`` again
|
|
100
|
+
with the same arguments plus ``action_code`` set to the code of the
|
|
101
|
+
completed action.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
phone_number: Number to buy, in E.164 format.
|
|
105
|
+
country_code: ISO 3166-1 alpha-2 country code.
|
|
106
|
+
phone_number_type: Number type (e.g. ``mobile``).
|
|
107
|
+
monthly_cost: Monthly cost as returned by :meth:`list_available`.
|
|
108
|
+
action_code: Code from a completed documents/payment action.
|
|
109
|
+
"""
|
|
110
|
+
body: Dict[str, Any] = {
|
|
111
|
+
"phoneNumber": phone_number,
|
|
112
|
+
"countryCode": country_code,
|
|
113
|
+
"phoneNumberType": phone_number_type,
|
|
114
|
+
"monthlyCost": monthly_cost,
|
|
115
|
+
}
|
|
116
|
+
if action_code is not None:
|
|
117
|
+
body["actionCode"] = action_code
|
|
118
|
+
|
|
119
|
+
data = self._http.request(method="POST", path="/numbers/buy", body=body)
|
|
120
|
+
try:
|
|
121
|
+
return BuyNumberResponse(**data)
|
|
122
|
+
except PydanticValidationError as e:
|
|
123
|
+
raise _invalid_response(e) from e
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AsyncNumbersResource:
|
|
127
|
+
"""Numbers API resource (async)"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, http: AsyncHttpClient):
|
|
130
|
+
self._http = http
|
|
131
|
+
|
|
132
|
+
async def list_countries(self) -> NumberCountriesResponse:
|
|
133
|
+
"""List the countries where numbers can be searched and purchased."""
|
|
134
|
+
data = await self._http.request(method="GET", path="/numbers/countries")
|
|
135
|
+
try:
|
|
136
|
+
return NumberCountriesResponse(**data)
|
|
137
|
+
except PydanticValidationError as e:
|
|
138
|
+
raise _invalid_response(e) from e
|
|
139
|
+
|
|
140
|
+
async def list_available(
|
|
141
|
+
self,
|
|
142
|
+
country: str,
|
|
143
|
+
type: str,
|
|
144
|
+
contains: Optional[str] = None,
|
|
145
|
+
) -> AvailableNumbersResponse:
|
|
146
|
+
"""Search for available numbers, already priced for the customer."""
|
|
147
|
+
params: Dict[str, Any] = {"country": country, "type": type}
|
|
148
|
+
if contains is not None:
|
|
149
|
+
params["contains"] = contains
|
|
150
|
+
|
|
151
|
+
data = await self._http.request(method="GET", path="/numbers/available", params=params)
|
|
152
|
+
try:
|
|
153
|
+
return AvailableNumbersResponse(**data)
|
|
154
|
+
except PydanticValidationError as e:
|
|
155
|
+
raise _invalid_response(e) from e
|
|
156
|
+
|
|
157
|
+
async def list(self) -> OwnedNumbersResponse:
|
|
158
|
+
"""List the numbers the account already owns."""
|
|
159
|
+
data = await self._http.request(method="GET", path="/numbers")
|
|
160
|
+
try:
|
|
161
|
+
return OwnedNumbersResponse(**data)
|
|
162
|
+
except PydanticValidationError as e:
|
|
163
|
+
raise _invalid_response(e) from e
|
|
164
|
+
|
|
165
|
+
async def buy(
|
|
166
|
+
self,
|
|
167
|
+
phone_number: str,
|
|
168
|
+
country_code: str,
|
|
169
|
+
phone_number_type: str,
|
|
170
|
+
monthly_cost: str,
|
|
171
|
+
action_code: Optional[str] = None,
|
|
172
|
+
) -> BuyNumberResponse:
|
|
173
|
+
"""Buy a number. See :meth:`NumbersResource.buy`."""
|
|
174
|
+
body: Dict[str, Any] = {
|
|
175
|
+
"phoneNumber": phone_number,
|
|
176
|
+
"countryCode": country_code,
|
|
177
|
+
"phoneNumberType": phone_number_type,
|
|
178
|
+
"monthlyCost": monthly_cost,
|
|
179
|
+
}
|
|
180
|
+
if action_code is not None:
|
|
181
|
+
body["actionCode"] = action_code
|
|
182
|
+
|
|
183
|
+
data = await self._http.request(method="POST", path="/numbers/buy", body=body)
|
|
184
|
+
try:
|
|
185
|
+
return BuyNumberResponse(**data)
|
|
186
|
+
except PydanticValidationError as e:
|
|
187
|
+
raise _invalid_response(e) from e
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _invalid_response(e: PydanticValidationError) -> SendlyError:
|
|
191
|
+
"""Wrap a pydantic schema error as a SendlyError, matching the SDK's idiom."""
|
|
192
|
+
return SendlyError(
|
|
193
|
+
message=f"Invalid API response format: {e}",
|
|
194
|
+
code="invalid_response",
|
|
195
|
+
status_code=200,
|
|
196
|
+
)
|