phonelint 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- phonelint-0.1.0/PKG-INFO +155 -0
- phonelint-0.1.0/README.md +135 -0
- phonelint-0.1.0/pyproject.toml +32 -0
- phonelint-0.1.0/setup.cfg +4 -0
- phonelint-0.1.0/src/phonelint/__init__.py +156 -0
- phonelint-0.1.0/src/phonelint/_data/uk.db +0 -0
- phonelint-0.1.0/src/phonelint.egg-info/PKG-INFO +155 -0
- phonelint-0.1.0/src/phonelint.egg-info/SOURCES.txt +11 -0
- phonelint-0.1.0/src/phonelint.egg-info/dependency_links.txt +1 -0
- phonelint-0.1.0/src/phonelint.egg-info/requires.txt +1 -0
- phonelint-0.1.0/src/phonelint.egg-info/top_level.txt +1 -0
- phonelint-0.1.0/tests/test_phonelint.py +77 -0
phonelint-0.1.0/LICENSE
ADDED
|
@@ -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.
|
phonelint-0.1.0/PKG-INFO
ADDED
|
@@ -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,135 @@
|
|
|
1
|
+
# phonelint
|
|
2
|
+
|
|
3
|
+
**Is this number's range actually assigned to anyone?**
|
|
4
|
+
|
|
5
|
+
`libphonenumber` tells you a number is well-formed and what type it looks like.
|
|
6
|
+
It does not tell you whether the range it sits in has been allocated to an
|
|
7
|
+
operator — and a number in an unallocated range **cannot ring**, however
|
|
8
|
+
perfectly it is formatted.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import phonelint
|
|
12
|
+
|
|
13
|
+
r = phonelint.lookup("+44 1803 930000")
|
|
14
|
+
r.line_type # 'landline'
|
|
15
|
+
r.location # 'Torquay'
|
|
16
|
+
r.allocated # False <- this range is published as Free
|
|
17
|
+
r.reachable # False
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`libphonenumber` calls that same number `FIXED_LINE` and valid. Both are true.
|
|
21
|
+
It still cannot ring.
|
|
22
|
+
|
|
23
|
+
## The comparison, reproducible in ten lines
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import phonenumbers, phonelint
|
|
27
|
+
from phonenumbers import carrier, number_type
|
|
28
|
+
|
|
29
|
+
n = phonenumbers.parse("+441803930000", None)
|
|
30
|
+
print(number_type(n), repr(carrier.name_for_number(n, "en")))
|
|
31
|
+
# 1 '' <- FIXED_LINE, no carrier
|
|
32
|
+
|
|
33
|
+
print(phonelint.lookup("+441803930000"))
|
|
34
|
+
# line_type='landline' location='Torquay' allocated=False reachable=False
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| | libphonenumber | phonelint |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| parse, format, validate | **yes** | delegates to it |
|
|
40
|
+
| line type | yes, where ranges allow | from allocation records |
|
|
41
|
+
| operator the range was allocated to | often empty | yes, where published |
|
|
42
|
+
| **is the range allocated at all** | **no** | **yes** |
|
|
43
|
+
|
|
44
|
+
This is a companion, not a replacement. Parsing is a solved problem and
|
|
45
|
+
`libphonenumber` solved it; this adds the one layer it does not carry.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install phonelint
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`phonenumbers` comes with it. No network calls, no API key, no account. Every
|
|
54
|
+
lookup is local.
|
|
55
|
+
|
|
56
|
+
## Coverage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
phonelint.coverage()
|
|
60
|
+
# {'+44 GB/GG/IM/JE': 345274}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**United Kingdom only, 345,274 prefixes**, from Ofcom's published allocation
|
|
64
|
+
records. GB, Guernsey, Jersey and the Isle of Man share one numbering plan and
|
|
65
|
+
one dataset.
|
|
66
|
+
|
|
67
|
+
**The United States is not bundled.** It is where the gap is widest —
|
|
68
|
+
`libphonenumber` returns `FIXED_LINE_OR_MOBILE` and an empty carrier for
|
|
69
|
+
essentially every US number, because NANP mobile and landline numbers come from
|
|
70
|
+
the same ranges and nothing in the digits separates them. The allocation data
|
|
71
|
+
that answers it is public, but NANPA publishes no terms of use, only a blanket
|
|
72
|
+
copyright footer, and redistributing someone else's data inside a package is a
|
|
73
|
+
different act from reading it. The code is country-agnostic; if that is settled
|
|
74
|
+
the data drops in without an API change.
|
|
75
|
+
|
|
76
|
+
## What this does not do
|
|
77
|
+
|
|
78
|
+
**It does not know about porting.** `carrier` is the operator a *range* was
|
|
79
|
+
allocated to. A subscriber who moved network keeps their number, so a ported
|
|
80
|
+
number still reports the operator it started with. Resolving that needs a live
|
|
81
|
+
query against the routing database, which no offline dataset can answer. If
|
|
82
|
+
your use case turns on current network rather than original allocation, this is
|
|
83
|
+
not the tool and nothing offline is.
|
|
84
|
+
|
|
85
|
+
**It does not tell you a number is in service.** An allocated range means
|
|
86
|
+
numbers in it *can* exist. Whether a particular one is connected today is a
|
|
87
|
+
different question, and again a live one.
|
|
88
|
+
|
|
89
|
+
**`allocated=False` is the firm half.** That one is deterministic: if the
|
|
90
|
+
regulator has assigned the range to nobody, nothing in it rings. Most of the
|
|
91
|
+
value in cleaning a list is there.
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
phonelint.lookup(number: str, region: str | None = None) -> Result
|
|
97
|
+
phonelint.enrich(parsed_phonenumber) -> Result
|
|
98
|
+
phonelint.coverage() -> dict[str, int]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`Result` fields are `None` when unknown and are never guessed:
|
|
102
|
+
|
|
103
|
+
`number` `country` `valid` `allocated` `line_type` `carrier` `location`
|
|
104
|
+
`prefix` `reachable`
|
|
105
|
+
|
|
106
|
+
`region` is a two-letter code, needed only for numbers written in local format
|
|
107
|
+
(`"01803 930000", "GB"`).
|
|
108
|
+
|
|
109
|
+
## Data licence
|
|
110
|
+
|
|
111
|
+
Code is MIT. The bundled UK data is separate:
|
|
112
|
+
|
|
113
|
+
> Contains information licensed under the Open Government Licence v3.0.
|
|
114
|
+
|
|
115
|
+
See [DATA-LICENCE.md](DATA-LICENCE.md). Ofcom does not endorse this package.
|
|
116
|
+
|
|
117
|
+
## Related
|
|
118
|
+
|
|
119
|
+
Built by the people behind [phonelint.com](https://phonelint.com) — a desktop
|
|
120
|
+
bulk list cleaner and a free browser tool that does the same allocation check
|
|
121
|
+
on a whole file without uploading it.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
git clone https://github.com/<owner>/phonelint-py
|
|
127
|
+
cd phonelint-py
|
|
128
|
+
pip install -e . pytest
|
|
129
|
+
pytest -q
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`pip install -e .` matters: this is a `src/` layout, so `pytest` on a fresh
|
|
133
|
+
clone fails with `ModuleNotFoundError: No module named 'phonelint'` until the
|
|
134
|
+
package is installed, or `PYTHONPATH=src` is set. That is the layout working as
|
|
135
|
+
intended, not a broken checkout.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "phonelint"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Carrier, line type and block allocation for phone numbers, on top of libphonenumber"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Phonelint" }]
|
|
13
|
+
keywords = ["phone", "telephone", "carrier", "line-type", "numbering-plan",
|
|
14
|
+
"libphonenumber", "validation", "ofcom", "msisdn"]
|
|
15
|
+
dependencies = ["phonenumbers>=8.13"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Communications :: Telephony",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://phonelint.com"
|
|
26
|
+
Source = "https://github.com/phonelint/phonelint-py"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
phonelint = ["_data/*.db"]
|
|
@@ -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))
|
|
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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/phonelint/__init__.py
|
|
5
|
+
src/phonelint.egg-info/PKG-INFO
|
|
6
|
+
src/phonelint.egg-info/SOURCES.txt
|
|
7
|
+
src/phonelint.egg-info/dependency_links.txt
|
|
8
|
+
src/phonelint.egg-info/requires.txt
|
|
9
|
+
src/phonelint.egg-info/top_level.txt
|
|
10
|
+
src/phonelint/_data/uk.db
|
|
11
|
+
tests/test_phonelint.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phonenumbers>=8.13
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phonelint
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests pinned to real published ranges, not to invented numbers.
|
|
2
|
+
|
|
3
|
+
Every expectation below is checked against the bundled Ofcom extract rather
|
|
4
|
+
than asserted from memory, because a test that encodes a guess passes forever
|
|
5
|
+
and proves nothing.
|
|
6
|
+
"""
|
|
7
|
+
import phonenumbers
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import phonelint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_coverage_reports_one_dataset_not_four():
|
|
14
|
+
cov = phonelint.coverage()
|
|
15
|
+
assert len(cov) == 1, "GB/GG/JE/IM share one plan and must not be counted four times"
|
|
16
|
+
assert next(iter(cov.values())) > 300_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_allocated_mobile_returns_its_operator():
|
|
20
|
+
r = phonelint.lookup("+447911123456")
|
|
21
|
+
assert r.line_type == "mobile"
|
|
22
|
+
assert r.carrier # an operator, whichever it is
|
|
23
|
+
assert r.allocated is True
|
|
24
|
+
assert r.reachable is True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_free_range_is_reported_unreachable():
|
|
28
|
+
# 01803 930xxx is published as Free: correctly formatted, cannot ring.
|
|
29
|
+
r = phonelint.lookup("+441803930000")
|
|
30
|
+
assert r.valid is True, "libphonenumber considers it a possible number"
|
|
31
|
+
assert r.allocated is False
|
|
32
|
+
assert r.reachable is False, "this is the distinction the package exists for"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_channel_islands_resolve_to_the_uk_plan():
|
|
36
|
+
# libphonenumber returns GG for this, not GB. Mapping only GB silently
|
|
37
|
+
# missed every Guernsey, Jersey and Isle of Man number.
|
|
38
|
+
r = phonelint.lookup("+447911123456")
|
|
39
|
+
assert r.country in {"GG", "GB", "JE", "IM"}
|
|
40
|
+
assert r.line_type is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_longest_prefix_wins():
|
|
44
|
+
r = phonelint.lookup("+442079460958")
|
|
45
|
+
assert r.prefix and len(r.prefix) >= 5
|
|
46
|
+
assert r.location
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_unknown_country_degrades_without_guessing():
|
|
50
|
+
r = phonelint.lookup("+12125550147") # US: no data bundled
|
|
51
|
+
assert r.country == "US"
|
|
52
|
+
assert r.line_type is None and r.carrier is None
|
|
53
|
+
assert r.allocated is None, "absent data must read as unknown, never as False"
|
|
54
|
+
assert r.reachable is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_garbage_input_does_not_raise():
|
|
58
|
+
for bad in ["", "not a number", "+", "++44", "0000"]:
|
|
59
|
+
assert phonelint.lookup(bad).line_type is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_local_format_needs_a_region():
|
|
63
|
+
assert phonelint.lookup("01803 930000").country is None
|
|
64
|
+
assert phonelint.lookup("01803 930000", "GB").country == "GB"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_enrich_accepts_a_parsed_number():
|
|
68
|
+
n = phonenumbers.parse("+441803930000", None)
|
|
69
|
+
assert phonelint.enrich(n).allocated is False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_it_adds_what_libphonenumber_does_not():
|
|
73
|
+
"""The package's entire reason for existing, as an assertion."""
|
|
74
|
+
from phonenumbers import carrier, number_type, PhoneNumberType
|
|
75
|
+
n = phonenumbers.parse("+441803930000", None)
|
|
76
|
+
assert carrier.name_for_number(n, "en") == "" # libphonenumber: nothing
|
|
77
|
+
assert phonelint.lookup("+441803930000").location # ours: Torquay
|