From f35530f95808a24c2b0ae2b6d672bc209f3a1af9 Mon Sep 17 00:00:00 2001 From: Koushik SK Date: Wed, 8 Apr 2026 16:25:56 +0530 Subject: [PATCH 1/3] feat: add PhoneNumber Compliance API support Add new regulatory compliance APIs under PhoneNumber/Compliance/ path: - Requirements API (GET) for discovering compliance requirements - Compliance Applications API (POST, GET, LIST, PATCH, DELETE) - Number Linking API (POST) for bulk linking numbers to applications Includes multipart form-data support for document file uploads with indexed file fields (documents[N].file) and JSON data field. Co-Authored-By: Claude Opus 4.6 (1M context) --- plivo/resources/__init__.py | 1 + plivo/resources/phone_number_compliance.py | 119 +++++++++++++++++++++ plivo/rest/client.py | 4 + 3 files changed, 124 insertions(+) create mode 100644 plivo/resources/phone_number_compliance.py diff --git a/plivo/resources/__init__.py b/plivo/resources/__init__.py index 49fa1d62..7aa9127a 100644 --- a/plivo/resources/__init__.py +++ b/plivo/resources/__init__.py @@ -22,3 +22,4 @@ from .multipartycall import MultiPartyCalls, MultiPartyCall, MultiPartyCallParticipant from .verify import Sessions from .tollfree_verification import TollfreeVerifications +from .phone_number_compliance import PhoneNumberComplianceRequirements, PhoneNumberComplianceApplications, PhoneNumberComplianceLink diff --git a/plivo/resources/phone_number_compliance.py b/plivo/resources/phone_number_compliance.py new file mode 100644 index 00000000..272a9d04 --- /dev/null +++ b/plivo/resources/phone_number_compliance.py @@ -0,0 +1,119 @@ +import json +import os +from plivo.base import PlivoResource, PlivoResourceInterface + + +class PhoneNumberComplianceRequirement(PlivoResource): + _name = 'PhoneNumberComplianceRequirement' + _identifier_string = 'requirement_id' + + +class PhoneNumberComplianceRequirements(PlivoResourceInterface): + _resource_type = PhoneNumberComplianceRequirement + + def get(self, country_iso=None, number_type=None, user_type=None): + # GET /PhoneNumber/Compliance/Requirements + return self.client.request( + 'GET', + ('PhoneNumber', 'Compliance', 'Requirements'), + dict(country_iso=country_iso, number_type=number_type, user_type=user_type) + ) + + +class PhoneNumberCompliance(PlivoResource): + _name = 'PhoneNumberCompliance' + _identifier_string = 'compliance_id' + + +class PhoneNumberComplianceApplications(PlivoResourceInterface): + _resource_type = PhoneNumberCompliance + + def create(self, data=None, documents=None): + """ + data: dict with keys country_iso, number_type, alias, end_user, documents, callback_url, callback_method + documents: list of local file paths for document uploads + """ + payload, files = _build_compliance_multipart(data, documents) + return self.client.request( + 'POST', + ('PhoneNumber', 'Compliance'), + payload, + files=files + ) + + def list(self, limit=None, offset=None, status=None, country_iso=None, + number_type=None, user_type=None, alias=None, expand=None): + params = {} + if limit is not None: + params['limit'] = limit + if offset is not None: + params['offset'] = offset + if status: + params['status'] = status + if country_iso: + params['country_iso'] = country_iso + if number_type: + params['number_type'] = number_type + if user_type: + params['user_type'] = user_type + if alias: + params['alias'] = alias + if expand: + params['expand'] = expand + return self.client.request( + 'GET', + ('PhoneNumber', 'Compliance'), + params + ) + + def get(self, compliance_id, expand=None): + params = {} + if expand: + params['expand'] = expand + return self.client.request( + 'GET', + ('PhoneNumber', 'Compliance', compliance_id), + params + ) + + def update(self, compliance_id, data=None, documents=None): + payload, files = _build_compliance_multipart(data, documents) + return self.client.request( + 'PATCH', + ('PhoneNumber', 'Compliance', compliance_id), + payload, + files=files + ) + + def delete(self, compliance_id): + return self.client.request( + 'DELETE', + ('PhoneNumber', 'Compliance', compliance_id) + ) + + +class PhoneNumberComplianceLink(PlivoResourceInterface): + + def link(self, numbers=None): + """ + numbers: list of dicts, each with 'number' and 'compliance_application_id' + """ + return self.client.request( + 'POST', + ('PhoneNumber', 'Compliance', 'Link'), + dict(numbers=numbers) + ) + + +def _build_compliance_multipart(data, documents): + payload = {} + files = {} + if data: + payload['data'] = json.dumps(data) + if documents: + for idx, doc_path in enumerate(documents): + field_name = 'documents[{}].file'.format(idx) + files[field_name] = (os.path.basename(doc_path), open(doc_path, 'rb')) + if not files: + files = None + return payload, files diff --git a/plivo/rest/client.py b/plivo/rest/client.py index 7b79b344..842e155a 100644 --- a/plivo/rest/client.py +++ b/plivo/rest/client.py @@ -24,6 +24,7 @@ from plivo.resources.queued_calls import QueuedCalls from plivo.resources.regulatory_compliance import EndUsers, ComplianceDocumentTypes, ComplianceDocuments, \ ComplianceRequirements, ComplianceApplications +from plivo.resources.phone_number_compliance import PhoneNumberComplianceRequirements, PhoneNumberComplianceApplications, PhoneNumberComplianceLink from plivo.utils import is_valid_mainaccount, is_valid_subaccount from plivo.version import __version__ from requests import Request, Session @@ -127,6 +128,9 @@ def __init__(self, auth_id=None, auth_token=None, proxies=None, timeout=5): self.verify_session = Sessions(self) self.verify_callerids = VerifyCallerids(self) self.transcriptions = Transcriptions(self) + self.phone_number_compliance_requirements = PhoneNumberComplianceRequirements(self) + self.phone_number_compliance = PhoneNumberComplianceApplications(self) + self.phone_number_compliance_link = PhoneNumberComplianceLink(self) def __enter__(self): From a4242ba32ac4718dadfd93b03d86ac0b7caaa616 Mon Sep 17 00:00:00 2001 From: Koushik SK Date: Wed, 8 Apr 2026 16:31:21 +0530 Subject: [PATCH 2/3] chore: bump version to 4.60.0 and update changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 +++++++ plivo/version.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07c3526e..1e9ccb8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Change Log +## [4.60.0](https://github.com/plivo/plivo-python/tree/v4.60.0) (2026-04-08) +**Feature - PhoneNumber Compliance API support** +- Added `phone_number_compliance_requirements` resource for discovering compliance requirements by country, number type, and user type +- Added `phone_number_compliance` resource with full CRUD support (create, get, list, update, delete) for compliance applications +- Added `phone_number_compliance_link` resource for bulk linking phone numbers to accepted compliance applications +- Create and update operations support multipart file uploads for compliance documents + ## [4.59.6](https://github.com/plivo/plivo-python/tree/v4.59.6) (2026-02-18) **Feature - Campaign API optional fields support** - Added `sample3`, `sample4`, `sample5` optional sample message fields to Campaign `create` and `update` methods diff --git a/plivo/version.py b/plivo/version.py index 5735124a..9d187116 100644 --- a/plivo/version.py +++ b/plivo/version.py @@ -1,2 +1,2 @@ # -*- coding: utf-8 -*- -__version__ = '4.59.5' +__version__ = '4.60.0' diff --git a/setup.py b/setup.py index face7956..3c2ab227 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='plivo', - version='4.59.6', + version='4.60.0', description='A Python SDK to make voice calls & send SMS using Plivo and to generate Plivo XML', long_description=long_description, url='https://github.com/plivo/plivo-python', From 4a507c88ed739392f1ae204b3fc9d21ffd417277 Mon Sep 17 00:00:00 2001 From: Koushik SK Date: Mon, 13 Apr 2026 15:52:53 +0530 Subject: [PATCH 3/3] test: add unit tests for PhoneNumber Compliance API Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/test_phone_number_compliance.py | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 tests/resources/test_phone_number_compliance.py diff --git a/tests/resources/test_phone_number_compliance.py b/tests/resources/test_phone_number_compliance.py new file mode 100644 index 00000000..b5e16a57 --- /dev/null +++ b/tests/resources/test_phone_number_compliance.py @@ -0,0 +1,478 @@ +# -*- coding: utf-8 -*- + +from plivo import exceptions +from tests.base import PlivoResourceTestCase + + +class PhoneNumberComplianceRequirementsTest(PlivoResourceTestCase): + + def test_get_requirements(self): + expected_response = { + 'requirement_id': 'req_123', + 'country_iso': 'DE', + 'number_type': 'local', + 'user_type': 'business', + 'document_types': [ + { + 'document_type': 'national_id', + 'description': 'National ID Card' + }, + { + 'document_type': 'utility_bill', + 'description': 'Utility Bill' + } + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance_requirements.get( + country_iso='DE', number_type='local', user_type='business') + + self.assertEqual( + self.client.current_request.url, + 'https://api.plivo.com/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/Requirements/?country_iso=DE&number_type=local&user_type=business') + self.assertEqual(self.client.current_request.method, 'GET') + self.assertEqual(response.requirement_id, expected_response['requirement_id']) + + def test_get_requirements_url_path(self): + expected_response = { + 'requirement_id': 'req_456', + 'document_types': [] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + self.client.phone_number_compliance_requirements.get( + country_iso='US') + + # Verify the URL contains the correct path segments + self.assertIn( + '/PhoneNumber/Compliance/Requirements/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'GET') + + def test_get_requirements_empty_document_types(self): + expected_response = { + 'requirement_id': 'req_789', + 'country_iso': 'US', + 'number_type': 'local', + 'document_types': [] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance_requirements.get( + country_iso='US', number_type='local') + + self.assertEqual(self.client.current_request.method, 'GET') + self.assertEqual(len(response.document_types), 0) + + +class PhoneNumberComplianceApplicationsTest(PlivoResourceTestCase): + + def test_create(self): + expected_response = { + 'compliance_id': 'comp_abc123', + 'message': 'Compliance application created successfully' + } + self.client.set_expected_response( + status_code=201, data_to_return=expected_response) + + response = self.client.phone_number_compliance.create( + data={ + 'country_iso': 'DE', + 'number_type': 'local', + 'alias': 'My German Number', + 'end_user': {'name': 'Test User'}, + }) + + self.assertIn( + '/PhoneNumber/Compliance/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'POST') + self.assertEqual(response.compliance_id, expected_response['compliance_id']) + self.assertEqual(response.message, expected_response['message']) + + def test_create_multipart_data_structure(self): + expected_response = { + 'compliance_id': 'comp_def456', + 'message': 'created' + } + self.client.set_expected_response( + status_code=201, data_to_return=expected_response) + + data = { + 'country_iso': 'GB', + 'number_type': 'mobile', + 'alias': 'UK Mobile', + 'end_user': {'name': 'Jane Doe'}, + 'callback_url': 'https://example.com/callback', + 'callback_method': 'POST', + } + response = self.client.phone_number_compliance.create(data=data) + + self.assertEqual(self.client.current_request.method, 'POST') + self.assertEqual(response.compliance_id, 'comp_def456') + + def test_list(self): + expected_response = { + 'meta': { + 'limit': 20, + 'offset': 0, + 'next': '/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/?limit=20&offset=20', + 'previous': None, + 'total_count': 2 + }, + 'compliances': [ + { + 'compliance_id': 'comp_001', + 'status': 'approved', + 'country_iso': 'DE', + 'alias': 'German Line' + }, + { + 'compliance_id': 'comp_002', + 'status': 'pending', + 'country_iso': 'GB', + 'alias': 'UK Line' + } + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.list() + + self.assertIn( + '/PhoneNumber/Compliance/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'GET') + self.assertEqual(len(response.compliances), 2) + + def test_list_with_filters(self): + expected_response = { + 'meta': { + 'limit': 10, + 'offset': 0, + 'next': None, + 'previous': None, + 'total_count': 1 + }, + 'compliances': [ + { + 'compliance_id': 'comp_001', + 'status': 'approved', + 'country_iso': 'DE' + } + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.list( + status='approved', country_iso='DE', limit=10, offset=0) + + url = self.client.current_request.url + self.assertIn('status=approved', url) + self.assertIn('country_iso=DE', url) + self.assertIn('limit=10', url) + self.assertIn('offset=0', url) + self.assertEqual(self.client.current_request.method, 'GET') + + def test_list_empty(self): + expected_response = { + 'meta': { + 'limit': 20, + 'offset': 0, + 'next': None, + 'previous': None, + 'total_count': 0 + }, + 'compliances': [] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.list() + + self.assertEqual(self.client.current_request.method, 'GET') + self.assertEqual(len(response.compliances), 0) + self.assertEqual(response.meta.total_count, 0) + + def test_get(self): + expected_response = { + 'compliance': { + 'compliance_id': 'comp_abc123', + 'status': 'approved', + 'country_iso': 'DE', + 'number_type': 'local', + 'alias': 'My German Number', + 'end_user': { + 'name': 'Test User', + 'email': 'test@example.com' + }, + 'documents': [ + {'document_id': 'doc_001', 'document_type': 'national_id'} + ], + 'created_at': '2025-01-15T10:00:00Z', + 'updated_at': '2025-01-16T12:00:00Z' + } + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.get('comp_abc123') + + self.assertEqual( + self.client.current_request.url, + 'https://api.plivo.com/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/comp_abc123/') + self.assertEqual(self.client.current_request.method, 'GET') + self.assertEqual(response.compliance.compliance_id, 'comp_abc123') + + def test_get_with_expand(self): + expected_response = { + 'compliance': { + 'compliance_id': 'comp_abc123', + 'status': 'approved', + 'country_iso': 'DE', + 'end_user': {'name': 'Test User'}, + 'documents': [] + } + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.get( + 'comp_abc123', expand='end_user,documents') + + self.assertIn('expand=end_user%2Cdocuments', self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'GET') + + def test_get_response_fields(self): + expected_response = { + 'compliance': { + 'compliance_id': 'comp_full', + 'status': 'pending', + 'country_iso': 'FR', + 'number_type': 'mobile', + 'user_type': 'business', + 'alias': 'French Mobile', + 'end_user': { + 'name': 'Acme Corp', + 'email': 'admin@acme.com', + 'phone': '+33123456789' + }, + 'documents': [ + { + 'document_id': 'doc_100', + 'document_type': 'national_id', + 'status': 'verified' + }, + { + 'document_id': 'doc_101', + 'document_type': 'utility_bill', + 'status': 'pending' + } + ], + 'callback_url': 'https://example.com/callback', + 'callback_method': 'POST', + 'created_at': '2025-06-01T08:00:00Z', + 'updated_at': '2025-06-02T09:30:00Z' + } + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.get('comp_full') + + compliance = response.compliance + self.assertEqual(compliance.compliance_id, 'comp_full') + self.assertEqual(compliance.status, 'pending') + self.assertEqual(compliance.country_iso, 'FR') + self.assertEqual(compliance.number_type, 'mobile') + self.assertEqual(compliance.alias, 'French Mobile') + self.assertEqual(compliance.end_user.name, 'Acme Corp') + self.assertEqual(len(compliance.documents), 2) + + def test_get_not_found(self): + expected_response = { + 'error': 'Compliance application not found' + } + self.client.set_expected_response( + status_code=404, data_to_return=expected_response) + + self.assertRaises( + exceptions.ResourceNotFoundError, + self.client.phone_number_compliance.get, + 'comp_nonexistent') + + def test_update(self): + expected_response = { + 'message': 'Compliance application updated successfully', + 'compliance': { + 'compliance_id': 'comp_abc123', + 'status': 'pending', + 'alias': 'Updated Alias' + } + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.update( + 'comp_abc123', + data={'alias': 'Updated Alias'}) + + self.assertIn( + '/PhoneNumber/Compliance/comp_abc123/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'PATCH') + self.assertEqual(response.message, 'Compliance application updated successfully') + + def test_update_http_method(self): + expected_response = { + 'message': 'updated', + 'compliance': { + 'compliance_id': 'comp_xyz789', + 'status': 'pending' + } + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + self.client.phone_number_compliance.update( + 'comp_xyz789', + data={'end_user': {'name': 'New Name'}}) + + # Verify PATCH method is used (not POST or PUT) + self.assertEqual(self.client.current_request.method, 'PATCH') + + def test_delete(self): + expected_response = { + 'compliance_id': 'comp_abc123', + 'message': 'Compliance application deleted successfully' + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.delete('comp_abc123') + + self.assertIn( + '/PhoneNumber/Compliance/comp_abc123/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'DELETE') + self.assertEqual(response.compliance_id, 'comp_abc123') + self.assertEqual(response.message, 'Compliance application deleted successfully') + + def test_delete_not_found(self): + expected_response = { + 'error': 'Compliance application not found' + } + self.client.set_expected_response( + status_code=404, data_to_return=expected_response) + + self.assertRaises( + exceptions.ResourceNotFoundError, + self.client.phone_number_compliance.delete, + 'comp_nonexistent') + + def test_list_pagination_meta(self): + expected_response = { + 'meta': { + 'limit': 5, + 'offset': 10, + 'next': '/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/?limit=5&offset=15', + 'previous': '/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/?limit=5&offset=5', + 'total_count': 25 + }, + 'compliances': [ + {'compliance_id': 'comp_010', 'status': 'approved'}, + {'compliance_id': 'comp_011', 'status': 'pending'}, + {'compliance_id': 'comp_012', 'status': 'approved'}, + {'compliance_id': 'comp_013', 'status': 'rejected'}, + {'compliance_id': 'comp_014', 'status': 'pending'} + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance.list(limit=5, offset=10) + + self.assertEqual(response.meta.limit, 5) + self.assertEqual(response.meta.offset, 10) + self.assertEqual(response.meta.total_count, 25) + self.assertIsNotNone(response.meta.next) + self.assertIsNotNone(response.meta.previous) + self.assertEqual(len(response.compliances), 5) + + +class PhoneNumberComplianceLinkTest(PlivoResourceTestCase): + + def test_link(self): + expected_response = { + 'report': [ + { + 'number': '+4930123456', + 'compliance_application_id': 'comp_abc123', + 'status': 'success', + 'message': 'Number linked successfully' + }, + { + 'number': '+4930654321', + 'compliance_application_id': 'comp_abc123', + 'status': 'success', + 'message': 'Number linked successfully' + } + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance_link.link( + numbers=[ + {'number': '+4930123456', 'compliance_application_id': 'comp_abc123'}, + {'number': '+4930654321', 'compliance_application_id': 'comp_abc123'} + ]) + + self.assertIn( + '/PhoneNumber/Compliance/Link/', + self.client.current_request.url) + self.assertEqual(self.client.current_request.method, 'POST') + self.assertEqual(len(response.report), 2) + + def test_link_empty_report(self): + expected_response = { + 'report': [] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + response = self.client.phone_number_compliance_link.link(numbers=[]) + + self.assertEqual(self.client.current_request.method, 'POST') + self.assertEqual(len(response.report), 0) + + def test_link_request_body(self): + expected_response = { + 'report': [ + { + 'number': '+441234567890', + 'compliance_application_id': 'comp_uk001', + 'status': 'success' + } + ] + } + self.client.set_expected_response( + status_code=200, data_to_return=expected_response) + + numbers = [ + {'number': '+441234567890', 'compliance_application_id': 'comp_uk001'} + ] + response = self.client.phone_number_compliance_link.link(numbers=numbers) + + self.assertEqual( + self.client.current_request.url, + 'https://api.plivo.com/v1/Account/MAXXXXXXXXXXXXXXXXXX/PhoneNumber/Compliance/Link/') + self.assertEqual(self.client.current_request.method, 'POST') + self.assertEqual(len(response.report), 1) + self.assertEqual(response.report[0].number, '+441234567890')