teluri 0.2.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.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - `TelURIError.reason` from the closed set `Reason`; errors survive pickling.
14
+ - Full RFC 3966 grammar: ASCII digits only, domain names per the ABNF, `pvalue` and
15
+ percent-encoding checks, `isub` per erratum 203, `ext` and `isub` mutually exclusive.
16
+ - `parse(..., strict=True)` enforces the section 3 parameter order.
17
+ - `equivalent(a, b)`, and `==` and `hash` on `TelURI`, follow the section 4 rules.
18
+ - `TelURI.isub`, `is_e164`, `unknown_mandatory` and `href`.
19
+ - `parse_link` reads real-world `tel:` links (spaces, encoded numbers, missing context).
20
+ - `build` accepts `isub`, strips spaces from printed numbers and encodes values.
21
+ - `teluri` command line with `--json`, `--strict`, `--link` and `--context`.
22
+
23
+ ### Changed
24
+
25
+ - `str(TelURI)` writes the canonical form: section 3 order, lower-case parameter names.
26
+ - `TelURI(number, ext, isub, phone_context, params)`: `is_global` is now derived from the
27
+ number, every field is validated on construction (wrong types raise `TypeError`), and
28
+ `params` is read-only and hashable.
29
+ - `params` values are percent-decoded; `isub` has its own field.
30
+ - `build` percent-encodes `params` values, so callers that passed encoded values must now
31
+ pass plain text.
32
+ - `TelURIError(reason, message)` replaces the single message argument.
33
+
34
+ ## [0.1.0]
35
+
36
+ - First release: parse and build tel: URIs per RFC 3966.
37
+ - `parse` reads global numbers (leading `+`) and local numbers with a
38
+ `phone-context`, strips visual separators, and keeps other parameters verbatim.
39
+ - `build` composes a tel: URI from a number, optional `ext`, optional
40
+ `phone-context` and extra parameters, normalizing visual separators.
41
+ - `TelURI` is a frozen, typed result whose `str()` round-trips through `build`.
42
+ - `TelURIError` signals malformed input.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/teluri/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
teluri-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
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.
teluri-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.5
2
+ Name: teluri
3
+ Version: 0.2.0
4
+ Summary: Strict, typed, zero-dependency parser, builder and comparator for tel: URIs (RFC 3966), with a reader for real-world tel: links
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/teluri
7
+ Project-URL: Issues, https://github.com/JaviLianes8/teluri/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/teluri/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: e164,href,link,parser,phone,phone-number,rfc3966,sip,tel,telephone,uri
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Communications :: Telephony
26
+ Classifier: Topic :: Software Development :: Libraries
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Provides-Extra: dev
30
+ Requires-Dist: build; extra == 'dev'
31
+ Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pytest; extra == 'dev'
33
+ Requires-Dist: ruff; extra == 'dev'
34
+ Requires-Dist: twine; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # teluri
38
+
39
+ Strict, typed, zero-dependency parser, builder and comparator for `tel:` URIs
40
+ (RFC 3966), with a reader for the `tel:` links found in real web pages and apps.
41
+
42
+ ## Install
43
+
44
+ ```
45
+ pip install teluri
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ### Parse
51
+
52
+ ```python
53
+ from teluri import parse
54
+
55
+ uri = parse("tel:+1-201-555-0123;ext=1234")
56
+ print(uri.number) # +12015550123
57
+ print(uri.is_global) # True
58
+ print(uri.ext) # 1234
59
+ print(uri.is_e164) # True
60
+ print(uri) # tel:+12015550123;ext=1234
61
+
62
+ local = parse("tel:863-1234;phone-context=+1-914-555")
63
+ print(local.number) # 8631234
64
+ print(local.phone_context) # +1914555
65
+ print(local.is_global) # False
66
+ ```
67
+
68
+ `parse` validates the whole RFC 3966 grammar and returns a frozen `TelURI`. Visual
69
+ separators (`-`, `.`, `(`, `)`) are removed from the number, the extension and a numeric
70
+ `phone-context`. Other parameters land in `params`, keyed by lower-case name, with
71
+ percent-decoded values and `None` for a parameter without a value. `str()` gives a
72
+ normalised form: separators removed, names in lower case, values re-encoded, and `ext` or
73
+ `isub` first, then `phone-context`, then the rest sorted by name, as section 3 asks. The
74
+ letter case of numbers, contexts and values is kept, so two equivalent URIs may still
75
+ print differently. Wrong types (`parse(None)`, `TelURI(123)`) raise `TypeError`.
76
+
77
+ ### Errors
78
+
79
+ ```python
80
+ from teluri import TelURIError, parse
81
+
82
+ reasons = []
83
+ for bad in ["tel:863-1234", "tel:+1;ext=1;isub=2", "tel:+1;b;a"]:
84
+ try:
85
+ parse(bad, strict=True)
86
+ except TelURIError as error:
87
+ reasons.append(error.reason)
88
+ print(reasons) # ['missing-context', 'ext-and-isub', 'param-order']
89
+ ```
90
+
91
+ The ABNF accepts parameters in any order, so `parse` does too; `strict=True` also
92
+ enforces the order rule of section 3, comparing names in lower case.
93
+
94
+ ### Compare
95
+
96
+ ```python
97
+ from teluri import equivalent, parse
98
+
99
+ print(equivalent("tel:+1-201-555-0123;EXT=1;a=X", "tel:+12015550123;a=x;ext=1")) # True
100
+ domain = "tel:7042;phone-context=Example.COM."
101
+ print(equivalent(domain, "tel:7042;phone-context=example.com")) # True
102
+ print(equivalent("tel:+1234", "tel:1234;phone-context=+1")) # False
103
+ print(len({parse("tel:+1;b;a"), parse("tel:+1;A;B")})) # 1
104
+ ```
105
+
106
+ `==`, `hash` and `equivalent` follow section 4: global and local numbers never match,
107
+ separators are ignored, a numeric context is compared digit by digit and a domain context
108
+ as a host name (a final root dot is ignored), parameters are compared by name in any
109
+ order, and the comparison is case-insensitive. Values, including `isub`, are compared as
110
+ their percent-decoded octets with ASCII letters folded, so `%2F` equals `/` and `%41`
111
+ equals `a`, while `%C3%A9` and `%C3%89` stay different.
112
+
113
+ ### Build
114
+
115
+ ```python
116
+ from teluri import build
117
+
118
+ print(build("+1 (201) 555-0123", ext="1234")) # tel:+12015550123;ext=1234
119
+ print(build("7042", phone_context="example.com")) # tel:7042;phone-context=example.com
120
+ print(build("+1 201 555 0123", params={"note": "a;b c"})) # tel:+12015550123;note=a%3Bb%20c
121
+ ```
122
+
123
+ `build` takes human input: it removes spaces and separators from the numbers, encodes
124
+ parameter values, refuses `ext`, `isub` and `phone-context` inside `params`, and always
125
+ writes the canonical order. The result always parses back with `strict=True`.
126
+
127
+ ### Links in the wild
128
+
129
+ ```python
130
+ from teluri import parse_link
131
+
132
+ print(parse_link("tel:+1 201 555 0123")) # tel:+12015550123
133
+ print(parse_link("tel:1-408-555-5555", context="+1")) # tel:14085555555;phone-context=+1
134
+ print(parse_link("tel:*31%23", context="example.com").href) # tel:*31%23;phone-context=example.com
135
+ ```
136
+
137
+ Web pages and apps often write `tel:` links that RFC 3966 does not allow: spaces as in
138
+ printed numbers, percent-encoded numbers, and local numbers without a `phone-context`
139
+ (Apple's Phone Links reference shows `tel:1-408-555-5555`). `parse_link` decodes and
140
+ cleans the number, adds `context` (checked as a single phone-context descriptor) to a
141
+ local number that has none, then validates the result as RFC 3966. In the other direction, `TelURI.href` writes `#` as `%23`: an HTML
142
+ URL parser would otherwise read it as the start of a fragment. Apple also documents that
143
+ its Phone app does not dial a link containing `*` or `#`.
144
+
145
+ ### Command line
146
+
147
+ ```
148
+ $ teluri "tel:+1-201-555-0123;Ext=9" "tel:863-1234"
149
+ tel:+12015550123;ext=9
150
+ invalid (missing-context): tel:863-1234
151
+ ```
152
+
153
+ One URI per argument, or one per line on standard input. `--json` prints every field,
154
+ `--strict` enforces the parameter order, `--link` reads links as above (not with
155
+ `--strict`) and `--context` gives local links their context. Output is ASCII, so any
156
+ console can print it. The exit status is 1 when any input is invalid, 2 for bad usage.
157
+
158
+ ## API
159
+
160
+ | Name | What it does |
161
+ | --- | --- |
162
+ | `parse(uri, *, strict=False)` | Parse and validate a `tel:` URI, return a `TelURI` |
163
+ | `parse_link(href, *, context=None)` | Read a real-world `tel:` link, return a `TelURI` |
164
+ | `build(number, *, ext, isub, phone_context, params)` | Canonical `tel:` URI from human input |
165
+ | `equivalent(a, b)` | Section 4 comparison of two URIs (strings or `TelURI`) |
166
+ | `TelURI` | Frozen, hashable result: `number`, `ext`, `isub`, `phone_context`, `params`, `is_global`, `is_e164`, `unknown_mandatory`, `href` |
167
+ | `TelURIError` | `ValueError` with a `reason` from `Reason` |
168
+ | `Reason` | The closed set of reasons, listed below |
169
+
170
+ `is_e164` checks the E.164 limits only: a global number of at most 15 digits whose
171
+ country code does not start with 0. `unknown_mandatory` lists `m-` parameters; section
172
+ 5.4 says not to use a URI whose mandatory parameters you do not understand. `isub` is
173
+ kept in its encoded form because a subaddress may carry any octet.
174
+
175
+ ### Reasons
176
+
177
+ - `scheme`: not a `tel:` URI
178
+ - `number`: the number is empty or has a character the grammar does not allow
179
+ - `ext`: the extension has no digit, or a character other than digits and separators
180
+ - `isub`: the subaddress is empty or not `1*paramchar` (erratum 203)
181
+ - `ext-and-isub`: both are present, which section 5.3 forbids
182
+ - `phone-context`: the context is neither a global number prefix nor a domain name
183
+ - `missing-context`: a local number without `phone-context`
184
+ - `global-context`: a global number with `phone-context`
185
+ - `param-name`: a parameter name is empty, malformed, or reserved for a field
186
+ - `param-value`: a value is empty, malformed, badly percent-encoded or not UTF-8
187
+ - `duplicate-param`: a parameter appears twice (names compare case-insensitively)
188
+ - `param-order`: with `strict=True`, parameters are not in the section 3 order
189
+
190
+ ## Why
191
+
192
+ RFC 3966 `tel:` URIs are their own grammar: global numbers with a leading `+`, local
193
+ numbers that must carry a `phone-context`, visual separators, `ext` and `isub`, and a
194
+ comparison rule that ignores separators, case and parameter order. The standard library
195
+ does not model any of it, general URI libraries stop at RFC 3986, and `phonenumbers` is a
196
+ number-validation database rather than a URI codec. teluri is a small, strict, typed one,
197
+ with the browser and phone dialect handled explicitly instead of by accident.
198
+
199
+ ## Sources
200
+
201
+ RFC 3966 and its errata, the IANA tel URI parameter registry, RFC 4694, 4715, 4759 and
202
+ 4904 for the registered parameters. Their examples are checked by the test suite; run
203
+ `python scripts/fetch_upstream.py` to fetch the texts and enable the tests that compare
204
+ against them.
205
+
206
+ ## License
207
+
208
+ MIT. See [LICENSE](LICENSE).
209
+
210
+ ## Links
211
+
212
+ - Website: https://lianes8studio.com
213
+ - GitHub: https://github.com/JaviLianes8
214
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
teluri-0.2.0/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # teluri
2
+
3
+ Strict, typed, zero-dependency parser, builder and comparator for `tel:` URIs
4
+ (RFC 3966), with a reader for the `tel:` links found in real web pages and apps.
5
+
6
+ ## Install
7
+
8
+ ```
9
+ pip install teluri
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ### Parse
15
+
16
+ ```python
17
+ from teluri import parse
18
+
19
+ uri = parse("tel:+1-201-555-0123;ext=1234")
20
+ print(uri.number) # +12015550123
21
+ print(uri.is_global) # True
22
+ print(uri.ext) # 1234
23
+ print(uri.is_e164) # True
24
+ print(uri) # tel:+12015550123;ext=1234
25
+
26
+ local = parse("tel:863-1234;phone-context=+1-914-555")
27
+ print(local.number) # 8631234
28
+ print(local.phone_context) # +1914555
29
+ print(local.is_global) # False
30
+ ```
31
+
32
+ `parse` validates the whole RFC 3966 grammar and returns a frozen `TelURI`. Visual
33
+ separators (`-`, `.`, `(`, `)`) are removed from the number, the extension and a numeric
34
+ `phone-context`. Other parameters land in `params`, keyed by lower-case name, with
35
+ percent-decoded values and `None` for a parameter without a value. `str()` gives a
36
+ normalised form: separators removed, names in lower case, values re-encoded, and `ext` or
37
+ `isub` first, then `phone-context`, then the rest sorted by name, as section 3 asks. The
38
+ letter case of numbers, contexts and values is kept, so two equivalent URIs may still
39
+ print differently. Wrong types (`parse(None)`, `TelURI(123)`) raise `TypeError`.
40
+
41
+ ### Errors
42
+
43
+ ```python
44
+ from teluri import TelURIError, parse
45
+
46
+ reasons = []
47
+ for bad in ["tel:863-1234", "tel:+1;ext=1;isub=2", "tel:+1;b;a"]:
48
+ try:
49
+ parse(bad, strict=True)
50
+ except TelURIError as error:
51
+ reasons.append(error.reason)
52
+ print(reasons) # ['missing-context', 'ext-and-isub', 'param-order']
53
+ ```
54
+
55
+ The ABNF accepts parameters in any order, so `parse` does too; `strict=True` also
56
+ enforces the order rule of section 3, comparing names in lower case.
57
+
58
+ ### Compare
59
+
60
+ ```python
61
+ from teluri import equivalent, parse
62
+
63
+ print(equivalent("tel:+1-201-555-0123;EXT=1;a=X", "tel:+12015550123;a=x;ext=1")) # True
64
+ domain = "tel:7042;phone-context=Example.COM."
65
+ print(equivalent(domain, "tel:7042;phone-context=example.com")) # True
66
+ print(equivalent("tel:+1234", "tel:1234;phone-context=+1")) # False
67
+ print(len({parse("tel:+1;b;a"), parse("tel:+1;A;B")})) # 1
68
+ ```
69
+
70
+ `==`, `hash` and `equivalent` follow section 4: global and local numbers never match,
71
+ separators are ignored, a numeric context is compared digit by digit and a domain context
72
+ as a host name (a final root dot is ignored), parameters are compared by name in any
73
+ order, and the comparison is case-insensitive. Values, including `isub`, are compared as
74
+ their percent-decoded octets with ASCII letters folded, so `%2F` equals `/` and `%41`
75
+ equals `a`, while `%C3%A9` and `%C3%89` stay different.
76
+
77
+ ### Build
78
+
79
+ ```python
80
+ from teluri import build
81
+
82
+ print(build("+1 (201) 555-0123", ext="1234")) # tel:+12015550123;ext=1234
83
+ print(build("7042", phone_context="example.com")) # tel:7042;phone-context=example.com
84
+ print(build("+1 201 555 0123", params={"note": "a;b c"})) # tel:+12015550123;note=a%3Bb%20c
85
+ ```
86
+
87
+ `build` takes human input: it removes spaces and separators from the numbers, encodes
88
+ parameter values, refuses `ext`, `isub` and `phone-context` inside `params`, and always
89
+ writes the canonical order. The result always parses back with `strict=True`.
90
+
91
+ ### Links in the wild
92
+
93
+ ```python
94
+ from teluri import parse_link
95
+
96
+ print(parse_link("tel:+1 201 555 0123")) # tel:+12015550123
97
+ print(parse_link("tel:1-408-555-5555", context="+1")) # tel:14085555555;phone-context=+1
98
+ print(parse_link("tel:*31%23", context="example.com").href) # tel:*31%23;phone-context=example.com
99
+ ```
100
+
101
+ Web pages and apps often write `tel:` links that RFC 3966 does not allow: spaces as in
102
+ printed numbers, percent-encoded numbers, and local numbers without a `phone-context`
103
+ (Apple's Phone Links reference shows `tel:1-408-555-5555`). `parse_link` decodes and
104
+ cleans the number, adds `context` (checked as a single phone-context descriptor) to a
105
+ local number that has none, then validates the result as RFC 3966. In the other direction, `TelURI.href` writes `#` as `%23`: an HTML
106
+ URL parser would otherwise read it as the start of a fragment. Apple also documents that
107
+ its Phone app does not dial a link containing `*` or `#`.
108
+
109
+ ### Command line
110
+
111
+ ```
112
+ $ teluri "tel:+1-201-555-0123;Ext=9" "tel:863-1234"
113
+ tel:+12015550123;ext=9
114
+ invalid (missing-context): tel:863-1234
115
+ ```
116
+
117
+ One URI per argument, or one per line on standard input. `--json` prints every field,
118
+ `--strict` enforces the parameter order, `--link` reads links as above (not with
119
+ `--strict`) and `--context` gives local links their context. Output is ASCII, so any
120
+ console can print it. The exit status is 1 when any input is invalid, 2 for bad usage.
121
+
122
+ ## API
123
+
124
+ | Name | What it does |
125
+ | --- | --- |
126
+ | `parse(uri, *, strict=False)` | Parse and validate a `tel:` URI, return a `TelURI` |
127
+ | `parse_link(href, *, context=None)` | Read a real-world `tel:` link, return a `TelURI` |
128
+ | `build(number, *, ext, isub, phone_context, params)` | Canonical `tel:` URI from human input |
129
+ | `equivalent(a, b)` | Section 4 comparison of two URIs (strings or `TelURI`) |
130
+ | `TelURI` | Frozen, hashable result: `number`, `ext`, `isub`, `phone_context`, `params`, `is_global`, `is_e164`, `unknown_mandatory`, `href` |
131
+ | `TelURIError` | `ValueError` with a `reason` from `Reason` |
132
+ | `Reason` | The closed set of reasons, listed below |
133
+
134
+ `is_e164` checks the E.164 limits only: a global number of at most 15 digits whose
135
+ country code does not start with 0. `unknown_mandatory` lists `m-` parameters; section
136
+ 5.4 says not to use a URI whose mandatory parameters you do not understand. `isub` is
137
+ kept in its encoded form because a subaddress may carry any octet.
138
+
139
+ ### Reasons
140
+
141
+ - `scheme`: not a `tel:` URI
142
+ - `number`: the number is empty or has a character the grammar does not allow
143
+ - `ext`: the extension has no digit, or a character other than digits and separators
144
+ - `isub`: the subaddress is empty or not `1*paramchar` (erratum 203)
145
+ - `ext-and-isub`: both are present, which section 5.3 forbids
146
+ - `phone-context`: the context is neither a global number prefix nor a domain name
147
+ - `missing-context`: a local number without `phone-context`
148
+ - `global-context`: a global number with `phone-context`
149
+ - `param-name`: a parameter name is empty, malformed, or reserved for a field
150
+ - `param-value`: a value is empty, malformed, badly percent-encoded or not UTF-8
151
+ - `duplicate-param`: a parameter appears twice (names compare case-insensitively)
152
+ - `param-order`: with `strict=True`, parameters are not in the section 3 order
153
+
154
+ ## Why
155
+
156
+ RFC 3966 `tel:` URIs are their own grammar: global numbers with a leading `+`, local
157
+ numbers that must carry a `phone-context`, visual separators, `ext` and `isub`, and a
158
+ comparison rule that ignores separators, case and parameter order. The standard library
159
+ does not model any of it, general URI libraries stop at RFC 3986, and `phonenumbers` is a
160
+ number-validation database rather than a URI codec. teluri is a small, strict, typed one,
161
+ with the browser and phone dialect handled explicitly instead of by accident.
162
+
163
+ ## Sources
164
+
165
+ RFC 3966 and its errata, the IANA tel URI parameter registry, RFC 4694, 4715, 4759 and
166
+ 4904 for the registered parameters. Their examples are checked by the test suite; run
167
+ `python scripts/fetch_upstream.py` to fetch the texts and enable the tests that compare
168
+ against them.
169
+
170
+ ## License
171
+
172
+ MIT. See [LICENSE](LICENSE).
173
+
174
+ ## Links
175
+
176
+ - Website: https://lianes8studio.com
177
+ - GitHub: https://github.com/JaviLianes8
178
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "teluri"
7
+ version = "0.2.0"
8
+ description = "Strict, typed, zero-dependency parser, builder and comparator for tel: URIs (RFC 3966), with a reader for real-world tel: links"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["tel", "uri", "rfc3966", "telephone", "phone", "phone-number", "e164", "href", "link", "sip", "parser"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Communications :: Telephony",
27
+ "Topic :: Software Development :: Libraries",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://lianes8studio.com"
37
+ Repository = "https://github.com/JaviLianes8/teluri"
38
+ Issues = "https://github.com/JaviLianes8/teluri/issues"
39
+ Changelog = "https://github.com/JaviLianes8/teluri/blob/main/CHANGELOG.md"
40
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
41
+ Funding = "https://buymeacoffee.com/jlianesglrs"
42
+
43
+ [project.scripts]
44
+ teluri = "teluri._cli:main"
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
48
+ include = [
49
+ "src/teluri",
50
+ "tests/*.py",
51
+ "tests/data/examples.json",
52
+ "tests/upstream/MANIFEST.json",
53
+ "scripts/fetch_upstream.py",
54
+ "CHANGELOG.md",
55
+ "CONTRIBUTING.md",
56
+ "README.md",
57
+ "LICENSE",
58
+ "pyproject.toml",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["src/teluri"]
63
+
64
+ [tool.ruff]
65
+ line-length = 100
66
+ target-version = "py310"
67
+
68
+ [tool.ruff.lint]
69
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
70
+
71
+ [tool.mypy]
72
+ python_version = "3.10"
73
+ strict = true
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
77
+ pythonpath = ["src"]
78
+ addopts = "-q"
@@ -0,0 +1,37 @@
1
+ """Fetch the third-party test files listed in tests/upstream/MANIFEST.json.
2
+
3
+ The files are not part of the repository. Their tests skip until this script has fetched
4
+ them; each download is checked against the size and sha256 in the manifest.
5
+
6
+ python scripts/fetch_upstream.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ FOLDER = Path(__file__).resolve().parents[1] / "tests" / "upstream"
18
+
19
+
20
+ def main() -> int:
21
+ manifest = json.loads((FOLDER / "MANIFEST.json").read_text(encoding="utf-8"))
22
+ failed = 0
23
+ for name, entry in manifest.items():
24
+ with urllib.request.urlopen(entry["url"], timeout=60) as response:
25
+ body = response.read()
26
+ digest = hashlib.sha256(body).hexdigest()
27
+ if len(body) != entry["size"] or digest != entry["sha256"]:
28
+ print(f"{name}: got {len(body)} bytes, sha256 {digest}; manifest differs")
29
+ failed += 1
30
+ continue
31
+ (FOLDER / name).write_bytes(body)
32
+ print(f"{name}: ok")
33
+ return 1 if failed else 0
34
+
35
+
36
+ if __name__ == "__main__":
37
+ sys.exit(main())
@@ -0,0 +1,13 @@
1
+ """Parse, build and compare tel: URIs per RFC 3966, zero dependencies and fully typed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._build import build
6
+ from ._errors import Reason, TelURIError
7
+ from ._link import parse_link
8
+ from ._model import TelURI
9
+ from ._parse import equivalent, parse
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = ["Reason", "TelURI", "TelURIError", "build", "equivalent", "parse", "parse_link"]
@@ -0,0 +1,9 @@
1
+ """Run the command line with ``python -m teluri``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
@@ -0,0 +1,39 @@
1
+ """Build a canonical tel: URI from human-written parts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from ._model import TelURI
8
+
9
+
10
+ def build(
11
+ number: str,
12
+ *,
13
+ ext: str | None = None,
14
+ isub: str | None = None,
15
+ phone_context: str | None = None,
16
+ params: Mapping[str, str | None] | None = None,
17
+ ) -> str:
18
+ """Build a canonical tel: URI, raising :class:`TelURIError` for invalid parts.
19
+
20
+ Spaces and visual separators are removed from the number, the extension and a numeric
21
+ phone-context, so printed forms such as ``+1 (201) 555-0123`` are accepted. A number
22
+ with a leading ``+`` is global; any other needs ``phone_context``. ``isub`` is taken
23
+ in its encoded form. ``params`` values are plain text and are percent-encoded.
24
+ """
25
+ context = phone_context
26
+ if context is not None and _unspaced(context).startswith("+"):
27
+ context = _unspaced(context)
28
+ uri = TelURI(
29
+ _unspaced(number),
30
+ ext=None if ext is None else _unspaced(ext),
31
+ isub=isub,
32
+ phone_context=context,
33
+ params=params or {},
34
+ )
35
+ return str(uri)
36
+
37
+
38
+ def _unspaced(text: str) -> str:
39
+ return "".join(char for char in text if not char.isspace())