From 5029bd46e844cb7ed209b1a0805f682f4b10310f Mon Sep 17 00:00:00 2001 From: tjkcc Date: Sat, 3 Oct 2026 10:32:03 -0700 Subject: [PATCH 1/2] Add context7.json for Context7 indexing Title, description and agent-facing rules for the Context7 index (context7.com): phone format, server-side key, string-typed fields, action/deliverable as the recommendation, when to pass ip, add-on credit costs, DNC coverage semantics, retry/429 handling, credits. Co-Authored-By: Claude Fable 5.1 --- context7.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 context7.json diff --git a/context7.json b/context7.json new file mode 100644 index 0000000..dd777c9 --- /dev/null +++ b/context7.json @@ -0,0 +1,20 @@ +{ + "$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_other=True (state DNC registries plus the national complainer list) is free. Enable only the add-ons you read.", + "With dnc_other, a blank dncStateResult means clear only when dncStateCovered is 'true'; some states have no registry data, so treat dncStateCovered 'false' as not checked.", + "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": [] +} From 585c9eafd9cd1a8a5f75b03aa8fd2e1a64addb09 Mon Sep 17 00:00:00 2001 From: tjkcc Date: Sat, 3 Oct 2026 10:45:30 -0700 Subject: [PATCH 2/2] Add dncState / dncComplainer; deprecate dncOther; 0.2.0 The API split the free DNC scrub into two independent checks (state registries, national complainer list) and kept dncOther only as a deprecated alias. The client now exposes both as their own options, translates the old flag into the two new ones on the wire so existing code keeps working, types the new dncStateChecked / dncComplainerChecked fields, and the README states coverage as it is today (38 states plus DC; absent fields mean not checked, never clear). Context7 rules updated to match. Co-Authored-By: Claude Fable 5.1 --- README.md | 19 ++++++++++++------- checkthatphone/__init__.py | 25 +++++++++++++++++++++---- context7.json | 14 ++++++++++---- pyproject.toml | 2 +- tests/test_client.py | 27 +++++++++++++++++++++++++-- 5 files changed, 69 insertions(+), 18 deletions(-) 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 index dd777c9..6dc5060 100644 --- a/context7.json +++ b/context7.json @@ -3,16 +3,22 @@ "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"], + "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_other=True (state DNC registries plus the national complainer list) is free. Enable only the add-ons you read.", - "With dnc_other, a blank dncStateResult means clear only when dncStateCovered is 'true'; some states have no registry data, so treat dncStateCovered 'false' as not checked.", + "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." ], 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",