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.
- teluri-0.2.0/.gitignore +16 -0
- teluri-0.2.0/CHANGELOG.md +42 -0
- teluri-0.2.0/CONTRIBUTING.md +19 -0
- teluri-0.2.0/LICENSE +21 -0
- teluri-0.2.0/PKG-INFO +214 -0
- teluri-0.2.0/README.md +178 -0
- teluri-0.2.0/pyproject.toml +78 -0
- teluri-0.2.0/scripts/fetch_upstream.py +37 -0
- teluri-0.2.0/src/teluri/__init__.py +13 -0
- teluri-0.2.0/src/teluri/__main__.py +9 -0
- teluri-0.2.0/src/teluri/_build.py +39 -0
- teluri-0.2.0/src/teluri/_chars.py +55 -0
- teluri-0.2.0/src/teluri/_cli.py +96 -0
- teluri-0.2.0/src/teluri/_errors.py +37 -0
- teluri-0.2.0/src/teluri/_grammar.py +92 -0
- teluri-0.2.0/src/teluri/_link.py +41 -0
- teluri-0.2.0/src/teluri/_model.py +144 -0
- teluri-0.2.0/src/teluri/_params.py +37 -0
- teluri-0.2.0/src/teluri/_parse.py +68 -0
- teluri-0.2.0/src/teluri/py.typed +0 -0
- teluri-0.2.0/tests/conftest.py +24 -0
- teluri-0.2.0/tests/data/examples.json +62 -0
- teluri-0.2.0/tests/test_build.py +61 -0
- teluri-0.2.0/tests/test_cli.py +69 -0
- teluri-0.2.0/tests/test_contract.py +34 -0
- teluri-0.2.0/tests/test_docs.py +90 -0
- teluri-0.2.0/tests/test_e2e_wheel.py +97 -0
- teluri-0.2.0/tests/test_errors.py +26 -0
- teluri-0.2.0/tests/test_link.py +63 -0
- teluri-0.2.0/tests/test_model.py +144 -0
- teluri-0.2.0/tests/test_parse.py +128 -0
- teluri-0.2.0/tests/test_review.py +112 -0
- teluri-0.2.0/tests/test_rfc_examples.py +57 -0
- teluri-0.2.0/tests/test_rfc_upstream.py +45 -0
- teluri-0.2.0/tests/test_typing.py +74 -0
- teluri-0.2.0/tests/upstream/MANIFEST.json +47 -0
teluri-0.2.0/.gitignore
ADDED
|
@@ -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,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())
|