Skip to content
Gauge

ax-check.com

Plivo

plivo.com

ax-check.com

Checked 29 September 2026

B

84/100

Agents need your login

Plivo's quickstart, pricing, and SDKs are all readable and workable.

21 of 23 checks passed: quickstart code, install commands, and pricing tables are all directly usable. Only the homepage lacks a Markdown version.

2 fixes to start with

Concrete changes, backed by what we found.

Onboarding

Give agents a clear login handoff and a way to resume setup.

Agents cannot progress through onboarding without human help. Provide an agent-accessible path through authentication and setup.

Docs

Return Markdown when clients request text/markdown.

Homepage returned text/html even when text/markdown was requested; no Markdown representation served.

Recorded coding sessions

Same task, 3 models, no plivo.com account. Recorded 29 September 2026 on Pi.

3 recorded · 5m 48s

5m 48s · recorded

Session findings

Select a finding to see it in context.

piv0.84.2
modelQwen 3.8 Maxcwd/sandbox/repo
Help me build a simple example using Plivo. Tell me how pricing works, and briefly tell me whether this product will be easy for you to manage. Let me know if you get blocked. If this product has no developer workflow you can act on, say so plainly and stop. Stay light: use the hosted product through its SDK or API. Do not start local service stacks or wait for long-running commands; if the quickstart requires either, say so plainly and stop.
Thinking…

The user asks me to help build a simple example using Plivo. They want:

  1. A simple example using Plivo
  2. How pricing works
  3. Whether this product will be easy for me to manage
  4. Let them know if I get blocked
  5. If the product has no developer workflow I can act on, say so plainly and stop
  6. Stay light: use the hosted product through its SDK or API
  7. Don't start local service stacks or wait for long-running commands

Let me first check the current directory to understand the environment, then work on a Plivo example.

Plivo is a CPaaS (Communications Platform as a Service) — they offer SMS and Voice APIs. This is a hosted product with SDKs and APIs. Let me check what's available.

Let me start by examining the working directory.

I'll help you build a simple Plivo example. Let me first check the environment and understand what we're working with.

$ pwd && ls -la && echo "---NODE---" && node --version 2>/dev/null && echo "---PYTHON---" && python3 --version 2>/dev/null && echo "---PIP---" && pip3 --version 2>/dev/null{"content":[{"text":"/sandbox/repo\ntotal 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 29 08:17 ..\ndrwxr-xr-x 8 root root 40…ctrl+o
{"content":[{"text":"/sandbox/repo\ntotal 16\ndrwxr-xr-x 3 root root 4096 Sep 18 08:00 .\ndrwxr-xr-x 7 root root 4096 Sep 29 08:17 ..\ndrwxr-xr-x 8 root root 4096 Sep 24 20:05 .git\n-rw-r--r-- 1 root root  408 Sep 18 08:00 .gitignore\n---NODE---\nv22.23.2\n---PYTHON---\nPython 3.12.3\n---PIP---\npip 26.2.1 from /opt/freestyle/python/lib/python3.12/site-packages/pip (python 3.12)\n","type":"text"}]}
Thinking…

Empty repo with git. Let me check network access to see if I can install the Plivo SDK. Let me check the git log and any config.

Let me check network connectivity to Plivo and npm/pypi.

Clean repo with Node 22 and Python 3.12. Let me check network access and the git history.

$ cd /sandbox/repo && git log --oneline -5 2>/dev/null; echo "---GITIGNORE---"; cat .gitignore; echo "---NPM PING---"; timeout 20 npm ping 2>&1 | tail -5{"content":[{"text":"9ebf5fb Initial workspace\n---GITIGNORE---\n# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store…ctrl+o
{"content":[{"text":"9ebf5fb Initial workspace\n---GITIGNORE---\n# Keep generated and vendored files out of source diffs.\n\n# Logs / OS cruft\n*.log\n.DS_Store\n\n# JavaScript / TypeScript\nnode_modules/\n.next/\ndist/\nbuild/\ncoverage/\n\n# Python\n.venv/\nvenv/\n__pycache__/\n*.py[cod]\n.pytest_cache/\n*.egg-info/\n\n# Rust\ntarget/\n\n# Go / PHP / Ruby vendored deps\nvendor/\n.bundle/\n\n# Java / Kotlin / Scala\n.gradle/\n\n# .NET\nbin/\nobj/\n\n# Elixir\n_build/\ndeps/\n\n# Dart / Flutter\n.dart_tool/\n---NPM PING---\nnpm notice PING https://registry.npmjs.org/\nnpm notice PONG 429ms\n","type":"text"}]}
$ echo "---PYPI---"; timeout 25 pip3 download plivo --no-deps -d /tmp/plivotest 2>&1 | tail -8{"content":[{"text":"---PYPI---\nCollecting plivo\n Downloading plivo-4.63.0-py2.py3-none-any.whl.metadata (1.8 kB)\nDownloading plivo-4.63.0-py2.py3-none-any.…ctrl+o
{"content":[{"text":"---PYPI---\nCollecting plivo\n  Downloading plivo-4.63.0-py2.py3-none-any.whl.metadata (1.8 kB)\nDownloading plivo-4.63.0-py2.py3-none-any.whl (91 kB)\nSaved /tmp/plivotest/plivo-4.63.0-py2.py3-none-any.whl\nSuccessfully downloaded plivo\n","type":"text"}]}
Thinking…

Both npm and PyPI are reachable. Plivo Python SDK 4.63.0 is available. Let me install it and check the SDK structure. Let me also confirm what the SDK exposes.

Plivo is a hosted CPaaS. There's no local service stack required — it's purely API/SDK based. So I can act on this.

Let me install the Python SDK (it's the lightest) and check its API. Actually, let me decide: Python or Node? Python SDK is well-established. Let me go with Python since the wheel downloaded fine and it's light.

Actually, let me check both and pick. Let me install Python plivo and inspect.

Let me set up a project.

Both registries are reachable and the Plivo Python SDK (4.63.0) downloaded fine. Plivo is a hosted CPaaS — no local service stack needed, so this is actionable. Let me install and inspect the SDK's real API surface.

$ cd /tmp/plivotest && pip3 install plivo 2>&1 | tail -3; echo "---VERSION---"; python3 -c "import plivo; print(plivo.__version__ if hasattr(plivo,'__version__') else 'n/a'); print(plivo.__file__)"{"content":[{"text":"Installing collected packages: lxml, decorator, plivo\n\nSuccessfully installed decorator-5.3.1 lxml-6.1.3 plivo-4.63.0\n---VERSION---\nn/a…ctrl+o
{"content":[{"text":"Installing collected packages: lxml, decorator, plivo\n\nSuccessfully installed decorator-5.3.1 lxml-6.1.3 plivo-4.63.0\n---VERSION---\nn/a\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/__init__.py\n","type":"text"}]}
$ cd /tmp/plivotest && python3 -c "{"content":[{"text":"Traceback (most recent call last):\n File \"<string>\", line 2, in <module>\nModuleNotFoundError: No module named 'plivo'\n","type":"text"…ctrl+o
cd /tmp/plivotest && python3 -c "
import plivo, inspect
print('--- RestClient sig ---')
print(inspect.signature(plivo.RestClient.__init__))
" 2>&1 | head -20

{"content":[{"text":"Traceback (most recent call last):\n  File \"<string>\", line 2, in <module>\nModuleNotFoundError: No module named 'plivo'\n","type":"text"}]}
Thinking…

The second call ran in parallel before install completed. Let me re-run the inspection now.

Install succeeded (the parallel inspect ran before install finished). Let me inspect the actual API surface.

$ python3 -c "{"content":[{"text":"RestClient: (self, auth_id=None, auth_token=None, proxies=None, timeout=5)\n=== MESSAGES API ===\nTraceback (most recent call last):\n Fil…ctrl+o
python3 -c "
import plivo, inspect
print('RestClient:', inspect.signature(plivo.RestClient.__init__))
c = plivo.RestClient.__init__
" 2>&1 | head -20; echo "=== MESSAGES API ==="; python3 -c "
import plivo, inspect
c = plivo.RestClient('x','y')
print('messages.create:', inspect.signature(c.messages.create))
" 2>&1 | head -20

{"content":[{"text":"RestClient: (self, auth_id=None, auth_token=None, proxies=None, timeout=5)\n=== MESSAGES API ===\nTraceback (most recent call last):\n  File \"<string>\", line 3, in <module>\n  File \"/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py\", line 88, in __init__\n    self.session.auth = fetch_credentials(auth_id, auth_token)\n                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py\", line 68, in fetch_credentials\n    raise AuthenticationError('Invalid auth_id supplied: %s' % auth_id)\nplivo.exceptions.AuthenticationError: Invalid auth_id supplied: x\n","type":"text"}]}
Thinking…

The SDK validates credentials locally. Let me inspect the message create signature from the source instead of instantiating.

Let me look at the resources file.

The SDK validates credentials client-side. Let me read the signature from source instead.

$ python3 -c "{"content":[{"text":"Traceback (most recent call last):\n File \"<string>\", line 3, in <module>\nModuleNotFoundError: No module named 'plivo.resources.message…ctrl+o
python3 -c "
import inspect
from plivo.resources.message import Messages
print('Messages.create:'); print(inspect.signature(Messages.create))
" 2>&1 | head -30

{"content":[{"text":"Traceback (most recent call last):\n  File \"<string>\", line 3, in <module>\nModuleNotFoundError: No module named 'plivo.resources.message'\n","type":"text"}]}
$ python3 -c "{"content":[{"text":"Traceback (most recent call last):\n File \"<string>\", line 3, in <module>\nModuleNotFoundError: No module named 'plivo.resources.call'\n…ctrl+o
python3 -c "
import inspect
from plivo.resources.call import Calls
print('Calls.create:'); print(inspect.signature(Calls.create))
" 2>&1 | head -30

{"content":[{"text":"Traceback (most recent call last):\n  File \"<string>\", line 3, in <module>\nModuleNotFoundError: No module named 'plivo.resources.call'\n","type":"text"}]}
Thinking…

Let me look at the actual package layout.

Let me look at the actual package layout.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; ls $P; echo "=== resources ==="; ls $P/resources | head -60{"content":[{"text":"__init__.py\n__pycache__\nbase.py\nexceptions.py\nresources\nrest\nutils\nversion.py\nxml\n=== resources ===\n__init__.py\n__pycache__\nacc…ctrl+o
{"content":[{"text":"__init__.py\n__pycache__\nbase.py\nexceptions.py\nresources\nrest\nutils\nversion.py\nxml\n=== resources ===\n__init__.py\n__pycache__\naccounts.py\naddresses.py\napplications.py\nbrand.py\ncall_feedback.py\ncalls.py\ncampaign.py\nconferences.py\nendpoints.py\nidentities.py\nlive_calls.py\nlookup.py\nmaskingsession.py\nmedia.py\nmessages.py\nmultipartycall.py\nnodes.py\nnumberpools.py\nnumbers.py\nphlos.py\nphone_number_compliance.py\npowerpacks.py\npricings.py\nprofile.py\nqueued_calls.py\nrecordings.py\nregulatory_compliance.py\ntoken.py\ntollfree_verification.py\ntranscription.py\nverify.py\nverify_callerid.py\n","type":"text"}]}
Thinking…

There's a pricings.py resource. That's directly relevant to the user's pricing question. Let me inspect messages, calls, and pricings.

There's a dedicated pricings.py resource — directly useful for the pricing question. Let me inspect the key signatures.

$ python3 -c "{"content":[{"text":"=== Messages.create ===\n(self, dst, text=None, src=None, type_='sms', url=None, method='POST', log='true', trackable=False, powerpack_uuid…ctrl+o
python3 -c "
import inspect
from plivo.resources.messages import Messages
print('=== Messages.create ==='); print(inspect.signature(Messages.create))
"

{"content":[{"text":"=== Messages.create ===\n(self, dst, text=None, src=None, type_='sms', url=None, method='POST', log='true', trackable=False, powerpack_uuid=None, media_urls=None, media_ids=None, message_expiry=None, template=None, interactive=None, location=None, dlt_entity_id=None, dlt_template_id=None, dlt_template_category=None)\n","type":"text"}]}
$ python3 -c "{"content":[{"text":"=== Pricings methods ===\nget (self, country_iso)\n=== Pricing attrs ===\ncreate (self, params)\ndelete (self)\nget (self)\nupdate (self, p…ctrl+o
python3 -c "
import inspect
from plivo.resources.pricings import Pricings, Pricing
print('=== Pricings methods ===')
for n,f in inspect.getmembers(Pricings, predicate=inspect.isfunction):
    if not n.startswith('_'): print(n, inspect.signature(f))
print('=== Pricing attrs ===')
for n,f in inspect.getmembers(Pricing, predicate=inspect.isfunction):
    if not n.startswith('_'): print(n, inspect.signature(f))
"

{"content":[{"text":"=== Pricings methods ===\nget (self, country_iso)\n=== Pricing attrs ===\ncreate (self, params)\ndelete (self)\nget (self)\nupdate (self, params, path, **kwargs)\n","type":"text"}]}
Thinking…

The Pricings resource has a get(country_iso) method. This lets me fetch actual pricing from the API — but that requires credentials which I don't have.

Let me check the pricing response structure from the SDK source to explain pricing. Let me also look at the Pricing class fields.

Let me look at pricings.py source.

Let me read the pricing resource source to understand its data model, and check the phone-number resource.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; sed -n '1,80p' $P/resources/pricings.py{"content":[{"text":"# -*- coding: utf-8 -*-\n\"\"\"\nPricing class - along with its list class\n\"\"\"\n\nfrom plivo.base import PlivoResource, PlivoResourceIn…ctrl+o
{"content":[{"text":"# -*- coding: utf-8 -*-\n\"\"\"\nPricing class - along with its list class\n\"\"\"\n\nfrom plivo.base import PlivoResource, PlivoResourceInterface\nfrom plivo.utils import to_param_dict\nfrom plivo.utils.validators import *\n\n\nclass Pricing(PlivoResource):\n    _name = 'Pricing'\n    _identifier_string = 'country_iso'\n\n    def get(self):\n        return self.client.pricing.get(self.id)\n\n\nclass Pricings(PlivoResourceInterface):\n    _resource_type = Pricing\n\n    @validate_args(country_iso=[regex(r'^[A-Z]{2}$')])\n    def get(self, country_iso):\n        return self.client.request(\n            'GET', ('Pricing', ),\n            to_param_dict(self.get, locals()),\n            response_type=Pricing)\n","type":"text"}]}
Thinking…

The client.pricing attribute — let me check whether it's pricing or pricings on the client. Let me grep the client.

Let me verify the exact client attribute names so my example code is correct.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "def \(messages\|calls\|pricing\|pricings\|numbers\|phone_numbers\|applications\|accounts\|lookup\|verify\)" $P/rest/client.py{"content":[{"text":"(no output)\n\nCommand exited with code 1","type":"text"}],"details":{}}
Thinking…

Let me grep differently — maybe they're properties defined with @property.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "messages\|pricing\|calls\|phone_numbers\|numbers\|lookup" $P/rest/client.py | head -40{"content":[{"text":"19:from plivo.resources.live_calls import LiveCalls\n24:from plivo.resources.queued_calls import QueuedCalls\n100: self.calls = Call…ctrl+o
{"content":[{"text":"19:from plivo.resources.live_calls import LiveCalls\n24:from plivo.resources.queued_calls import QueuedCalls\n100:        self.calls = Calls(self)\n102:        self.live_calls = LiveCalls(self)\n103:        self.queued_calls = QueuedCalls(self)\n106:        self.messages = Messages(self)\n107:        self.lookup = Lookup(self)\n108:        self.numbers = Numbers(self)\n115:        self.pricing = Pricings(self)\n125:        self.multi_party_calls = MultiPartyCalls(self)\n246:        elif kwargs.get('is_lookup_request', False):\n333:            elif kwargs.get('is_lookup_request', False):\n334:                req = self.create_request(method, path, data, is_lookup_request=True)\n335:                del kwargs['is_lookup_request']\n","type":"text"}]}
Thinking…

Now I have the confirmed attribute names:

  • client.messages.create(...)
  • client.calls.create(...)
  • client.pricing.get(country_iso) — note it's pricing, singular
  • client.numbers
  • client.lookup

Let me check phone_numbers and applications too. Let me look at the full client init section.

Let me read lines 95-140 of client.py.

Now let me see the full list of client resources and the calls.create signature.

read /opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py{"content":[{"text":" self.proxies = proxies\n self.timeout = timeout\n self.account = Accounts(self)\n self.subaccounts = Subaccoun…ctrl+o
{"content":[{"text":"        self.proxies = proxies\n        self.timeout = timeout\n        self.account = Accounts(self)\n        self.subaccounts = Subaccounts(self)\n        self.applications = Applications(self)\n        self.calls = Calls(self)\n        self.token = Token(self)\n        self.live_calls = LiveCalls(self)\n        self.queued_calls = QueuedCalls(self)\n        self.conferences = Conferences(self)\n        self.endpoints = Endpoints(self)\n        self.messages = Messages(self)\n        self.lookup = Lookup(self)\n        self.numbers = Numbers(self)\n        self.tollfree_verification = TollfreeVerifications(self)\n        self.powerpacks = Powerpacks(self)\n        self.brand = Brand(self)\n        self.campaign = Campaign(self)\n        self.profile = Profile(self)\n        self.media = Media(self)\n        self.pricing = Pricings(self)\n        self.recordings = Recordings(self)\n        self.addresses = Addresses(self)\n        self.identities = Identities(self)\n        self.call_feedback = CallFeedback(self)\n        self.end_users = EndUsers(self)\n        self.compliance_document_types = ComplianceDocumentTypes(self)\n        self.compliance_documents = ComplianceDocuments(self)\n        self.compliance_requirements = ComplianceRequirements(self)\n        self.compliance_applications = ComplianceApplications(self)\n        self.multi_party_calls = MultiPartyCalls(self)\n        self.masking_sessions = MaskingSessions(self)\n        self.voice_retry_count = 0\n        self.verify_session = Sessions(self)\n        self.verify_callerids = VerifyCallerids(self)\n        self.transcriptions = Transcriptions(self)\n        self.phone_number_compliance_requirements = PhoneNumberComplianceRequirements(self)\n        self.phone_number_compliance = PhoneNumberComplianceApplications(self)\n        self.phone_number_compliance_link = PhoneNumberComplianceLink(self)\n\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n\n[203 more lines in file. Use offset=140 to continue.]","type":"text"}]}
$ python3 -c "{"content":[{"text":"=== Calls.create ===\n(self, from_, to_, answer_url, answer_method='POST', ring_url=None, ring_method='POST', hangup_url=None, hangup_metho…ctrl+o
python3 -c "
import inspect
from plivo.resources.calls import Calls
print('=== Calls.create ==='); print(inspect.signature(Calls.create))
"; echo "=== exceptions ==="; python3 -c "
import plivo.exceptions as e
print([n for n in dir(e) if 'Error' in n or 'Exception' in n])"

{"content":[{"text":"=== Calls.create ===\n(self, from_, to_, answer_url, answer_method='POST', ring_url=None, ring_method='POST', hangup_url=None, hangup_method='POST', fallback_url=None, fallback_method='POST', caller_name=None, send_digits=None, send_on_preanswer=None, time_limit=None, hangup_on_ring=None, machine_detection=None, machine_detection_time=5000, machine_detection_url=None, machine_detection_method='POST', sip_headers=None, ring_timeout=120, parent_call_uuid=None, error_if_parent_not_found=False, callback_url=None, callback_method=None)\n=== exceptions ===\n['AuthenticationError', 'ForbiddenError', 'GeoPermissionError', 'InvalidRequestError', 'PlivoRestError', 'PlivoServerError', 'PlivoXMLError', 'ResourceNotFoundError', 'ValidationError']\n","type":"text"}]}
Thinking…

Now I have everything I need. Let me build a simple, well-structured example project.

Key design decisions:

  • Python SDK (installed, v4.63.0)
  • A small module with a clean client wrapper
  • SMS send example (the classic Plivo quickstart)
  • Pricing lookup example — this directly answers the pricing question using the real API
  • A dry-run mode so it can run WITHOUT credentials (important: I don't have Plivo credentials, so I can't make live calls). I should be transparent about that.
  • Handle exceptions properly

Let me create:

  1. requirements.txt
  2. plivo_example/config.py — credential loading
  3. plivo_example/client.py — factory
  4. plivo_example/send_sms.py — send SMS
  5. plivo_example/pricing.py — pricing lookup
  6. plivo_example/voice_call.py — maybe keep it light. Actually "stay light" — let me include SMS + pricing, plus optionally a make-call one. Let me include voice since it's a natural second, but keep it small. Actually let me focus: SMS + pricing lookup is the core. I'll add a make-call as a small extra since it's ~15 lines.
  7. README.md with pricing explanation and manageability assessment.

Let me also verify things work with a dry-run / mocked test so I can prove the code is correct without credentials. I can mock the HTTP layer to verify the request construction. That's a good verification approach.

Actually, a cleaner verification: I can check that the SDK builds the right request. Let me test with a fake by monkeypatching client.request. That proves my call signatures are right.

Let me build the project.

Let me check the exceptions module for what attributes they have, so error handling is right.

Let me check what ValidationError etc. carry. Let me peek at exceptions.py quickly.

Actually let me just build and test. Let me look at exceptions.py briefly to get accurate error handling.

I have the full API surface confirmed. Let me check the exception shapes so error handling is accurate.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; sed -n '1,60p' $P/exceptions.py{"content":[{"text":"# -*- coding: utf-8 -*-\nclass PlivoRestError(Exception):\n pass\n\n\nclass AuthenticationError(PlivoRestError):\n pass\n\n\nclass In…ctrl+o
{"content":[{"text":"# -*- coding: utf-8 -*-\nclass PlivoRestError(Exception):\n    pass\n\n\nclass AuthenticationError(PlivoRestError):\n    pass\n\n\nclass InvalidRequestError(PlivoRestError):\n    pass\n\n\nclass PlivoServerError(PlivoRestError):\n    pass\n\n\nclass PlivoXMLError(PlivoRestError):\n    pass\n\n\nclass ResourceNotFoundError(PlivoRestError):\n    pass\n\n\nclass ValidationError(PlivoRestError):\n    pass\n\n\nclass ForbiddenError(PlivoRestError):\n    pass\n\nclass GeoPermissionError(PlivoRestError):\n    pass\n","type":"text"}]}
Thinking…

Simple exceptions, all subclassing PlivoRestError. Good — I can catch PlivoRestError broadly.

Now let me build the project. I'll create a clean, small example.

Files:

  • requirements.txt
  • .env.example
  • plivo_example/init.py
  • plivo_example/config.py
  • plivo_example/client.py
  • plivo_example/send_sms.py
  • plivo_example/pricing.py
  • plivo_example/make_call.py
  • README.md
  • A test that mocks the transport to verify correctness without credentials

Let me write them.

For config, use env vars PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN. Use os.environ, and optionally python-dotenv but that adds a dependency — I'll just read .env manually with a tiny parser to stay light. Actually, let me keep it simple: read from os.environ, and support a .env file with a minimal parser (no extra dependency).

Let me write config.py.

For the client factory, support proxies/timeout.

send_sms.py: build a function send_sms(client, src, dst, text) -> response. CLI with argparse.

pricing.py: lookup pricing for a country ISO, print message/call rates.

The Pricing response object — what fields does it have? It's a PlivoResource built from the JSON response. The API returns something like: { "api_id": "...", "country": "United States", "country_iso": "US", "message": { "inbound": {"local": "...", "international": "...", "tollfree": "..."}, "outbound": {"local": "...", "international": "...", "tollfree": "..."} }, "phone_number": { "local": {"rate": "..."}, "tollfree": {"rate": "..."} }, "voice": { "inbound": {"ip": {...}, "local": {...}, "tollfree": {...}, "domestic_international": ...}, "outbound": {...} } }

The PlivoResource supports attribute access. I should write defensively since exact fields vary by country. I'll write a generic pretty-printer that handles dicts recursively, plus explicit handling for known keys. That's robust.

Let me make pricing.py print a human-readable breakdown and also dump raw JSON so it's useful regardless of schema variation.

Let me write the files now.

For the mock test: monkeypatch RestClient.request to capture args. But RestClient.init validates credentials. The validation is fetch_credentials(auth_id, auth_token) which raises AuthenticationError for invalid auth_id. Let me check what it validates — probably checks env vars and format (auth_id must start with 'MA' and be 18-20 chars?). Let me check.

Let me read fetch_credentials.

Simple exception hierarchy rooted at PlivoRestError. Let me check the credential validation rules so my mock tests use valid-format fake creds.

read /opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py{"content":[{"text":"API_VOICE_BASE_URI = '/'.join([API_VOICE, 'v1/Account'])\nAPI_VOICE_FALLBACK_1 = 'https://api.plivo.com'\nAPI_VOICE_FALLBACK_2 = 'https://a…ctrl+o
{"content":[{"text":"API_VOICE_BASE_URI = '/'.join([API_VOICE, 'v1/Account'])\nAPI_VOICE_FALLBACK_1 = 'https://api.plivo.com'\nAPI_VOICE_FALLBACK_2 = 'https://api.plivo.com'\nAPI_VOICE_BASE_URI_FALLBACK_1 = '/'.join([API_VOICE_FALLBACK_1, 'v1/Account'])\nAPI_VOICE_BASE_URI_FALLBACK_2 = '/'.join([API_VOICE_FALLBACK_2, 'v1/Account'])\n\nCALLINSIGHTS_BASE_URL = 'https://stats.plivo.com'\n\nGEO_PERMISSION_ENDPOINTS = ['/Message/', '/Call/', '/Session/']\n\n\ndef get_user_agent():\n    return 'plivo-python/%s (Python: %s)' % (__version__,\n                                             platform.python_version())\n\n\ndef fetch_credentials(auth_id, auth_token):\n    \"\"\"Fetches the right credentials either from params or from environment\"\"\"\n\n    if not (auth_id and auth_token):\n        try:\n            auth_id = os.environ['PLIVO_AUTH_ID']\n            auth_token = os.environ['PLIVO_AUTH_TOKEN']\n        except KeyError:\n            raise AuthenticationError('The Plivo Python SDK '\n                                      'could not find your auth credentials.')\n\n    if not (is_valid_mainaccount(auth_id) or is_valid_subaccount(auth_id)):\n        raise AuthenticationError('Invalid auth_id supplied: %s' % auth_id)\n\n\n[273 more lines in file. Use offset=70 to continue.]","type":"text"}]}
Thinking…

Let me check is_valid_mainaccount to know the format for fake creds.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -rn "def is_valid_mainaccount\|def is_valid_subaccount" -A 6 $P/utils/validators.py{"content":[{"text":"(no output)\n\nCommand exited with code 1","type":"text"}],"details":{}}
Thinking…

Let me find them.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -rn "is_valid_mainaccount" $P --include=*.py | head; echo "---"; grep -rn "def is_valid_mainaccount" -A 8 $P/utils/*.py{"content":[{"text":"/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py:28:from plivo.utils import is_valid_mainaccount, is_valid_subaccoun…ctrl+o
{"content":[{"text":"/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py:28:from plivo.utils import is_valid_mainaccount, is_valid_subaccount\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/client.py:67:    if not (is_valid_mainaccount(auth_id) or is_valid_subaccount(auth_id)):\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/base_client.py:14:from plivo.utils import is_valid_mainaccount, is_valid_subaccount\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/rest/base_client.py:43:    if not (is_valid_mainaccount(auth_id) or is_valid_subaccount(auth_id)):\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py:63:def is_valid_mainaccount(mainaccount):\n---\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py:63:def is_valid_mainaccount(mainaccount):\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-64-    mainaccount_string = str(mainaccount)\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-65-    if len(mainaccount_string) == 20 and mainaccount_string[:2] == 'MA':\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-66-        return True\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-67-    return False\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-68-\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-69-\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-70-def to_param_dict(func, vals, exclude_none=True, func_args_check=True):\n/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/__init__.py-71-    args = getargspec(func)[0]\n","type":"text"}]}
Thinking…

Auth ID format: 20 chars, starts with 'MA'. Subaccount presumably 'SA' + 18. So fake creds: MAXXXXXXXXXXXXXXXXXX (20 chars total: 'MA' + 18 X's).

Let me construct: 'MA' + '0'*18 = 20 chars. Good.

Now let me build the project.

I'll write the files now.

Auth ID format confirmed: 20 chars starting with MA. Now I have everything needed. Let me build the example project.

write /sandbox/repo/requirements.txt{"content":[{"text":"Successfully wrote 96 bytes to /sandbox/repo/requirements.txt","type":"text"}]}
write /sandbox/repo/.env.example{"content":[{"text":"Successfully wrote 529 bytes to /sandbox/repo/.env.example","type":"text"}]}
Thinking…

Now the package. Let me write config.py with a minimal .env loader (no extra dependency).

write /sandbox/repo/plivo_example/config.py{"content":[{"text":"Successfully wrote 2950 bytes to /sandbox/repo/plivo_example/config.py","type":"text"}]}
write /sandbox/repo/plivo_example/__init__.py{"content":[{"text":"Successfully wrote 690 bytes to /sandbox/repo/plivo_example/__init__.py","type":"text"}]}
Thinking…

Now send_sms.py and pricing.py and make_call.py.

Let me write send_sms.py.

The response from messages.create is a Message object (PlivoResource) with message_uuid(s). Let me handle generically.

Let me write a shared helper for printing responses. Let me put a small _output.py or just inline.

Let me write send_sms.py with argparse CLI.

Note type_='sms' param — it's type_ because type is builtin.

Also log='true' — logging.

Let me write it.

Now the core SMS example:

write /sandbox/repo/plivo_example/send_sms.py{"content":[{"text":"Successfully wrote 4002 bytes to /sandbox/repo/plivo_example/send_sms.py","type":"text"}]}
Thinking…

Now the reporting helper module.

describe_response(response) — PlivoResource has to_dict()? Let me check base.py for PlivoResource methods.

Let me check quickly what PlivoResource offers: __getattr__, to_dict? Let me grep.

Let me check base.py.

Let me check what PlivoResource exposes so my reporting helper is accurate.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "class PlivoResource\|def \|self.__dict__" $P/base.py | sed -n '1,60p'{"content":[{"text":"22:def _attr(obj, key):\n31:def _next_page_params(response, limit):\n82: def __init__(self):\n99: def __init__(self, params_dict, id_…ctrl+o
{"content":[{"text":"22:def _attr(obj, key):\n31:def _next_page_params(response, limit):\n82:    def __init__(self):\n99:    def __init__(self, params_dict, id_string=None):\n101:            self.__dict__[i] = params_dict[i]\n104:            self.__dict__['id'] = params_dict[id_string]\n108:    def __init__(self, dct):\n109:        self.__dict__.update(dct)\n111:    def __contains__(self, item):\n112:        return item in self.__dict__\n114:    def __getitem__(self, item):\n116:            return self.__dict__.__getitem__(item)\n120:    def __setitem__(self, key, value):\n121:        self.__dict__.__setitem__(key, value)\n123:    def __delitem__(self, key):\n124:        del self.__dict__\n126:    def __str__(self):\n127:        return pprint.pformat(self.__dict__)\n129:    def __repr__(self):\n130:        return pprint.pformat(self.__dict__)\n134:    def __init__(self, client, dct):\n140:    def __iter__(self):\n146:    def __len__(self):\n152:    def __str__(self):\n159:    def __repr__(self):\n166:    def has_error(self):\n171:    def __init__(self, client, dct):\n178:    def __iter__(self):\n184:    def __len__(self):\n190:    def __str__(self):\n197:    def __repr__(self):\n204:    def has_error(self):\n209:    def __init__(self, client, dct):\n212:    def __iter__(self):\n215:    def __len__(self):\n218:    def __str__(self):\n221:    def __repr__(self):\n225:    def __init__(self, client, dct):\n232:    def __iter__(self):\n238:    def __len__(self):\n244:    def __str__(self):\n251:    def __repr__(self):\n258:    def has_error(self):\n261:class PlivoResource(ResponseObject):\n271:    def id(self):\n272:        value = self.__dict__.get(self._identifier_string, None)\n277:    def __init__(self, client, data):\n284:    def __str__(self):\n288:        return pprint.pformat(self.__dict__)\n290:    def __repr__(self):\n293:    def update(self, params, path, **kwargs):\n302:    def _update(self, params):\n312:            self.__dict__[key] = params[key]\n315:            self.__dict__[key] = response_json[key]\n319:    def _execute_action(self,\n347:    def delete(self):\n356:    def get(self):\n366:    def create(self, params):\n391:    def secondary_id(self):\n392:        value = self.__dict__.get(self._secondary_identifier_string, None)\n","type":"text"}]}
Thinking…

PlivoResource.__dict__ holds the response fields (set in ResponseObject.init from params_dict). And __str__ is pprint.pformat of __dict__.

Note: PlivoResource also has _client, _name, _identifier_string in its dict. Let me check line 277 init.

So describe_response should filter out private/dunder keys and the SDK internals like _client, _name, _identifier_string, _resource_type.

Let me check lines 261-292.

read /opt/freestyle/python/lib/python3.12/site-packages/plivo/base.py{"content":[{"text":"class PlivoResource(ResponseObject):\n \"\"\"The Plivo resource object\n\n This provides an interface to deal with all Plivo resource…ctrl+o
{"content":[{"text":"class PlivoResource(ResponseObject):\n    \"\"\"The Plivo resource object\n\n    This provides an interface to deal with all Plivo resources and\n    sub-resources\n    \"\"\"\n\n    _identifier_string = None\n\n    @property\n    def id(self):\n        value = self.__dict__.get(self._identifier_string, None)\n        if not value:\n            raise ValueError('{} must be set'.format(self._identifier_string))\n        return value\n\n    def __init__(self, client, data):\n        \"\"\"Sets up the resource URI along with a hack for Account resource\"\"\"\n\n        super(PlivoResource, self).__init__(data)\n        self._name = self._name or self.__class__.__name__\n        self.client = client\n\n    def __str__(self):\n        # return '{class_name}({identifier})'.format(\n        #    class_name=self._name, identifier=self.id)\n\n        return pprint.pformat(self.__dict__)\n\n    def __repr__(self):\n        return self.__str__()\n\n\n[166 more lines in file. Use offset=293 to continue.]","type":"text"}]}
Thinking…

So __dict__ contains the response fields plus _name and client. I need to filter those out.

Now let me write reporting.py.

Now the shared reporting helper:

write /sandbox/repo/plivo_example/reporting.py{"content":[{"text":"Successfully wrote 1927 bytes to /sandbox/repo/plivo_example/reporting.py","type":"text"}]}
Thinking…

Now pricing.py — the pricing lookup. This directly answers the user's pricing question using the real API.

Now the pricing lookup — this queries Plivo's live rate card:

write /sandbox/repo/plivo_example/pricing.py{"content":[{"text":"Successfully wrote 2841 bytes to /sandbox/repo/plivo_example/pricing.py","type":"text"}]}
Thinking…

Now make_call.py — a voice example. It needs an answer_url which must be a public URL. That's a constraint worth noting (it requires hosting an XML app URL). I'll include it but mark it clearly, and provide the XML via plivo.xml helper. Actually — the user said "stay light" and don't start local service stacks. A voice call requires a publicly reachable answer_url, which would require hosting. I should include it but note the constraint clearly, and offer Plivo's own hosted PHLO / a static gist URL as the light option.

Actually, let me keep it but make it clearly optional and note the answer_url requirement. I'll include a small helper that generates the Plivo XML (PHLO-less) so users can host it anywhere static.

Let me write make_call.py.

Also let me include the XML element generation using plivo.xml.Response. Let me check what's available in plivo.xml.

Let me check.

Now a voice example. Let me first check the XML builder API that Plivo uses for call instructions.

$ python3 -c "{"content":[{"text":"['BreakElement', 'ConferenceElement', 'ContElement', 'DTMFElement', 'DialElement', 'EmphasisElement', 'GetDigitsElement', 'GetInputElement'…ctrl+o
python3 -c "
from plivo import xml as x
print([n for n in dir(x) if not n.startswith('_')])"

{"content":[{"text":"['BreakElement', 'ConferenceElement', 'ContElement', 'DTMFElement', 'DialElement', 'EmphasisElement', 'GetDigitsElement', 'GetInputElement', 'HangupElement', 'LangElement', 'MessageElement', 'MultiPartyCallElement', 'NumberElement', 'PElement', 'PhonemeElement', 'PlayElement', 'PlivoXMLElement', 'PreAnswerElement', 'ProsodyElement', 'RecordElement', 'RedirectElement', 'ResponseElement', 'SElement', 'SayAsElement', 'SpeakElement', 'StreamElement', 'SubElement', 'UserElement', 'WElement', 'WaitElement', 'breakElement', 'contElement', 'emphasisElement', 'getDigitsElement', 'getInputElement', 'hangupElement', 'langElement', 'map_type', 'messageElement', 'numberElement', 'pElement', 'phonemeElement', 'playElement', 'preAnswerElement', 'prosodyElement', 'recordElement', 'redirectElement', 'sElement', 'sayAsElement', 'speakElement', 'streamElement', 'subElement', 'userElement', 'wElement', 'waitElement', 'xmlUtils']\n","type":"text"}]}
Thinking…

ResponseElement with addSpeak. Let me check the ResponseElement methods.

Let me just write make_call.py using ResponseElement().addSpeak(...).to_string().

Let me verify that API works.

$ python3 -c "{"content":[{"text":"Traceback (most recent call last):\n File \"<string>\", line 4, in <module>\nAttributeError: 'ResponseElement' object has no attribute 'ad…ctrl+o
python3 -c "
from plivo.xml import ResponseElement
r = ResponseElement()
r.addSpeak('Hello from Plivo', voice='Polly.Joanna', loop=1)
print(r.to_string())
"

{"content":[{"text":"Traceback (most recent call last):\n  File \"<string>\", line 4, in <module>\nAttributeError: 'ResponseElement' object has no attribute 'addSpeak'. Did you mean: 'add_speak'?\n\n\nCommand exited with code 1","type":"text"}],"details":{}}
Thinking…

It's add_speak (snake_case). Let me verify.

$ python3 -c "{"content":[{"text":"<Response>\n <Speak voice=\"Polly.Joanna\" loop=\"1\">Hello from Plivo</Speak>\n <Hangup/>\n</Response>\n\n","type":"text"}]}ctrl+o
python3 -c "
from plivo.xml import ResponseElement
r = ResponseElement()
r.add_speak('Hello from Plivo', voice='Polly.Joanna', loop=1)
r.add_hangup()
print(r.to_string())
"

Thinking…

The XML builder works with snake_case methods. Now let me write make_call.py plus a small xml_app helper that generates the XML.

The key constraint: answer_url must be publicly reachable. I'll be explicit about that and provide the XML generator so it can be hosted anywhere static.

Let me write it.

XML builder works (snake_case). Now the voice example, with an honest note about the answer_url requirement:

write /sandbox/repo/plivo_example/make_call.py{"content":[{"text":"Successfully wrote 4378 bytes to /sandbox/repo/plivo_example/make_call.py","type":"text"}]}
Thinking…

Now let me test everything. First, verify the code imports and the dry-run works. I need a .env with placeholder creds for dry-run to work.

Actually get_config() requires PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN to exist. For dry-run to work with placeholders, I need .env to exist. Let me create .env from .env.example for testing (but .env should be gitignored — let me check .gitignore includes .env).

The .gitignore does NOT include .env! Let me add it. Important for credential safety.

Let me add .env to .gitignore.

Then test.

Let me also write a test file that mocks the transport to prove the SDK calls are constructed correctly without real credentials. That's a strong verification.

Let me first fix .gitignore, then test dry-run.

Let me make sure .env is gitignored (it isn't currently — important for credential safety), then test the dry-run paths.

edit /sandbox/repo/.gitignore{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/.gitignore.","type":"text"}],"details":{"diff":" ...\n 3 # Logs / OS cruft\n 4 *.log…ctrl+o
{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/.gitignore.","type":"text"}],"details":{"diff":"    ...\n  3 # Logs / OS cruft\n  4 *.log\n  5 .DS_Store\n  6 \n+ 7 # Secrets: never commit real Plivo credentials\n+ 8 .env\n+ 9 .env.*\n+10 !.env.example\n+11 \n  7 # JavaScript / TypeScript\n  8 node_modules/\n  9 .next/\n 10 dist/\n    ...","patch":"--- /sandbox/repo/.gitignore\n+++ /sandbox/repo/.gitignore\n@@ -3,8 +3,13 @@\n # Logs / OS cruft\n *.log\n .DS_Store\n \n+# Secrets: never commit real Plivo credentials\n+.env\n+.env.*\n+!.env.example\n+\n # JavaScript / TypeScript\n node_modules/\n .next/\n dist/\n","firstChangedLine":7}}
Thinking…

Now let me create a local .env for testing and run the dry-run commands.

Note: .env is now gitignored, so it's safe.

Let me copy .env.example to .env and test.

$ cd /sandbox/repo && cp .env.example .env && echo "--- git check-ignore .env ---" && git check-ignore -v .env; echo "--- DRY RUN SMS ---"; python3 -m plivo_example.send_sms --dry-run 2>&1{"content":[{"text":"--- git check-ignore .env ---\n.gitignore:8:.env\t.env\n--- DRY RUN SMS ---\n[dry-run] Would POST /Message/ with:\n src = +141555…ctrl+o
{"content":[{"text":"--- git check-ignore .env ---\n.gitignore:8:.env\t.env\n--- DRY RUN SMS ---\n[dry-run] Would POST /Message/ with:\n  src          = +14155550123\n  dst          = +14155550199\n  type         = sms\n  text         = 'Hello from the Plivo example!'\n\nNo API call was made.\n","type":"text"}]}
Thinking…

The dry-run works and .env is gitignored. Let me test the other paths:

  • make_call --print-xml
  • make_call --dry-run (needs answer-url)
  • pricing with placeholder creds (should give helpful error)
  • send_sms with placeholder creds (should give helpful error about placeholders)

Let me run these.

$ cd /sandbox/repo && echo "=== print-xml ==="; python3 -m plivo_example.make_call --print-xml; echo; echo "=== make_call dry-run (missing answer-url) ==="; python3 -m plivo_example.make_call --dry-run …{"content":[{"text":"=== print-xml ===\n<Response>\n <Speak voice=\"Polly.Joanna\" loop=\"1\">Hello from the Plivo example!</Speak>\n <Hangup/>\n</Response>\n…ctrl+o
cd /sandbox/repo && echo "=== print-xml ==="; python3 -m plivo_example.make_call --print-xml; echo; echo "=== make_call dry-run (missing answer-url) ==="; python3 -m plivo_example.make_call --dry-run 2>&1; echo "exit=$?"

{"content":[{"text":"=== print-xml ===\n<Response>\n  <Speak voice=\"Polly.Joanna\" loop=\"1\">Hello from the Plivo example!</Speak>\n  <Hangup/>\n</Response>\n\n\n=== make_call dry-run (missing answer-url) ===\nerror: No --answer-url. Plivo needs a PUBLIC URL that returns Plivo XML.\n  Generate one with: python -m plivo_example.make_call --print-xml\n  Host it anywhere static, then pass its URL here.\nexit=1\n","type":"text"}]}
$ cd /sandbox/repo && echo "=== pricing with placeholder creds ==="; python3 -m plivo_example.pricing US 2>&1; echo "exit=$?"; echo; echo "=== sms live with placeholder creds ==="; python3 -m plivo_exam…{"content":[{"text":"=== pricing with placeholder creds ===\nerror: Credentials are still placeholders, so the Pricing API cannot be called.\nPut real values in…ctrl+o
cd /sandbox/repo && echo "=== pricing with placeholder creds ==="; python3 -m plivo_example.pricing US 2>&1; echo "exit=$?"; echo; echo "=== sms live with placeholder creds ==="; python3 -m plivo_example.send_sms --to +14155550199 2>&1; echo "exit=$?"

{"content":[{"text":"=== pricing with placeholder creds ===\nerror: Credentials are still placeholders, so the Pricing API cannot be called.\nPut real values in .env (from https://console.plivo.com/) and re-run.\nSee README.md for the general pricing model in the meantime.\nexit=1\n\n=== sms live with placeholder creds ===\nerror: PLIVO_AUTH_ID/PLIVO_AUTH_TOKEN are still the placeholder values from .env.example.\nPut real credentials in .env, or re-run with --dry-run.\nexit=1\n","type":"text"}]}
Thinking…

All error paths work correctly with helpful messages. Now let me write a test that verifies the SDK calls are constructed correctly — mocking the transport. This proves the code is correct without real credentials.

I'll monkeypatch client.request to capture arguments and return a fake response. This validates:

  1. send_sms builds the right params and hits the right path
  2. pricing.get hits the right path
  3. make_call builds the right params

Let me write tests/test_api_construction.py using pytest... but is pytest installed? Let me check. If not, I'll write a plain unittest-based test that runs with python3 -m unittest — no new dependency. Let me use unittest to stay light.

Actually let me check if pytest is available.

Let me write the test with unittest so there's zero extra dependency.

The test needs to construct a RestClient with fake-but-valid-format creds ('MA' + 18 chars), then monkeypatch client.request.

Let me verify that RestClient construction with fake creds works (it only validates format, no network).

Then patch client.request and check the args.

Let me check the request signature: request(self, method, path, data=None, **kwargs).

Let me verify by grepping.

All error paths give helpful guidance. Now let me prove the SDK calls are constructed correctly without needing real credentials — I'll mock the transport layer.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n " def request" -A 4 $P/rest/client.py | head -20; echo "=== pytest? ==="; python3 -c "import pytest; print('pytest', pytest.__ver…{"content":[{"text":"291: def request(self,\n292- method,\n293- path=None,\n294- data=None,\n295- …ctrl+o
P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "    def request" -A 4 $P/rest/client.py | head -20; echo "=== pytest? ==="; python3 -c "import pytest; print('pytest', pytest.__version__)" 2>&1 | tail -1

{"content":[{"text":"291:    def request(self,\n292-                method,\n293-                path=None,\n294-                data=None,\n295-                response_type=None,\n=== pytest? ===\nModuleNotFoundError: No module named 'pytest'\n","type":"text"}]}
Thinking…

No pytest — I'll use stdlib unittest so there's no extra dependency.

Let me write the test that patches client.request.

request(self, method, path=None, data=None, response_type=None, **kwargs).

For pricing.get, it calls self.client.request('GET', ('Pricing',), to_param_dict(...), response_type=Pricing).

For messages.create, let me check how it calls request. Let me look at messages.py create.

Let me check.

No pytest, so I'll use stdlib unittest (zero extra deps). Let me check how messages.create and calls.create invoke the transport.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "return self.client.request" -B 2 -A 6 $P/resources/messages.py | tail -20; echo "=== CALLS ==="; grep -n "return self.client.reques…{"content":[{"text":"95: return self.client.request('POST', ('Message', ),\n96- to_param_dict(self.create, locals()))\n…ctrl+o
P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; grep -n "return self.client.request" -B 2 -A 6 $P/resources/messages.py | tail -20; echo "=== CALLS ==="; grep -n "return self.client.request" -A 6 $P/resources/calls.py | tail -12

{"content":[{"text":"95:        return self.client.request('POST', ('Message', ),\n96-                                   to_param_dict(self.create, locals()))\n97-\n98-    @validate_args(message_uuid=[of_type(six.text_type)])\n99-    def get(self, message_uuid):\n100:        return self.client.request(\n101-            'GET', ('Message', message_uuid), response_type=Message)\n102-\n103-    @validate_args(\n104-        subaccount=[optional(is_subaccount())],\n105-        message_direction=[optional(is_in(('inbound', 'outbound')))],\n106-        message_time__gt=[optional(is_valid_date())],\n--\n158-             to_number=None,\n159-             ):\n160:        return self.client.request(\n161-            'GET', ('Message', ),\n162-            to_param_dict(self.list, locals()),\n163-            response_type=ListMessagesResponseObject,\n164-            objects_type=Message)\n=== CALLS ===\n549-        call_uuid=[of_type(six.text_type)],\n550-        stream_id=[of_type(six.text_type)]\n--\n555:        return self.client.request('GET', ('Call', call_uuid, 'Stream', stream_id), is_voice_request=True)\n556-\n557-    @validate_args(\n558-        call_uuid=[of_type(six.text_type)]\n559-    )\n560-    def get_all_streams(self,\n561-                        call_uuid):\n562:        return self.client.request('GET', ('Call', call_uuid, 'Stream'), is_voice_request=True)\n563-\n","type":"text"}]}
Thinking…

Good. Now let me write the test file. I'll patch RestClient.request to capture (method, path, data).

Let me write tests/test_api_construction.py.

Key: build client with fake valid creds, patch request.

The validators: dst must be valid phone numbers. Let me check the validate_args for messages.create — dst=[multi_phone_number()]. So '+14155550199' should pass.

Let me write the test.

For pricing: client.pricing.get(country_iso='US') → request('GET', ('Pricing',), {'country_iso': 'US'}, response_type=Pricing). The response_type=Pricing means it will construct a Pricing object from the returned data. So my fake needs to return a dict that Pricing can consume. Pricing(client, data) — ResponseObject.init(dct) updates dict. Fine.

For messages.create, no response_type → returns plain dict probably. Let me check what request returns when response_type is None. It returns the JSON dict. Let me just return a dict.

Let me write the tests.

Now a verification test that mocks the transport — proving request construction is correct with no credentials and no network:

write /sandbox/repo/tests/test_api_construction.py{"content":[{"text":"Successfully wrote 4494 bytes to /sandbox/repo/tests/test_api_construction.py","type":"text"}]}
Thinking…

Let me run the tests. Need tests/init.py? For unittest discover with -s tests, it may need init.py or it works with namespace packages. Let me add an empty init.py to be safe. Actually discover requires the directory to be importable. Let me just add init.py.

Let me run.

$ cd /sandbox/repo && touch tests/__init__.py && python3 -m unittest discover -s tests -v 2>&1 | tail -30{"content":[{"text":"----------------------------------------------------------------------\nTraceback (most recent call last):\n File \"/sandbox/repo/tests/te…ctrl+o
{"content":[{"text":"----------------------------------------------------------------------\nTraceback (most recent call last):\n  File \"/sandbox/repo/tests/test_api_construction.py\", line 80, in test_powerpack_used_as_source\n    send_sms(client, src=\"\", dst=DST, text=\"Hi\", powerpack_uuid=\"pp-123\")\n  File \"/sandbox/repo/plivo_example/send_sms.py\", line 50, in send_sms\n    return client.messages.create(**kwargs)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/opt/freestyle/python/lib/python3.12/site-packages/decorator/__init__.py\", line 247, in fun\n    return caller(func, *(extras + args), **kw)\n           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/opt/freestyle/python/lib/python3.12/site-packages/plivo/utils/validators.py\", line 210, in wrapper\n    return wrapped(**params)\n           ^^^^^^^^^^^^^^^^^\n  File \"/opt/freestyle/python/lib/python3.12/site-packages/plivo/resources/messages.py\", line 78, in create\n    raise ValidationError(\nplivo.exceptions.ValidationError: Both powerpack_uuid and src cannot be specified. Specify either powerpack_uuid or src in request params to send a message.\n\n======================================================================\nERROR: test_sms_posts_to_message_endpoint (test_api_construction.TestSendSms.test_sms_posts_to_message_endpoint)\n----------------------------------------------------------------------\nTraceback (most recent call last):\n  File \"/sandbox/repo/tests/test_api_construction.py\", line 66, in test_sms_posts_to_message_endpoint\n    self.assertEqual(data[\"type_\"], \"sms\")\n                     ~~~~^^^^^^^^^\nKeyError: 'type_'\n\n----------------------------------------------------------------------\nRan 7 tests in 0.004s\n\nFAILED (errors=5)\n","type":"text"}]}
Thinking…

Two real bugs found by the tests — this is exactly why the tests are valuable:

  1. type_ is NOT passed through as type_ in the data. Let me check what messages.create does with type_ — it probably converts to type. Let me look at messages.py create.

  2. Passing src="" with powerpack_uuid triggers validation error: "Both powerpack_uuid and src cannot be specified." So my send_sms must omit src entirely when using powerpack, not pass empty string.

Let me look at messages.py create source to fix both.

Excellent — the tests found two real bugs. Let me look at the SDK's create source to fix them properly.

read /opt/freestyle/python/lib/python3.12/site-packages/plivo/resources/messages.py{"content":[{"text":" type_='sms',\n url=None,\n method='POST',\n log=\"true\",\n trackabl…ctrl+o
{"content":[{"text":"               type_='sms',\n               url=None,\n               method='POST',\n               log=\"true\",\n               trackable=False,\n               powerpack_uuid=None,\n               media_urls=None,\n               media_ids=None,\n               message_expiry=None, \n               template=None,\n               interactive=None,\n               location=None,\n               dlt_entity_id=None,\n               dlt_template_id=None,\n               dlt_template_category=None):\n        if src in dst.split('<'):\n            raise ValidationError(\n                'destination number cannot be same as source number')\n        elif (src is None) and (powerpack_uuid is None):\n            raise ValidationError(\n                'Specify either powerpack_uuid or src in request params to send a message'\n            )\n        elif (src is not None) and (powerpack_uuid is not None):\n            raise ValidationError(\n                'Both powerpack_uuid and src cannot be specified. Specify either powerpack_uuid or src in request params to send a message.'\n            )\n        if ((type_ != 'whatsapp') and (template is not None)):\n            raise ValidationError(\n                'Template paramater is only applicable when message_type is whatsapp'\n            )\n        if ((type_ == 'whatsapp') and (src is None)):\n            raise ValidationError(\n                'src parameter not present'\n            )\n        if template is not None:\n            template =  template.__dict__\n        if interactive is not None:\n            interactive = interactive.__dict__\n        if location is not None:\n            location = location.__dict__\n        return self.client.request('POST', ('Message', ),\n                                   to_param_dict(self.create, locals()))\n\n    @validate_args(message_uuid=[of_type(six.text_type)])\n    def get(self, message_uuid):\n\n[66 more lines in file. Use offset=100 to continue.]","type":"text"}]}
Thinking…

Key insight: the SDK checks src is not None and powerpack_uuid is not None. So src="" counts as "not None" → error. I must pass src=None when using powerpack.

Also to_param_dict(self.create, locals()) — with exclude_none=True. Let me check how it maps type_. Let me look at to_param_dict.

It probably strips trailing underscore: type_ → type. Let me check.

$ P=/opt/freestyle/python/lib/python3.12/site-packages/plivo; sed -n '70,100p' $P/utils/__init__.py{"content":[{"text":"def to_param_dict(func, vals, exclude_none=True, func_args_check=True):\n args = getargspec(func)[0]\n arg_names = list(args)\n # …ctrl+o
{"content":[{"text":"def to_param_dict(func, vals, exclude_none=True, func_args_check=True):\n    args = getargspec(func)[0]\n    arg_names = list(args)\n    # The bit of regex magic below is for arguments that are keywords in\n    # Python, like from. These can't be used directly, so our convention is to\n    # add \"_\" suffixes to them. This strips them out.\n    pd = {\n        re.sub(r'^(.*)_+$', r'\\1', key): value\n        for key, value in vals.items()\n        if key != 'self' and (key in arg_names or func_args_check==False) and (\n            value is not None or exclude_none is False)\n    }\n    return pd\n","type":"text"}]}
Thinking…

Confirmed: to_param_dict strips trailing underscores, so type_ becomes type in the payload, and from_ becomes from. And None values are excluded.

So:

  1. My test should assert data["type"] not data["type_"].
  2. send_sms must pass src=None (not "") when using powerpack.

Let me fix both the source and the tests.

Fix send_sms: build kwargs with src only if truthy.

Also make_call test: from_ → from in data. Let me check my test asserts data["from_"]. That will also fail. Let me fix to data["from"].

Wait — the test output showed 5 errors. Let me fix all and re-run.

Let me fix:

  • send_sms.py: kwargs["src"] only when src truthy
  • test: data["type"], data["from"]

Let me apply the edits.

Confirmed: to_param_dict strips trailing underscores (type_→type, from_→from) and drops None. Let me fix the real bug in send_sms and correct my test assertions.

edit /sandbox/repo/plivo_example/send_sms.py{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/plivo_example/send_sms.py.","type":"text"}],"details":{"diff":" ...\n 34 if not …ctrl+o
{"content":[{"text":"Successfully replaced 1 block(s) in /sandbox/repo/plivo_example/send_sms.py.","type":"text"}],"details":{"diff":"     ...\n  34     if not (src or powerpack_uuid):\n  35         raise ValueError(\"Provide either src (a Plivo number) or powerpack_uuid.\")\n  36 \n  37     kwargs: dict[str, object] = {\n- 38         \"src\": src,\n  39         \"dst\": dst,\n  40         \"text\": text,\n  41         \"type_\": \"mms\" if media_urls else \"sms\",\n  42         # 'true' keeps the message in the dashboard logs; 'false' omits it.\n  43         \"log\": \"true\",\n  44     }\n- 45     if media_urls:\n- 46         kwargs[\"media_urls\"] = media_urls\n+ 44     # The SDK rejects a request that carries BOTH src and powerpack_uuid, and it\n+ 45     # tests with `is not None` -- so an empty string would still collide.\n+ 46     # Only pass the source mechanism that was actually chosen.\n  47     if powerpack_uuid:\n  48         kwargs[\"powerpack_uuid\"] = powerpack_uuid\n+ 49     else:\n+ 50         kwargs[\"src\"] = src\n+ 51     if media_urls:\n+ 52         kwargs[\"media_urls\"] = media_urls\n  49 \n  50     return client.messages.create(**kwargs)\n  51 \n  52 \n     ...","patch":"--- /sandbox/repo/plivo_example/send_sms.py\n+++ /sandbox/repo/plivo_example/send_sms.py\n@@ -34,19 +34,23 @@\n     if not (src or powerpack_uuid):\n         raise ValueError(\"Provide either src (a Plivo number) or powerpack_uuid.\")\n \n     kwargs: dict[str, object] = {\n-        \"src\": src,\n         \"dst\": dst,\n         \"text\": text,\n         \"type_\": \"mms\" if media_urls else \"sms\",\n         # 'true' keeps the message in the dashboard logs; 'false' omits it.\n         \"log\": \"true\",\n     }\n-    if media_urls:\n-        kwargs[\"media_urls\"] = media_urls\n+    # The SDK rejects a request that carries BOTH src and powerpack_uuid, and it\n+    # tests with `is not None` -- so an empty string would still collide.\n+    # Only pass the source mechanism that was actually chosen.\n     if powerpack_uuid:\n         kwargs[\"powerpack_uuid\"] = powerpack_uuid\n+    else:\n+        kwargs[\"src\"] = src\n+    if media_urls:\n+        kwargs[\"media_urls\"] = media_urls\n \n     return client.messages.create(**kwargs)\n \n \n","firstChangedLine":38}}
edit /sandbox/repo/tests/test_api_construction.py{"content":[{"text":"Successfully replaced 3 block(s) in /sandbox/repo/tests/test_api_construction.py.","type":"text"}],"details":{"diff":" ...\n 62 …ctrl+o
{"content":[{"text":"Successfully replaced 3 block(s) in /sandbox/repo/tests/test_api_construction.py.","type":"text"}],"details":{"diff":"     ...\n  62         data = spy.last[\"data\"]\n  63         self.assertEqual(data[\"src\"], SRC)\n  64         self.assertEqual(data[\"dst\"], DST)\n  65         self.assertEqual(data[\"text\"], \"Hello\")\n- 66         self.assertEqual(data[\"type_\"], \"sms\")\n+ 66         # NB: the SDK strips the trailing underscore, so `type_` is sent as `type`.\n+ 67         self.assertEqual(data[\"type\"], \"sms\")\n  67         self.assertEqual(data[\"log\"], \"true\")\n  68 \n  69     def test_media_urls_switch_to_mms(self):\n  70         client, spy = make_client({\"message_uuid\": [\"u-2\"]})\n  71 \n  72         send_sms(client, src=SRC, dst=DST, text=\"Pic\", media_urls=[\"https://x/y.png\"])\n  73 \n- 74         self.assertEqual(spy.last[\"data\"][\"type_\"], \"mms\")\n+ 75         self.assertEqual(spy.last[\"data\"][\"type\"], \"mms\")\n  75         self.assertEqual(spy.last[\"data\"][\"media_urls\"], [\"https://x/y.png\"])\n  76 \n  77     def test_powerpack_used_as_source(self):\n  78         client, spy = make_client({\"message_uuid\": [\"u-3\"]})\n     ...\n  95 \n  96         self.assertEqual(spy.last[\"method\"], \"POST\")\n  97         self.assertEqual(spy.last[\"path\"], (\"Call\",))\n  98         data = spy.last[\"data\"]\n- 99         self.assertEqual(data[\"from_\"], SRC)\n-100         self.assertEqual(data[\"to_\"], DST)\n+100         # NB: trailing underscores are stripped, so `from_` is sent as `from`.\n+101         self.assertEqual(data[\"from\"], SRC)\n+102         self.assertEqual(data[\"to\"], DST)\n 101         self.assertEqual(data[\"answer_url\"], \"https://x/plivo.xml\")\n 102         self.assertEqual(data[\"answer_method\"], \"GET\")\n 103 \n 104     def test_answer_xml_is_valid_plivo_xml(self):\n     ...","patch":"--- /sandbox/repo/tests/test_api_construction.py\n+++ /sandbox/repo/tests/test_api_construction.py\n@@ -62,17 +62,18 @@\n         data = spy.last[\"data\"]\n         self.assertEqual(data[\"src\"], SRC)\n         self.assertEqual(data[\"dst\"], DST)\n         self.assertEqual(data[\"text\"], \"Hello\")\n-        self.assertEqual(data[\"type_\"], \"sms\")\n+        # NB: the SDK strips the trailing underscore, so `type_` is sent as `type`.\n+        self.assertEqual(data[\"type\"], \"sms\")\n         self.assertEqual(data[\"log\"], \"true\")\n \n     def test_media_urls_switch_to_mms(self):\n         client, spy = make_client({\"message_uuid\": [\"u-2\"]})\n \n         send_sms(client, src=SRC, dst=DST, text=\"Pic\", media_urls=[\"https://x/y.png\"])\n \n-        self.assertEqual(spy.last[\"data\"][\"type_\"], \"mms\")\n+        self.assertEqual(spy.last[\"data\"][\"type\"], \"mms\")\n         self.assertEqual(spy.last[\"data\"][\"media_urls\"], [\"https://x/y.png\"])\n \n     def test_powerpack_used_as_source(self):\n         client, spy = make_client({\"message_uuid\": [\"u-3\"]})\n@@ -95,10 +96,11 @@\n \n         self.assertEqual(spy.last[\"method\"], \"POST\")\n         self.assertEqual(spy.last[\"path\"], (\"Call\",))\n         data = spy.last[\"data\"]\n-        self.assertEqual(data[\"from_\"], SRC)\n-        self.assertEqual(data[\"to_\"], DST)\n+        # NB: trailing underscores are stripped, so `from_` is sent as `from`.\n+        self.assertEqual(data[\"from\"], SRC)\n+        self.assertEqual(data[\"to\"], DST)\n         self.assertEqual(data[\"answer_url\"], \"https://x/plivo.xml\")\n         self.assertEqual(data[\"answer_method\"], \"GET\")\n \n     def test_answer_xml_is_valid_plivo_xml(self):\n","firstChangedLine":66}}
Thinking…

Also the test_requires_a_source test: send_sms(client, src="", dst=DST, text="Hi") with no powerpack → my ValueError check if not (src or powerpack_uuid) raises ValueError. Good.

Let me re-run tests.

$ cd /sandbox/repo && python3 -m unittest discover -s tests -v 2>&1 | tail -25{"content":[{"text":"test_answer_xml_is_valid_plivo_xml (test_api_construction.TestMakeCall.test_answer_xml_is_valid_plivo_xml) ... ok\ntest_call_posts_to_call_…ctrl+o
{"content":[{"text":"test_answer_xml_is_valid_plivo_xml (test_api_construction.TestMakeCall.test_answer_xml_is_valid_plivo_xml) ... ok\ntest_call_posts_to_call_endpoint (test_api_construction.TestMakeCall.test_call_posts_to_call_endpoint) ... ok\ntest_pricing_gets_country_rate_card (test_api_construction.TestPricing.test_pricing_gets_country_rate_card) ... ERROR\ntest_media_urls_switch_to_mms (test_api_construction.TestSendSms.test_media_urls_switch_to_mms) ... ok\ntest_powerpack_used_as_source (test_api_construction.TestSendSms.test_powerpack_used_as_source) ... ok\ntest_requires_a_source (test_api_construction.TestSendSms.test_requires_a_source) ... ok\ntest_sms_posts_to_message_endpoint (test_api_construction.TestSendSms.test_sms_posts_to_message_endpoint) ... ok\n\n======================================================================\nERROR: test_pricing_gets_country_rate_card (test_api_construction.TestPricing.test_pricing_gets_country_rate_card)\n----------------------------------------------------------------------\nTraceback (most recent call last):\n  File \"/sandbox/repo/tests/test_api_construction.py\", line 128, in test_pricing_gets_country_rate_card\n    self.assertEqual(pricing.country, \"United States\")\n                     ^^^^^^^^^^^^^^^\nAttributeError: 'dict' object has no attribute 'country'\n\n----------------------------------------------------------------------\nRan 7 tests in 0.003s\n\nFAILED (errors=1)\n","type":"text"}]}
Thinking…

6/7 pass. The pricing test fails because my spy returns the raw dict (I bypassed the response_type wrapping that the real request does). The real client.request would wrap the dict into a Pricing object via response_type=Pricing.

So in the test, my spy should honor response_type to be faithful. Let me make the spy construct the response_type if provided. That makes the test more realistic and also validates that response_type is passed.

Let me update TransportSpy to wrap with response_type when given.

response_type(client, data) — PlivoResource.init(self, client, data). So response_type(client, canned).

Let me update the spy.

6/7 pass. The last failure is my spy being too simplistic — the real request wraps data into response_type. Let me make the spy faithful:

edit /sandbox/repo/tests/test_api_construction.py{"content":[{"text":"Successfully replaced 2 block(s) in /sandbox/repo/tests/test_api_construction.py.","type":"text"}],"details":{"diff":" ...\n 29 \n 30…ctrl+o
{"content":[{"text":"Successfully replaced 2 block(s) in /sandbox/repo/tests/test_api_construction.py.","type":"text"}],"details":{"diff":"     ...\n  29 \n  30 class TransportSpy:\n  31     \"\"\"Records every call to RestClient.request and returns canned data.\"\"\"\n  32 \n- 33     def __init__(self, canned):\n+ 33     def __init__(self, client, canned):\n+ 34         self.client = client\n  34         self.calls = []\n  35         self.canned = canned\n  36 \n- 37     def __call__(self, method, path=None, data=None, **kwargs):\n+ 38     def __call__(self, method, path=None, data=None, response_type=None, **kwargs):\n  38         self.calls.append({\"method\": method, \"path\": path, \"data\": data, \"kwargs\": kwargs})\n+ 40         # Mirror the real client: when a response_type is requested, the decoded\n+ 41         # JSON is wrapped in that resource class (giving attribute access).\n+ 42         if response_type is not None:\n+ 43             return response_type(self.client, self.canned)\n  39         return self.canned\n  40 \n  41     @property\n  42     def last(self):\n     ...\n  45 \n  46 def make_client(canned):\n  47     \"\"\"Build a real RestClient with its transport replaced by a spy.\"\"\"\n  48     client = plivo.RestClient(auth_id=FAKE_AUTH_ID, auth_token=FAKE_AUTH_TOKEN)\n- 49     spy = TransportSpy(canned)\n+ 54     spy = TransportSpy(client, canned)\n  50     client.request = spy\n  51     return client, spy\n  52 \n  53 \n     ...","patch":"--- /sandbox/repo/tests/test_api_construction.py\n+++ /sandbox/repo/tests/test_api_construction.py\n@@ -29,14 +29,19 @@\n \n class TransportSpy:\n     \"\"\"Records every call to RestClient.request and returns canned data.\"\"\"\n \n-    def __init__(self, canned):\n+    def __init__(self, client, canned):\n+        self.client = client\n         self.calls = []\n         self.canned = canned\n \n-    def __call__(self, method, path=None, data=None, **kwargs):\n+    def __call__(self, method, path=None, data=None, response_type=None, **kwargs):\n         self.calls.append({\"method\": method, \"path\": path, \"data\": data, \"kwargs\": kwargs})\n+        # Mirror the real client: when a response_type is requested, the decoded\n+        # JSON is wrapped in that resource class (giving attribute access).\n+        if response_type is not None:\n+            return response_type(self.client, self.canned)\n         return self.canned\n \n     @property\n     def last(self):\n@@ -45,9 +50,9 @@\n \n def make_client(canned):\n     \"\"\"Build a real RestClient with its transport replaced by a spy.\"\"\"\n     client = plivo.RestClient(auth_id=FAKE_AUTH_ID, auth_token=FAKE_AUTH_TOKEN)\n-    spy = TransportSpy(canned)\n+    spy = TransportSpy(client, canned)\n     client.request = spy\n     return client, spy\n \n \n","firstChangedLine":33}}
Thinking…

Also I should assert that response_type was passed for pricing. Let me re-run first.

$ cd /sandbox/repo && python3 -m unittest discover -s tests -v 2>&1 | tail -15{"content":[{"text":"test_answer_xml_is_valid_plivo_xml (test_api_construction.TestMakeCall.test_answer_xml_is_valid_plivo_xml) ... ok\ntest_call_posts_to_call_…ctrl+o
{"content":[{"text":"test_answer_xml_is_valid_plivo_xml (test_api_construction.TestMakeCall.test_answer_xml_is_valid_plivo_xml) ... ok\ntest_call_posts_to_call_endpoint (test_api_construction.TestMakeCall.test_call_posts_to_call_endpoint) ... ok\ntest_pricing_gets_country_rate_card (test_api_construction.TestPricing.test_pricing_gets_country_rate_card) ... ok\ntest_media_urls_switch_to_mms (test_api_construction.TestSendSms.test_media_urls_switch_to_mms) ... ok\ntest_powerpack_used_as_source (test_api_construction.TestSendSms.test_powerpack_used_as_source) ... ok\ntest_requires_a_source (test_api_construction.TestSendSms.test_requires_a_source) ... ok\ntest_sms_posts_to_message_endpoint (test_api_construction.TestSendSms.test_sms_posts_to_message_endpoint) ... ok\n\n----------------------------------------------------------------------\nRan 7 tests in 0.002s\n\nOK\n","type":"text"}]}
Thinking…

All 7 tests pass. Let me also verify the pricing reporting path works with a realistic payload — I can test the to_dict/print_tree rendering against a realistic Plivo pricing payload without network. Let me write a quick smoke check.

Let me also test the config error paths (missing .env).

Let me run a quick check on the reporting module with a realistic pricing payload.

Then write the README with pricing explanation + manageability assessment.

Let me do a smoke test of the reporting renderer.

All 7 pass. Let me smoke-test the pricing renderer against a realistic Plivo payload (no network needed):

$ cd /sandbox/repo && python3 -c "{"content":[{"text":"Plivo pricing - United States (US) [all rates USD]\n========================================================\n\ncountry = United States\n…ctrl+o
cd /sandbox/repo && python3 -c "
import plivo
from plivo.resources.pricings import Pricing
from plivo_example.reporting import to_dict, print_tree
from plivo_example.pricing import INTERESTING

# Realistic shape of a GET /Pricing/?country_iso=US response
payload = {
  'api_id': 'x', 'country': 'United States', 'country_iso': 'US',
  'message': {
    'inbound':  {'local': '0.00000', 'international': '0.00000', 'tollfree': '0.00000'},
    'outbound': {'local': '0.00350', 'international': '0.01200', 'tollfree': '0.00400'}},
  'phone_number': {'local': {'rate': '0.80000'}, 'tollfree': {'rate': '2.00000'}},
  'voice': {
    'inbound':  {'local': {'rate': '0.00300'}, 'tollfree': {'rate': '0.01000'}},
    'outbound': {'local': {'rate': '0.00850'}, 'international': {'rate': '0.01900'}}}
}
client = plivo.RestClient(auth_id='MA'+'0'*18, auth_token='t')
p = Pricing(client, payload)
data = to_dict(p)
assert 'client' not in data and '_name' not in data, 'SDK internals leaked'
print('Plivo pricing - %s (%s)   [all rates USD]' % (data['country'], data['country_iso']))
print('='*56)
for s in INTERESTING:
    if s in data:
        v = data[s]
        if isinstance(v, dict):
            print(); print(s+':'); print_tree(v, 1)
        else:
            print(); print('%s = %s' % (s, v))
"

{"content":[{"text":"Plivo pricing - United States (US)   [all rates USD]\n========================================================\n\ncountry = United States\n\ncountry_iso = US\n\nmessage:\n  inbound:\n    local = 0.00000\n    international = 0.00000\n    tollfree = 0.00000\n  outbound:\n    local = 0.00350\n    international = 0.01200\n    tollfree = 0.00400\n\nvoice:\n  inbound:\n    local:\n      rate = 0.00300\n    tollfree:\n      rate = 0.01000\n  outbound:\n    local:\n      rate = 0.00850\n    international:\n      rate = 0.01900\n\nphone_number:\n  local:\n    rate = 0.80000\n  tollfree:\n    rate = 2.00000\n","type":"text"}]}
Thinking…

The renderer works and strips SDK internals. Let me test the config error path (no .env) and verify the module handles it gracefully.

Let me test with .env removed.

Then write the README.

Let me test missing env.

The renderer works correctly. Let me verify the "no credentials at all" path, then write the README.

$ cd /sandbox/repo && mv .env .env.bak && env -u PLIVO_AUTH_ID -u PLIVO_AUTH_TOKEN python3 -m plivo_example.send_sms --dry-run 2>&1; echo "exit=$?"; echo "=== bad auth id format ==="; PLIVO_AUTH_ID=shor…{"content":[{"text":"error: Missing required environment variable(s): PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN\n Copy .env.example -> .env and fill in your credentials …ctrl+o
cd /sandbox/repo && mv .env .env.bak && env -u PLIVO_AUTH_ID -u PLIVO_AUTH_TOKEN python3 -m plivo_example.send_sms --dry-run 2>&1; echo "exit=$?"; echo "=== bad auth id format ==="; PLIVO_AUTH_ID=short PLIVO_AUTH_TOKEN=t python3 -m plivo_example.send_sms --dry-run 2>&1; echo "exit=$?"; mv .env.bak .env

{"content":[{"text":"error: Missing required environment variable(s): PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN\n  Copy .env.example -> .env and fill in your credentials from https://console.plivo.com/\nexit=1\n=== bad auth id format ===\nerror: PLIVO_AUTH_ID 'short' does not look like a Plivo auth id (expected 20 characters starting with 'MA' or 'SA').\nexit=1\n","type":"text"}]}
Thinking…

All error paths give clear guidance. Now let me write the README with:

  1. What this is
  2. Setup
  3. Usage
  4. Pricing explanation (the user asked specifically)
  5. Manageability assessment (the user asked specifically)

For pricing, let me be accurate about Plivo's pricing model. I know Plivo's general model:

  • Pay-as-you-go, no monthly platform fee for the API itself
  • SMS/MMS: per-message, varies by destination country and number type (local/tollfree/international). US outbound SMS is around $0.0035-$0.005/message. Inbound is often free or cheap.
  • Voice: per-minute, varies by destination. US outbound ~$0.0085/min inbound ~$0.003/min
  • Phone numbers: monthly rental. US local ~$0.80/mo, tollfree ~$2/mo
  • Carrier fees: for US A2P 10DLC there are carrier/regulatory fees, campaign registration fees (one-time brand + campaign vetting), and per-message carrier fees
  • Lookup, Verify, etc. have their own per-lookup pricing
  • Volume discounts / committed use

I should be careful to present these as approximate/indicative and direct them to the authoritative source: the Pricing API in this repo and plivo.com/pricing. I should NOT state exact numbers as authoritative since they change. Let me frame them as "indicative, verify with the API."

Let me be honest that I can't fetch live pricing without credentials — that's a blocker I should report.

Let me write the README.

Also, the manageability assessment: I should be honest. Easy parts: SDK is pure HTTP, no infra, tests can mock transport, pricing self-documenting via API. Harder parts: requires real credentials (I don't have them), requires a rented phone number (paid, requires account + address/KYC for some countries), US A2P 10DLC registration is a multi-day compliance process with human review, voice requires a public answer_url (hosting), and inbound webhooks require public HTTPS endpoints. So I can't fully validate end-to-end.

Let me write the README.

All error paths give clear guidance. Now the README with pricing and an honest manageability assessment:

write /sandbox/repo/README.md{"content":[{"text":"Successfully wrote 7254 bytes to /sandbox/repo/README.md","type":"text"}]}
Thinking…

Let me do a final verification: run all tests, verify the whole thing works, check the file tree, and make sure .env isn't committed.

Let me also verify no syntax errors across all files and run a final full check.

Final verification pass:

$ cd /sandbox/repo && echo "=== TESTS ==="; python3 -m unittest discover -s tests 2>&1 | tail -4; echo; echo "=== COMPILE ALL ==="; python3 -m compileall -q plivo_example tests >/dev/null 2>&1 && echo "…{"content":[{"text":"=== TESTS ===\n----------------------------------------------------------------------\nRan 7 tests in 0.003s\n\nOK\n\n=== COMPILE ALL ===\n…ctrl+o
cd /sandbox/repo && echo "=== TESTS ==="; python3 -m unittest discover -s tests 2>&1 | tail -4; echo; echo "=== COMPILE ALL ==="; python3 -m compileall -q plivo_example tests >/dev/null 2>&1 && echo "all files compile OK"; echo; echo "=== CLI HELP SMOKE ==="; for m in send_sms pricing make_call; do python3 -m plivo_example.$m --help >/dev/null 2>&1 && echo "$m --help OK" || echo "$m --help FAILED"; done

{"content":[{"text":"=== TESTS ===\n----------------------------------------------------------------------\nRan 7 tests in 0.003s\n\nOK\n\n=== COMPILE ALL ===\nall files compile OK\n\n=== CLI HELP SMOKE ===\nsend_sms --help OK\npricing --help OK\nmake_call --help OK\n","type":"text"}]}
$ cd /sandbox/repo && echo "=== TREE ==="; find . -path ./.git -prune -o -type f -print | grep -v __pycache__ | sort; echo; echo "=== .env safely ignored? ==="; git status --porcelain | grep -c "\.env$"…{"content":[{"text":"=== TREE ===\n./.env\n./.env.example\n./.gitignore\n./README.md\n./plivo_example/__init__.py\n./plivo_example/config.py\n./plivo_example/ma…ctrl+o
cd /sandbox/repo && echo "=== TREE ==="; find . -path ./.git -prune -o -type f -print | grep -v __pycache__ | sort; echo; echo "=== .env safely ignored? ==="; git status --porcelain | grep -c "\.env$" | xargs -I{} echo "env files staged: {}"; git status --short

{"content":[{"text":"=== TREE ===\n./.env\n./.env.example\n./.gitignore\n./README.md\n./plivo_example/__init__.py\n./plivo_example/config.py\n./plivo_example/make_call.py\n./plivo_example/pricing.py\n./plivo_example/reporting.py\n./plivo_example/send_sms.py\n./requirements.txt\n./tests/__init__.py\n./tests/test_api_construction.py\n\n=== .env safely ignored? ===\nenv files staged: 0\n M .gitignore\n?? .env.example\n?? README.md\n?? plivo_example/\n?? requirements.txt\n?? tests/\n","type":"text"}]}
Thinking…

Everything passes. .env is correctly ignored (not showing in git status), and .env.example is tracked.

Let me clean up the pycache and the .env I created for testing? Actually .env with placeholders is useful for the user to run --dry-run immediately. But it's gitignored so it won't be committed. I'll leave it since it enables the dry-run demo, and it only has placeholders. Let me mention it.

Actually, let me reconsider — leaving a .env with placeholder values is helpful for immediate dry-run. It's gitignored. I'll note it.

Let me clean pycache though (gitignored anyway).

Let me do a final commit? The user didn't ask to commit. Let me not commit but leave the working tree. Actually, it might be nice to leave it uncommitted for review. Let me just report.

Let me clean up pycache.

Everything is done. Let me write the summary.

Let me quickly double check: the user asked "Let me know if you get blocked." I should clearly state the blocker: no credentials → cannot make live calls or fetch live pricing.

Let me finalize.

$ cd /sandbox/repo && find . -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null; rm -rf /tmp/plivotest; echo "cleaned"; echo "=== final dry-run demo ==="; python3 -m plivo_example.send_sms --to +14…{"content":[{"text":"cleaned\n=== final dry-run demo ===\n[dry-run] Would POST /Message/ with:\n src = +14155550123\n dst = +14155550199\n …ctrl+o
cd /sandbox/repo && find . -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null; rm -rf /tmp/plivotest; echo "cleaned"; echo "=== final dry-run demo ==="; python3 -m plivo_example.send_sms --to +14155550199 --text "Ready to go" --dry-run

{"content":[{"text":"cleaned\n=== final dry-run demo ===\n[dry-run] Would POST /Message/ with:\n  src          = +14155550123\n  dst          = +14155550199\n  type         = sms\n  text         = 'Ready to go'\n\nNo API call was made.\n","type":"text"}]}
Thinking…

Everything is done. Let me write the final summary.

I should be concise but cover:

  1. What I built
  2. Pricing (briefly — it's in README)
  3. Manageability assessment
  4. Blockers

Let me keep it tight.

Done. Plivo has a real developer workflow (hosted SDK/API, no local stack), so this was actionable.

What I built — /sandbox/repo

plivo_example/
├── config.py       # creds from env/.env, validated before any network call
├── reporting.py    # consistent response/error printing
├── send_sms.py     # POST /Message/  -> SMS or MMS
├── make_call.py    # POST /Call/     -> voice + Plivo XML builder
└── pricing.py      # GET  /Pricing/  -> live per-country rate card
tests/test_api_construction.py   # 7 tests, no network, no creds
README.md  requirements.txt  .env.example

All 7 tests pass in ~3ms. Nothing long-running was started; no local services.

The tests earned their keep — they caught two real bugs I'd otherwise have shipped:

  • The SDK strips trailing underscores, so type_→type and from_→from on the wire.
  • The SDK rejects a message carrying both src and powerpack_uuid, and it tests is not None — so an empty-string src still collided. Fixed by only passing the source mechanism actually chosen.

I verified request construction by stubbing the transport seam (client.request), which gives real verification without credentials. I also added .env to .gitignore — it wasn't there, and would have leaked credentials.

Pricing

Pay-as-you-go, no platform fee. Four buckets: SMS/MMS per message ($0.003–0.005 US outbound), voice per minute ($0.0085/min US), numbers monthly rental (~$0.80 local, ~$2 tollfree), plus per-use extras (Lookup, Verify).

What actually moves the bill: destination (not origin), number type, and — the one that breaks estimates — US A2P 10DLC carrier fees and campaign vetting, which sit on top of the sticker rate. My numbers are indicative; python -m plivo_example.pricing US returns your account's authoritative rate card.

Is it easy for me to manage?

Yes on the code, partly blocked on the product.

Easy: zero infrastructure, millisecond feedback, single mockable transport seam, self-documenting pricing via API, credentials validated client-side.

Hard: it needs a paid rented number, often with KYC paperwork; US 10DLC registration is human-reviewed and takes days-to-weeks; voice needs a public answer_url; inbound flows need public webhooks. Those are account/human gates I can't complete.

Where I got blocked

No Plivo credentials, so nothing here has actually sent a message or fetched live pricing. I got as far as possible without them — --dry-run on every command, plus transport-level tests proving endpoints and parameters are correct.

To go live: add real creds to .env, rent a number, complete 10DLC for US SMS, then drop --dry-run.

One note: I left a .env with the placeholder values from .env.example so --dry-run works immediately. It's gitignored and contains no secrets — delete it when you add real credentials.

AX Fundamentals

23 checks. Open an area for details.

This check is a quick preview. Test more tasks and models with Gauge Agents.

Test your product with Gauge Agents

AX Check by Gauge