diff --git a/README.md b/README.md index 9e4581d..eda2583 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![PyPI version](https://img.shields.io/pypi/v/checkthatphone)](https://pypi.org/project/checkthatphone/) [![CI](https://github.com/CheckThatPhone/checkthatphone-python/actions/workflows/ci.yml/badge.svg)](https://github.com/CheckThatPhone/checkthatphone-python/actions/workflows/ci.yml) [![python >= 3.9](https://img.shields.io/pypi/pyversions/checkthatphone)](https://pypi.org/project/checkthatphone/) -Official Python client for the [CheckThatPhone](https://checkthatphone.com) phone validation API. Validate US and Canadian phone numbers in real time: carrier and line type from live carrier data, portability and deliverability, GeoIP and timezone, plus optional TCPA litigator screening and a free state do-not-call scrub — one call, one credit. +Official Python client for the [CheckThatPhone](https://checkthatphone.com) phone validation API. Validate US and Canadian phone numbers in real time: carrier and line type from live carrier data, portability and deliverability, GeoIP and timezone, plus optional TCPA litigator screening and free state do-not-call and complainer scrubs — one call, one credit. Zero dependencies (standard library only). Python 3.9+. @@ -40,17 +40,22 @@ if result.data.get("litigator") == "true": suppress(result.data["subscriber"]) ``` -## State DNC scrub (free) +## State DNC and complainer scrub (free) -Screen state do-not-call registries (40 states) and a national complainer list at no extra credit: +Screen state do-not-call registries (38 states plus DC) and a national complainer list at no extra credit. Each check is its own flag: ```python -result = client.lookup("8182925409", dnc_other=True) -result.data.get("dncStateResult") # "STATE DNC" or "" -result.data.get("dncComplainerResult") # "DNC COMPLAINER" or "" -result.data.get("dncStateCovered") # "false" = state not in the data; don't read "" as clear +result = client.lookup("8182925409", dnc_state=True, dnc_complainer=True) +result.data.get("dncStateChecked") # "true" when the state check ran; absent for the 12 states with no registry data +result.data.get("dncStateResult") # "STATE DNC" on a match, "" otherwise (only when dncStateChecked is "true") +result.data.get("dncComplainerChecked") # "true" when the complainer check ran +result.data.get("dncComplainerResult") # "DNC COMPLAINER" on a match, "" otherwise ``` +Read `dncStateChecked` before `dncStateResult`: a number in an uncovered state comes back with neither field, which means not checked, not clear. `"error"` means the check could not complete; treat it as unavailable. + +`dnc_other=True` still works as a shorthand for both checks, but it is deprecated. + ## Landline SMS reachability Some landlines can receive texts. Detect them instead of dropping them (+1 credit, charged only when the number is a landline): diff --git a/checkthatphone/__init__.py b/checkthatphone/__init__.py index 8dd1d13..c7444a6 100644 --- a/checkthatphone/__init__.py +++ b/checkthatphone/__init__.py @@ -9,10 +9,11 @@ import json import urllib.error +import warnings import urllib.request from typing import Any, Optional -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = ["CheckThatPhone", "CheckThatPhoneError", "LookupResult"] _DEFAULT_BASE_URL = "https://api.checkthatphone.com" @@ -71,7 +72,7 @@ class CheckThatPhone: from checkthatphone import CheckThatPhone client = CheckThatPhone(api_key="ctp_live_...") - result = client.lookup("8182925409", litigator_filter=True, dnc_other=True) + result = client.lookup("8182925409", litigator_filter=True, dnc_state=True) if result.data.get("litigator") == "true": ... """ @@ -92,6 +93,8 @@ def lookup( ip: Optional[str] = None, litigator_filter: bool = False, landline_sms_lookup: bool = False, + dnc_state: bool = False, + dnc_complainer: bool = False, dnc_other: bool = False, ) -> LookupResult: """Validate one US/Canada phone number. @@ -104,7 +107,13 @@ def lookup( litigator_filter: TCPA litigator scrub (+1 credit). landline_sms_lookup: landline SMS reachability (+1 credit, charged only when the number is a landline). - dnc_other: state DNC & complainers scrub (free). + dnc_state: state do-not-call registry check (free; 38 states + plus DC). Adds ``dncStateChecked`` and ``dncStateResult``; + both are absent for the 12 states with no registry data, so + absent means not checked, never clear. + dnc_complainer: national complainer-list check (free). Adds + ``dncComplainerChecked`` and ``dncComplainerResult``. + dnc_other: deprecated alias that turns on both DNC checks. Raises: CheckThatPhoneError: for any non-2xx API response. @@ -117,7 +126,15 @@ def lookup( if landline_sms_lookup: body["landlineSmsLookup"] = True if dnc_other: - body["dncOther"] = True + warnings.warn( + "dnc_other is deprecated; use dnc_state and dnc_complainer", + DeprecationWarning, + stacklevel=2, + ) + if dnc_state or dnc_other: + body["dncState"] = True + if dnc_complainer or dnc_other: + body["dncComplainer"] = True req = urllib.request.Request( f"{self._base_url}/v1/lookup", diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..6dc5060 --- /dev/null +++ b/context7.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "CheckThatPhone Python SDK", + "description": "Official Python client for the CheckThatPhone phone validation API: US and Canada carrier lookup, line type, portability, deliverability, GeoIP and timezone, TCPA litigator screening and a free state do-not-call scrub in one call.", + "folders": [], + "excludeFolders": [ + "checkthatphone", + "tests", + ".github" + ], + "excludeFiles": [ + "LICENSE" + ], + "rules": [ + "Phone numbers must be US or Canadian: 10 digits, or 11 digits starting with 1. Strip formatting before calling client.lookup(); anything else is rejected with HTTP 400 and costs nothing.", + "Keep the API key server-side, e.g. CheckThatPhone(api_key=os.environ['CHECKTHATPHONE_API_KEY']). Never embed it in client-side code.", + "Every value in result.data is a string: compare with 'true' / 'false' (result.data.get('deliverable') == 'true'), never with booleans. Use .get(), because add-on fields are absent unless requested.", + "Use result.data['action'] and result.data['deliverable'] as the recommendation for a number. Do not re-derive deliverability from raw carrier fields.", + "Pass ip= (the contact's IP address) whenever you have it: it upgrades location to city level and sets an IANA timezone (result.data['timezone']) at no extra credit. Without it, location falls back to the area code.", + "Add-ons are opt-in keyword arguments: litigator_filter=True costs +1 credit per lookup, landline_sms_lookup=True costs +1 credit only when the number is a landline, dnc_state=True (state do-not-call registries) and dnc_complainer=True (national complainer list) are free. Enable only the add-ons you read. dnc_other is a deprecated alias for both DNC checks.", + "With dnc_state, read result.data.get('dncStateChecked') before dncStateResult: both are absent for the 12 states with no registry data (not checked, never clear), and 'error' means the check could not complete.", + "Catch CheckThatPhoneError and check its retryable attribute before retrying; a 429 means the per-minute rate limit was hit, so back off. Failed and invalid requests cost 0 credits.", + "One lookup costs one credit plus any add-ons (result.credits_used). The free tier is 500 lookups per month and hard-capped, so cache results if you re-check the same numbers often." + ], + "previousVersions": [] +} diff --git a/pyproject.toml b/pyproject.toml index 35df9a8..9b90fa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "checkthatphone" -version = "0.1.0" +version = "0.2.0" description = "Official Python client for the CheckThatPhone phone validation API — US & Canada carrier lookup, line type, TCPA litigator and state DNC screening" readme = "README.md" license = "MIT" diff --git a/tests/test_client.py b/tests/test_client.py index 11c9ddc..46ce74e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -29,18 +29,41 @@ def fake_urlopen(req, timeout=None): with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): client = CheckThatPhone("ctp_live_test") - result = client.lookup("(818) 292-5409", litigator_filter=True, dnc_other=True) + result = client.lookup("(818) 292-5409", litigator_filter=True, dnc_state=True, dnc_complainer=True) self.assertEqual(captured["url"], "https://api.checkthatphone.com/v1/lookup") self.assertEqual( captured["body"], - {"phone": "(818) 292-5409", "litigatorFilter": True, "dncOther": True}, + {"phone": "(818) 292-5409", "litigatorFilter": True, "dncState": True, "dncComplainer": True}, ) self.assertEqual(captured["auth"], "Bearer ctp_live_test") self.assertTrue(result.success) self.assertEqual(result.credits_used, 2) self.assertEqual(result.data["nanpType"], "mobile") + def test_deprecated_dnc_other_sends_both_new_flags(self): + captured = {} + + def fake_urlopen(req, timeout=None): + captured["body"] = json.loads(req.data.decode()) + return _fake_response({"success": True, "credits_used": 1, "data": {}}) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + with self.assertWarns(DeprecationWarning): + CheckThatPhone("k").lookup("8182925409", dnc_other=True) + self.assertEqual(captured["body"], {"phone": "8182925409", "dncState": True, "dncComplainer": True}) + + def test_sends_only_the_requested_dnc_check(self): + captured = {} + + def fake_urlopen(req, timeout=None): + captured["body"] = json.loads(req.data.decode()) + return _fake_response({"success": True, "credits_used": 1, "data": {}}) + + with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + CheckThatPhone("k").lookup("8182925409", dnc_complainer=True) + self.assertEqual(captured["body"], {"phone": "8182925409", "dncComplainer": True}) + def test_http_error_becomes_typed_exception(self): err = urllib.error.HTTPError( url="x",