phonelint 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phonelint/__init__.py ADDED
@@ -0,0 +1,156 @@
1
+ """Carrier and line type for phone numbers, from published numbering plans.
2
+
3
+ libphonenumber parses and formats; it does not tell you who a number belongs to.
4
+ For a UK or US number its `carrier` is usually empty and its type is often
5
+ FIXED_LINE_OR_MOBILE, because that is as far as range metadata goes. This fills
6
+ exactly that gap and nothing else:
7
+
8
+ >>> import phonelint
9
+ >>> r = phonelint.lookup("+44 1803 930000")
10
+ >>> r.line_type, r.location, r.allocated
11
+ ('landline', 'Torquay', False)
12
+
13
+ It is built ON libphonenumber rather than against it. Parsing, validation and
14
+ formatting are delegated to `phonenumbers`, which does them well. What is added
15
+ is the block the number sits in, taken from the regulator's own allocation
16
+ records.
17
+
18
+ The most useful field is the one nobody else returns: `allocated`. A number in
19
+ a range no operator has been assigned **cannot ring**, whatever its shape. That
20
+ is deterministic, and it is where most of the value in list cleaning sits.
21
+
22
+ Coverage: United Kingdom, 345,274 prefixes. United States is not bundled — see
23
+ the README; the data exists but its redistribution terms do not permit shipping
24
+ it here yet.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import functools
29
+ import pathlib
30
+ import sqlite3
31
+ from dataclasses import dataclass
32
+ from typing import Optional
33
+
34
+ __all__ = ["lookup", "Result", "enrich", "coverage", "__version__"]
35
+ __version__ = "0.1.0"
36
+
37
+ _DATA = pathlib.Path(__file__).parent / "_data"
38
+ # +44 is one numbering plan across four jurisdictions. libphonenumber returns
39
+ # the ISO region, so a Guernsey or Jersey number comes back GG or JE and would
40
+ # miss the dataset entirely — a mobile that resolved to "Guernsey" was the bug
41
+ # that found this.
42
+ _SUPPORTED = {"GB": "uk.db", "GG": "uk.db", "JE": "uk.db", "IM": "uk.db"}
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Result:
47
+ """What is known about a number. Fields are None when unknown, never guessed."""
48
+
49
+ number: str
50
+ country: Optional[str] = None
51
+ valid: Optional[bool] = None # from libphonenumber: is it a possible number
52
+ allocated: Optional[bool] = None # is its block assigned to an operator
53
+ line_type: Optional[str] = None # landline | mobile | voip | freephone | ...
54
+ carrier: Optional[str] = None # the operator the BLOCK was allocated to
55
+ location: Optional[str] = None # the exchange area, where published
56
+ prefix: Optional[str] = None # the block that matched
57
+
58
+ @property
59
+ def reachable(self) -> Optional[bool]:
60
+ """False when the number provably cannot ring. None when not known.
61
+
62
+ A number outside an allocated block is dead regardless of how well it
63
+ is formatted, and that is the one judgement here that is deterministic
64
+ rather than probabilistic.
65
+ """
66
+ if self.valid is False or self.allocated is False:
67
+ return False
68
+ if self.valid and self.allocated:
69
+ return True
70
+ return None
71
+
72
+
73
+ @functools.lru_cache(maxsize=4)
74
+ def _db(country: str) -> Optional[sqlite3.Connection]:
75
+ name = _SUPPORTED.get(country)
76
+ if not name:
77
+ return None
78
+ path = _DATA / name
79
+ if not path.exists():
80
+ return None
81
+ return sqlite3.connect(f"file:{path}?mode=ro", uri=True, check_same_thread=False)
82
+
83
+
84
+ def coverage() -> dict[str, int]:
85
+ """Prefix count per bundled DATASET, so callers can check before relying on it.
86
+
87
+ Keyed by dataset, not by region: GB, GG, JE and IM share one numbering plan
88
+ and one file, and reporting 345,274 four times would imply four times the
89
+ data.
90
+ """
91
+ out = {}
92
+ for name in sorted(set(_SUPPORTED.values())):
93
+ path = _DATA / name
94
+ if not path.exists():
95
+ continue
96
+ regions = sorted(k for k, v in _SUPPORTED.items() if v == name)
97
+ db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
98
+ out["+44 " + "/".join(regions)] = db.execute(
99
+ "SELECT COUNT(*) FROM blocks").fetchone()[0]
100
+ db.close()
101
+ return out
102
+
103
+
104
+ def _match(db: sqlite3.Connection, national: str):
105
+ """Longest matching block.
106
+
107
+ Allocation records are published at several depths — a six-digit range and
108
+ a seven-digit range can both exist, and the longer one is the more specific
109
+ truth. Matching shortest-first would answer with the parent block and get
110
+ the operator wrong whenever a range has been subdivided.
111
+ """
112
+ for end in range(len(national), 2, -1):
113
+ row = db.execute(
114
+ "SELECT prefix, line_type, carrier, location, allocated "
115
+ "FROM blocks WHERE prefix = ?", (national[:end],)
116
+ ).fetchone()
117
+ if row:
118
+ return row
119
+ return None
120
+
121
+
122
+ def lookup(number: str, region: Optional[str] = None) -> Result:
123
+ """Look up `number`. `region` is a two-letter code for local-format input."""
124
+ import phonenumbers
125
+
126
+ try:
127
+ parsed = phonenumbers.parse(number, region)
128
+ except phonenumbers.NumberParseException:
129
+ return Result(number=number)
130
+
131
+ country = phonenumbers.region_code_for_number(parsed)
132
+ valid = phonenumbers.is_valid_number(parsed)
133
+ national = str(parsed.national_number)
134
+
135
+ db = _db(country or "")
136
+ if db is None:
137
+ # No data bundled for this country. Report what libphonenumber knows
138
+ # and leave the rest None rather than inventing a confident blank.
139
+ return Result(number=number, country=country, valid=valid)
140
+
141
+ row = _match(db, national)
142
+ if row is None:
143
+ return Result(number=number, country=country, valid=valid, allocated=False)
144
+
145
+ prefix, line_type, carrier, location, allocated = row
146
+ return Result(number=number, country=country, valid=valid,
147
+ allocated=bool(allocated), line_type=line_type,
148
+ carrier=carrier or None, location=location or None,
149
+ prefix=prefix)
150
+
151
+
152
+ def enrich(parsed) -> Result:
153
+ """Same as `lookup`, for a number already parsed by `phonenumbers`."""
154
+ import phonenumbers
155
+ return lookup(phonenumbers.format_number(
156
+ parsed, phonenumbers.PhoneNumberFormat.E164))
phonelint/_data/uk.db ADDED
Binary file
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: phonelint
3
+ Version: 0.1.0
4
+ Summary: Carrier, line type and block allocation for phone numbers, on top of libphonenumber
5
+ Author: Phonelint
6
+ License: MIT
7
+ Project-URL: Homepage, https://phonelint.com
8
+ Project-URL: Source, https://github.com/phonelint/phonelint-py
9
+ Keywords: phone,telephone,carrier,line-type,numbering-plan,libphonenumber,validation,ofcom,msisdn
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Communications :: Telephony
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: phonenumbers>=8.13
19
+ Dynamic: license-file
20
+
21
+ # phonelint
22
+
23
+ **Is this number's range actually assigned to anyone?**
24
+
25
+ `libphonenumber` tells you a number is well-formed and what type it looks like.
26
+ It does not tell you whether the range it sits in has been allocated to an
27
+ operator — and a number in an unallocated range **cannot ring**, however
28
+ perfectly it is formatted.
29
+
30
+ ```python
31
+ import phonelint
32
+
33
+ r = phonelint.lookup("+44 1803 930000")
34
+ r.line_type # 'landline'
35
+ r.location # 'Torquay'
36
+ r.allocated # False <- this range is published as Free
37
+ r.reachable # False
38
+ ```
39
+
40
+ `libphonenumber` calls that same number `FIXED_LINE` and valid. Both are true.
41
+ It still cannot ring.
42
+
43
+ ## The comparison, reproducible in ten lines
44
+
45
+ ```python
46
+ import phonenumbers, phonelint
47
+ from phonenumbers import carrier, number_type
48
+
49
+ n = phonenumbers.parse("+441803930000", None)
50
+ print(number_type(n), repr(carrier.name_for_number(n, "en")))
51
+ # 1 '' <- FIXED_LINE, no carrier
52
+
53
+ print(phonelint.lookup("+441803930000"))
54
+ # line_type='landline' location='Torquay' allocated=False reachable=False
55
+ ```
56
+
57
+ | | libphonenumber | phonelint |
58
+ |---|---|---|
59
+ | parse, format, validate | **yes** | delegates to it |
60
+ | line type | yes, where ranges allow | from allocation records |
61
+ | operator the range was allocated to | often empty | yes, where published |
62
+ | **is the range allocated at all** | **no** | **yes** |
63
+
64
+ This is a companion, not a replacement. Parsing is a solved problem and
65
+ `libphonenumber` solved it; this adds the one layer it does not carry.
66
+
67
+ ## Install
68
+
69
+ ```
70
+ pip install phonelint
71
+ ```
72
+
73
+ `phonenumbers` comes with it. No network calls, no API key, no account. Every
74
+ lookup is local.
75
+
76
+ ## Coverage
77
+
78
+ ```python
79
+ phonelint.coverage()
80
+ # {'+44 GB/GG/IM/JE': 345274}
81
+ ```
82
+
83
+ **United Kingdom only, 345,274 prefixes**, from Ofcom's published allocation
84
+ records. GB, Guernsey, Jersey and the Isle of Man share one numbering plan and
85
+ one dataset.
86
+
87
+ **The United States is not bundled.** It is where the gap is widest —
88
+ `libphonenumber` returns `FIXED_LINE_OR_MOBILE` and an empty carrier for
89
+ essentially every US number, because NANP mobile and landline numbers come from
90
+ the same ranges and nothing in the digits separates them. The allocation data
91
+ that answers it is public, but NANPA publishes no terms of use, only a blanket
92
+ copyright footer, and redistributing someone else's data inside a package is a
93
+ different act from reading it. The code is country-agnostic; if that is settled
94
+ the data drops in without an API change.
95
+
96
+ ## What this does not do
97
+
98
+ **It does not know about porting.** `carrier` is the operator a *range* was
99
+ allocated to. A subscriber who moved network keeps their number, so a ported
100
+ number still reports the operator it started with. Resolving that needs a live
101
+ query against the routing database, which no offline dataset can answer. If
102
+ your use case turns on current network rather than original allocation, this is
103
+ not the tool and nothing offline is.
104
+
105
+ **It does not tell you a number is in service.** An allocated range means
106
+ numbers in it *can* exist. Whether a particular one is connected today is a
107
+ different question, and again a live one.
108
+
109
+ **`allocated=False` is the firm half.** That one is deterministic: if the
110
+ regulator has assigned the range to nobody, nothing in it rings. Most of the
111
+ value in cleaning a list is there.
112
+
113
+ ## API
114
+
115
+ ```python
116
+ phonelint.lookup(number: str, region: str | None = None) -> Result
117
+ phonelint.enrich(parsed_phonenumber) -> Result
118
+ phonelint.coverage() -> dict[str, int]
119
+ ```
120
+
121
+ `Result` fields are `None` when unknown and are never guessed:
122
+
123
+ `number` `country` `valid` `allocated` `line_type` `carrier` `location`
124
+ `prefix` `reachable`
125
+
126
+ `region` is a two-letter code, needed only for numbers written in local format
127
+ (`"01803 930000", "GB"`).
128
+
129
+ ## Data licence
130
+
131
+ Code is MIT. The bundled UK data is separate:
132
+
133
+ > Contains information licensed under the Open Government Licence v3.0.
134
+
135
+ See [DATA-LICENCE.md](DATA-LICENCE.md). Ofcom does not endorse this package.
136
+
137
+ ## Related
138
+
139
+ Built by the people behind [phonelint.com](https://phonelint.com) — a desktop
140
+ bulk list cleaner and a free browser tool that does the same allocation check
141
+ on a whole file without uploading it.
142
+
143
+ ## Development
144
+
145
+ ```
146
+ git clone https://github.com/<owner>/phonelint-py
147
+ cd phonelint-py
148
+ pip install -e . pytest
149
+ pytest -q
150
+ ```
151
+
152
+ `pip install -e .` matters: this is a `src/` layout, so `pytest` on a fresh
153
+ clone fails with `ModuleNotFoundError: No module named 'phonelint'` until the
154
+ package is installed, or `PYTHONPATH=src` is set. That is the layout working as
155
+ intended, not a broken checkout.
@@ -0,0 +1,7 @@
1
+ phonelint/__init__.py,sha256=6PATKqlUGYeGw5a5ySl8AYSG5tCRFJwZY6gwZgEgxgU,6048
2
+ phonelint/_data/uk.db,sha256=nRSMFHaVt_PlNMVRMoT4Vi0RoHdRaxiWKp_jORSel9M,13438976
3
+ phonelint-0.1.0.dist-info/licenses/LICENSE,sha256=yRzfu7MD0y0cpNoFj7a_GWodg0YHJH-evyeMld-mRVk,1066
4
+ phonelint-0.1.0.dist-info/METADATA,sha256=CuP498iES_-a2zdSmu-mbiuV0ZEYUOkZwJwK_U50Pzg,5323
5
+ phonelint-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ phonelint-0.1.0.dist-info/top_level.txt,sha256=PloyMK_pJp0EFWWGEJJrRjmRJBUh_FL4ouLZrFOpJGY,10
7
+ phonelint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Phonelint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ phonelint