teluri 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- teluri/__init__.py +13 -0
- teluri/__main__.py +9 -0
- teluri/_build.py +39 -0
- teluri/_chars.py +55 -0
- teluri/_cli.py +96 -0
- teluri/_errors.py +37 -0
- teluri/_grammar.py +92 -0
- teluri/_link.py +41 -0
- teluri/_model.py +144 -0
- teluri/_params.py +37 -0
- teluri/_parse.py +68 -0
- teluri/py.typed +0 -0
- teluri-0.2.0.dist-info/METADATA +214 -0
- teluri-0.2.0.dist-info/RECORD +17 -0
- teluri-0.2.0.dist-info/WHEEL +4 -0
- teluri-0.2.0.dist-info/entry_points.txt +2 -0
- teluri-0.2.0.dist-info/licenses/LICENSE +21 -0
teluri/__init__.py
ADDED
|
@@ -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"]
|
teluri/__main__.py
ADDED
teluri/_build.py
ADDED
|
@@ -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())
|
teluri/_chars.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Character classes and percent-encoding from the RFC 3966 grammar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
VISUAL_SEPARATORS = "-.()"
|
|
8
|
+
_SEPARATOR_TABLE = {ord(char): None for char in VISUAL_SEPARATORS}
|
|
9
|
+
|
|
10
|
+
ALPHA = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
11
|
+
DIGITS = frozenset("0123456789")
|
|
12
|
+
ALPHANUM = ALPHA | DIGITS
|
|
13
|
+
HEXDIG = DIGITS | frozenset("ABCDEFabcdef")
|
|
14
|
+
LOCAL_DIGITS = HEXDIG | frozenset("*#")
|
|
15
|
+
PNAME_CHARS = ALPHANUM | frozenset("-")
|
|
16
|
+
MARK = frozenset("-_.!~*'()")
|
|
17
|
+
UNRESERVED = ALPHANUM | MARK
|
|
18
|
+
# paramchar minus pct-encoded: the characters a value may carry unencoded.
|
|
19
|
+
PARAMCHAR = UNRESERVED | frozenset("[]/:&+$")
|
|
20
|
+
_SAFE = "".join(sorted(PARAMCHAR - ALPHANUM))
|
|
21
|
+
|
|
22
|
+
# Names with their own field on TelURI; they never appear in `params`.
|
|
23
|
+
RESERVED_PARAMETERS = frozenset({"ext", "isub", "phone-context"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_separators(value: str) -> str:
|
|
27
|
+
"""Drop the visual separators (-, ., (, )) that only aid readability."""
|
|
28
|
+
return value.translate(_SEPARATOR_TABLE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def only(value: str, allowed: frozenset[str]) -> bool:
|
|
32
|
+
"""True when every character of `value` is in `allowed` (ASCII sets only)."""
|
|
33
|
+
return all(char in allowed for char in value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode_value(text: str) -> str:
|
|
37
|
+
"""Percent-encode `text` (UTF-8) so that it is a valid `pvalue`."""
|
|
38
|
+
return quote(text, safe=_SAFE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def valid_pct(value: str, allowed: frozenset[str]) -> bool:
|
|
42
|
+
"""True when `value` is made of `allowed` characters and well-formed %HH escapes."""
|
|
43
|
+
index = 0
|
|
44
|
+
while index < len(value):
|
|
45
|
+
char = value[index]
|
|
46
|
+
if char == "%":
|
|
47
|
+
escape = value[index + 1 : index + 3]
|
|
48
|
+
if len(escape) != 2 or not only(escape, HEXDIG):
|
|
49
|
+
return False
|
|
50
|
+
index += 3
|
|
51
|
+
continue
|
|
52
|
+
if char not in allowed:
|
|
53
|
+
return False
|
|
54
|
+
index += 1
|
|
55
|
+
return True
|
teluri/_cli.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command line: print the canonical form of each tel: URI, exit 1 if any is invalid."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from typing import TextIO
|
|
11
|
+
|
|
12
|
+
from ._errors import TelURIError
|
|
13
|
+
from ._link import parse_link
|
|
14
|
+
from ._model import TelURI
|
|
15
|
+
from ._parse import parse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="teluri",
|
|
21
|
+
description="Validate tel: URIs (RFC 3966) and print their canonical form. "
|
|
22
|
+
"Reads one URI per line from standard input when none are given.",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("uris", nargs="*", metavar="URI")
|
|
25
|
+
parser.add_argument("--json", action="store_true", help="print one JSON object per URI")
|
|
26
|
+
parser.add_argument("--strict", action="store_true", help="enforce the parameter order")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--link", action="store_true", help="read links as browsers do (spaces, %%23)"
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--context", metavar="DESCRIPTOR", help="phone-context for local links without one"
|
|
32
|
+
)
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(
|
|
37
|
+
argv: Sequence[str] | None = None,
|
|
38
|
+
stdin: TextIO | None = None,
|
|
39
|
+
stdout: TextIO | None = None,
|
|
40
|
+
stderr: TextIO | None = None,
|
|
41
|
+
) -> int:
|
|
42
|
+
"""Run the command line and return its exit status (0 ok, 1 invalid, 2 usage)."""
|
|
43
|
+
out = stdout or sys.stdout
|
|
44
|
+
err = stderr or sys.stderr
|
|
45
|
+
try:
|
|
46
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
47
|
+
args = _parser().parse_args(argv)
|
|
48
|
+
if args.context is not None and not args.link:
|
|
49
|
+
_parser().error("--context needs --link")
|
|
50
|
+
if args.strict and args.link:
|
|
51
|
+
_parser().error("--strict reads RFC 3966 only and cannot be used with --link")
|
|
52
|
+
except SystemExit as stop:
|
|
53
|
+
return int(stop.code or 0)
|
|
54
|
+
uris: Iterable[str] = args.uris or _lines(stdin or sys.stdin)
|
|
55
|
+
status = 0
|
|
56
|
+
for uri in uris:
|
|
57
|
+
try:
|
|
58
|
+
if args.link:
|
|
59
|
+
parsed = parse_link(uri, context=args.context)
|
|
60
|
+
else:
|
|
61
|
+
parsed = parse(uri, strict=args.strict)
|
|
62
|
+
except TelURIError as error:
|
|
63
|
+
err.write(f"invalid ({error.reason}): {_shown(uri)}\n")
|
|
64
|
+
status = 1
|
|
65
|
+
continue
|
|
66
|
+
out.write(json.dumps(_fields(parsed)) if args.json else str(parsed))
|
|
67
|
+
out.write("\n")
|
|
68
|
+
return status
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _fields(uri: TelURI) -> dict[str, object]:
|
|
72
|
+
return {
|
|
73
|
+
"uri": str(uri),
|
|
74
|
+
"number": uri.number,
|
|
75
|
+
"global": uri.is_global,
|
|
76
|
+
"e164": uri.is_e164,
|
|
77
|
+
"ext": uri.ext,
|
|
78
|
+
"isub": uri.isub,
|
|
79
|
+
"phone_context": uri.phone_context,
|
|
80
|
+
"params": dict(uri.params),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _lines(stream: TextIO) -> Iterable[str]:
|
|
85
|
+
for line in stream:
|
|
86
|
+
text = line.rstrip("\r\n")
|
|
87
|
+
if text:
|
|
88
|
+
yield text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _shown(uri: str) -> str:
|
|
92
|
+
"""The input as safe to print on any console: non-ASCII and control characters escaped."""
|
|
93
|
+
escape = "unicode_escape"
|
|
94
|
+
return "".join(
|
|
95
|
+
c if c.isascii() and c.isprintable() else c.encode(escape).decode("ascii") for c in uri
|
|
96
|
+
)
|
teluri/_errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The error raised for a malformed tel: URI, with a machine-readable reason."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
Reason = Literal[
|
|
8
|
+
"scheme",
|
|
9
|
+
"number",
|
|
10
|
+
"ext",
|
|
11
|
+
"isub",
|
|
12
|
+
"ext-and-isub",
|
|
13
|
+
"phone-context",
|
|
14
|
+
"missing-context",
|
|
15
|
+
"global-context",
|
|
16
|
+
"param-name",
|
|
17
|
+
"param-value",
|
|
18
|
+
"duplicate-param",
|
|
19
|
+
"param-order",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TelURIError(ValueError):
|
|
24
|
+
"""Raised when a tel: URI is malformed or cannot be built.
|
|
25
|
+
|
|
26
|
+
`reason` names the rule that failed, from the closed set `Reason`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
reason: Reason
|
|
30
|
+
|
|
31
|
+
def __init__(self, reason: Reason, message: str = "") -> None:
|
|
32
|
+
super().__init__(reason, message)
|
|
33
|
+
self.reason = reason
|
|
34
|
+
self.message = message
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return f"{self.reason}: {self.message}" if self.message else self.reason
|
teluri/_grammar.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Validators for the pieces of the RFC 3966 grammar, each raising a precise reason."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
from ._chars import (
|
|
8
|
+
ALPHA,
|
|
9
|
+
ALPHANUM,
|
|
10
|
+
DIGITS,
|
|
11
|
+
LOCAL_DIGITS,
|
|
12
|
+
PARAMCHAR,
|
|
13
|
+
PNAME_CHARS,
|
|
14
|
+
VISUAL_SEPARATORS,
|
|
15
|
+
only,
|
|
16
|
+
strip_separators,
|
|
17
|
+
valid_pct,
|
|
18
|
+
)
|
|
19
|
+
from ._errors import Reason, TelURIError
|
|
20
|
+
|
|
21
|
+
_GLOBAL = DIGITS | frozenset(VISUAL_SEPARATORS)
|
|
22
|
+
_LOCAL = LOCAL_DIGITS | frozenset(VISUAL_SEPARATORS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def global_digits(raw: str, reason: Reason = "number") -> str:
|
|
26
|
+
"""`global-number-digits`: digits and separators after the `+` the caller has seen."""
|
|
27
|
+
body = raw[1:]
|
|
28
|
+
if not only(body, _GLOBAL) or not strip_separators(body):
|
|
29
|
+
raise TelURIError(reason, f"not a global number: {raw!r}")
|
|
30
|
+
return "+" + strip_separators(body)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def local_digits(raw: str) -> str:
|
|
34
|
+
"""`local-number-digits`: hex digits, `*` and `#` with separators, at least one of them."""
|
|
35
|
+
if not only(raw, _LOCAL) or not strip_separators(raw):
|
|
36
|
+
raise TelURIError("number", f"not a local number: {raw!r}")
|
|
37
|
+
return strip_separators(raw)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_domainname(value: str) -> bool:
|
|
41
|
+
"""`domainname`: dot-separated labels, a top label starting with a letter, optional root dot."""
|
|
42
|
+
labels = value[:-1].split(".") if value.endswith(".") else value.split(".")
|
|
43
|
+
if not all(_is_label(label) for label in labels):
|
|
44
|
+
return False
|
|
45
|
+
return labels[-1][0] in ALPHA
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def descriptor(value: str, reason: Reason = "phone-context") -> str:
|
|
49
|
+
"""A context `descriptor`: a global number prefix (separators dropped) or a domain name."""
|
|
50
|
+
if value.startswith("+"):
|
|
51
|
+
return global_digits(value, reason)
|
|
52
|
+
if not is_domainname(value):
|
|
53
|
+
raise TelURIError(reason, f"not a domain name or global number: {value!r}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def extension(value: str) -> str:
|
|
58
|
+
"""`ext`: digits and separators; at least one digit, since an empty extension names nothing."""
|
|
59
|
+
digits = strip_separators(value)
|
|
60
|
+
if not only(value, _GLOBAL) or not digits:
|
|
61
|
+
raise TelURIError("ext", f"not an extension: {value!r}")
|
|
62
|
+
return digits
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def subaddress(value: str) -> str:
|
|
66
|
+
"""`isub` per erratum 203: `1*paramchar`, kept in its encoded form (any octet may occur)."""
|
|
67
|
+
if not value or not valid_pct(value, PARAMCHAR):
|
|
68
|
+
raise TelURIError("isub", f"not an ISDN subaddress: {value!r}")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pname(name: str) -> str:
|
|
73
|
+
"""`pname`, lower-cased: names compare case-insensitively and SHOULD be lower case."""
|
|
74
|
+
if not name or not only(name, PNAME_CHARS):
|
|
75
|
+
raise TelURIError("param-name", f"not a parameter name: {name!r}")
|
|
76
|
+
return name.lower()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def pvalue(value: str) -> str:
|
|
80
|
+
"""`pvalue`, percent-decoded as UTF-8 (an empty one is refused by the model)."""
|
|
81
|
+
if not valid_pct(value, PARAMCHAR):
|
|
82
|
+
raise TelURIError("param-value", f"not a parameter value: {value!r}")
|
|
83
|
+
try:
|
|
84
|
+
return unquote(value, errors="strict")
|
|
85
|
+
except UnicodeDecodeError:
|
|
86
|
+
raise TelURIError("param-value", f"not UTF-8 once decoded: {value!r}") from None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_label(label: str) -> bool:
|
|
90
|
+
if not label or not only(label, ALPHANUM | frozenset("-")):
|
|
91
|
+
return False
|
|
92
|
+
return label[0] != "-" and label[-1] != "-"
|
teluri/_link.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Read tel: links as browsers and phones accept them, which RFC 3966 does not."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import unquote
|
|
6
|
+
|
|
7
|
+
from ._errors import TelURIError
|
|
8
|
+
from ._grammar import descriptor
|
|
9
|
+
from ._model import TelURI
|
|
10
|
+
from ._parse import parse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_link(href: str, *, context: str | None = None) -> TelURI:
|
|
14
|
+
"""Parse a tel: link from a web page or an app, then validate it as RFC 3966.
|
|
15
|
+
|
|
16
|
+
Links in the wild write spaces (``tel:+1 201 555 0123``), percent-encode the number
|
|
17
|
+
(``%20``, ``%2B``, ``%23``) and leave local numbers without a phone-context. This
|
|
18
|
+
reader decodes the number, drops whitespace, and gives a local number ``context``
|
|
19
|
+
when the link has none. Everything after the number is read as strict RFC 3966.
|
|
20
|
+
"""
|
|
21
|
+
if not isinstance(href, str):
|
|
22
|
+
raise TypeError(f"href must be a str, not {type(href).__name__}")
|
|
23
|
+
if context is not None:
|
|
24
|
+
descriptor(context) # one descriptor, so it cannot smuggle in other parameters
|
|
25
|
+
text = href.strip()
|
|
26
|
+
scheme, colon, rest = text.partition(":")
|
|
27
|
+
if not colon or scheme.lower() != "tel":
|
|
28
|
+
raise TelURIError("scheme", f"not a tel: link: {href!r}")
|
|
29
|
+
number, semicolon, tail = rest.partition(";")
|
|
30
|
+
try:
|
|
31
|
+
decoded = unquote(number, errors="strict")
|
|
32
|
+
except UnicodeDecodeError:
|
|
33
|
+
raise TelURIError("number", f"not UTF-8 once decoded: {number!r}") from None
|
|
34
|
+
cleaned = "".join(char for char in decoded if not char.isspace())
|
|
35
|
+
if ";" in cleaned: # an encoded separator must not start a parameter
|
|
36
|
+
raise TelURIError("number", f"not a telephone number: {number!r}")
|
|
37
|
+
names = [segment.partition("=")[0].lower() for segment in tail.split(";")]
|
|
38
|
+
if context is not None and not cleaned.startswith("+") and "phone-context" not in names:
|
|
39
|
+
tail = f"{tail};phone-context={context}" if semicolon else f"phone-context={context}"
|
|
40
|
+
semicolon = ";"
|
|
41
|
+
return parse(f"tel:{cleaned}{semicolon}{tail}")
|
teluri/_model.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The parsed, validated representation of a tel: URI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from urllib.parse import unquote_to_bytes
|
|
8
|
+
|
|
9
|
+
from ._chars import RESERVED_PARAMETERS, encode_value
|
|
10
|
+
from ._errors import TelURIError
|
|
11
|
+
from ._grammar import descriptor, extension, global_digits, local_digits, pname, subaddress
|
|
12
|
+
from ._params import Params
|
|
13
|
+
|
|
14
|
+
_E164_MAX_DIGITS = 15
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, eq=False)
|
|
18
|
+
class TelURI:
|
|
19
|
+
"""A valid tel: URI (RFC 3966) with visual separators removed from its numbers.
|
|
20
|
+
|
|
21
|
+
Constructing one validates every field: a number with a leading ``+`` is global and
|
|
22
|
+
takes no ``phone_context``; any other number is local and requires one. ``params``
|
|
23
|
+
holds the remaining parameters by lower-case name, with decoded values and ``None``
|
|
24
|
+
for a parameter without a value. ``==`` and ``hash`` follow RFC 3966 section 4 and
|
|
25
|
+
``str()`` gives the canonical form.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
number: str
|
|
29
|
+
ext: str | None = None
|
|
30
|
+
isub: str | None = None
|
|
31
|
+
phone_context: str | None = None
|
|
32
|
+
params: Mapping[str, str | None] = field(default_factory=lambda: Params({}))
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
_check_types(self)
|
|
36
|
+
number = self.number
|
|
37
|
+
is_global = number.startswith("+")
|
|
38
|
+
set_ = object.__setattr__
|
|
39
|
+
set_(self, "number", global_digits(number) if is_global else local_digits(number))
|
|
40
|
+
if self.ext is not None and self.isub is not None:
|
|
41
|
+
raise TelURIError("ext-and-isub", "ext and isub cannot both be present")
|
|
42
|
+
if self.ext is not None:
|
|
43
|
+
set_(self, "ext", extension(self.ext))
|
|
44
|
+
if self.isub is not None:
|
|
45
|
+
set_(self, "isub", subaddress(self.isub))
|
|
46
|
+
if self.phone_context is not None:
|
|
47
|
+
if is_global:
|
|
48
|
+
raise TelURIError("global-context", "a global number takes no phone-context")
|
|
49
|
+
set_(self, "phone_context", descriptor(self.phone_context))
|
|
50
|
+
elif not is_global:
|
|
51
|
+
raise TelURIError("missing-context", f"local number {number!r} needs phone-context")
|
|
52
|
+
set_(self, "params", _checked(self.params))
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_global(self) -> bool:
|
|
56
|
+
"""True for a global (E.164) number, written with a leading ``+``."""
|
|
57
|
+
return self.number.startswith("+")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_e164(self) -> bool:
|
|
61
|
+
"""True for a global number of 1 to 15 digits not starting with 0 (E.164 limits)."""
|
|
62
|
+
digits = self.number[1:]
|
|
63
|
+
return self.is_global and len(digits) <= _E164_MAX_DIGITS and digits[0] != "0"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def unknown_mandatory(self) -> tuple[str, ...]:
|
|
67
|
+
"""Names of ``m-`` parameters: section 5.4 says not to use a URI it cannot honour."""
|
|
68
|
+
return tuple(name for name in self.params if name.startswith("m-"))
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def href(self) -> str:
|
|
72
|
+
"""The canonical form for an HTML ``href``: ``#`` escaped so it is not a fragment."""
|
|
73
|
+
return str(self).replace("#", "%23")
|
|
74
|
+
|
|
75
|
+
def __str__(self) -> str:
|
|
76
|
+
out = ["tel:", self.number]
|
|
77
|
+
if self.ext is not None:
|
|
78
|
+
out.append(";ext=" + self.ext)
|
|
79
|
+
if self.isub is not None:
|
|
80
|
+
out.append(";isub=" + self.isub)
|
|
81
|
+
if self.phone_context is not None:
|
|
82
|
+
out.append(";phone-context=" + self.phone_context)
|
|
83
|
+
for name, value in self.params.items():
|
|
84
|
+
out.append(f";{name}" if value is None else f";{name}={encode_value(value)}")
|
|
85
|
+
return "".join(out)
|
|
86
|
+
|
|
87
|
+
def __repr__(self) -> str:
|
|
88
|
+
return f"TelURI({str(self)!r})"
|
|
89
|
+
|
|
90
|
+
def __eq__(self, other: object) -> bool:
|
|
91
|
+
if not isinstance(other, TelURI):
|
|
92
|
+
return NotImplemented
|
|
93
|
+
return self._key() == other._key()
|
|
94
|
+
|
|
95
|
+
def __hash__(self) -> int:
|
|
96
|
+
return hash(self._key())
|
|
97
|
+
|
|
98
|
+
def _key(self) -> tuple[object, ...]:
|
|
99
|
+
context = self.phone_context
|
|
100
|
+
if context is not None and not context.startswith("+"):
|
|
101
|
+
context = context.lower().removesuffix(".")
|
|
102
|
+
return (
|
|
103
|
+
self.number.lower(),
|
|
104
|
+
self.ext,
|
|
105
|
+
None if self.isub is None else unquote_to_bytes(self.isub).lower(),
|
|
106
|
+
context,
|
|
107
|
+
frozenset(
|
|
108
|
+
(name, None if value is None else value.encode("utf-8").lower())
|
|
109
|
+
for name, value in self.params.items()
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _check_types(uri: TelURI) -> None:
|
|
115
|
+
if not isinstance(uri.number, str):
|
|
116
|
+
raise TypeError(f"number must be a str, not {type(uri.number).__name__}")
|
|
117
|
+
for name in ("ext", "isub", "phone_context"):
|
|
118
|
+
value = getattr(uri, name)
|
|
119
|
+
if value is not None and not isinstance(value, str):
|
|
120
|
+
raise TypeError(f"{name} must be a str or None, not {type(value).__name__}")
|
|
121
|
+
if not isinstance(uri.params, Mapping) or not all(isinstance(k, str) for k in uri.params):
|
|
122
|
+
raise TypeError("params must be a mapping with str keys")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _checked(params: Mapping[str, str | None]) -> Params:
|
|
126
|
+
items: dict[str, str | None] = {}
|
|
127
|
+
for raw, value in params.items():
|
|
128
|
+
name = pname(raw)
|
|
129
|
+
if name in RESERVED_PARAMETERS:
|
|
130
|
+
raise TelURIError("param-name", f"{name!r} has its own field")
|
|
131
|
+
if name in items:
|
|
132
|
+
raise TelURIError("duplicate-param", f"{name!r} appears twice")
|
|
133
|
+
if value is not None and (not isinstance(value, str) or not value or _unencodable(value)):
|
|
134
|
+
raise TelURIError("param-value", f"{name!r} needs a non-empty text value or None")
|
|
135
|
+
items[name] = value
|
|
136
|
+
return Params(dict(sorted(items.items())))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _unencodable(text: str) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
text.encode("utf-8")
|
|
142
|
+
except UnicodeEncodeError: # a lone surrogate cannot be written in a URI
|
|
143
|
+
return True
|
|
144
|
+
return False
|
teluri/_params.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""A read-only, hashable, picklable mapping for the extra parameters of a tel: URI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Params(Mapping[str, "str | None"]):
|
|
9
|
+
"""Parameters other than ext, isub and phone-context, keyed by lower-case name."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("_items",)
|
|
12
|
+
|
|
13
|
+
def __init__(self, items: Mapping[str, str | None]) -> None:
|
|
14
|
+
self._items = dict(items)
|
|
15
|
+
|
|
16
|
+
def __getitem__(self, name: str) -> str | None:
|
|
17
|
+
return self._items[name]
|
|
18
|
+
|
|
19
|
+
def __iter__(self) -> Iterator[str]:
|
|
20
|
+
return iter(self._items)
|
|
21
|
+
|
|
22
|
+
def __len__(self) -> int:
|
|
23
|
+
return len(self._items)
|
|
24
|
+
|
|
25
|
+
def __hash__(self) -> int:
|
|
26
|
+
return hash(frozenset(self._items.items()))
|
|
27
|
+
|
|
28
|
+
def __eq__(self, other: object) -> bool:
|
|
29
|
+
if isinstance(other, Mapping):
|
|
30
|
+
return dict(self._items) == dict(other)
|
|
31
|
+
return NotImplemented
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
return f"Params({self._items!r})"
|
|
35
|
+
|
|
36
|
+
def __reduce__(self) -> tuple[type[Params], tuple[dict[str, str | None]]]:
|
|
37
|
+
return (Params, (self._items,))
|
teluri/_parse.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Parse a tel: URI (RFC 3966) into a validated :class:`TelURI`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._errors import TelURIError
|
|
6
|
+
from ._grammar import pname, pvalue
|
|
7
|
+
from ._model import TelURI
|
|
8
|
+
|
|
9
|
+
_FIELDS = frozenset({"ext", "isub", "phone-context"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse(uri: str, *, strict: bool = False) -> TelURI:
|
|
13
|
+
"""Parse a tel: URI, raising :class:`TelURIError` with a reason if it is malformed.
|
|
14
|
+
|
|
15
|
+
Parameters may come in any order, as the ABNF allows. With ``strict=True`` the order
|
|
16
|
+
rule of section 3 is enforced too: ext or isub first, then phone-context, then the
|
|
17
|
+
other parameters in lexicographic order.
|
|
18
|
+
"""
|
|
19
|
+
if not isinstance(uri, str):
|
|
20
|
+
raise TypeError(f"uri must be a str, not {type(uri).__name__}")
|
|
21
|
+
scheme, colon, rest = uri.partition(":")
|
|
22
|
+
if not colon or scheme.lower() != "tel":
|
|
23
|
+
raise TelURIError("scheme", f"not a tel: URI: {uri!r}")
|
|
24
|
+
number, *segments = rest.split(";")
|
|
25
|
+
if not number:
|
|
26
|
+
raise TelURIError("number", "the telephone number is empty")
|
|
27
|
+
|
|
28
|
+
fields: dict[str, str] = {}
|
|
29
|
+
params: dict[str, str | None] = {}
|
|
30
|
+
order: list[str] = []
|
|
31
|
+
for segment in segments:
|
|
32
|
+
raw_name, equals, raw_value = segment.partition("=")
|
|
33
|
+
name = pname(raw_name)
|
|
34
|
+
if name in order:
|
|
35
|
+
raise TelURIError("duplicate-param", f"{name!r} appears twice")
|
|
36
|
+
order.append(name)
|
|
37
|
+
if name in _FIELDS:
|
|
38
|
+
fields[name] = raw_value # the model refuses an empty or missing value
|
|
39
|
+
elif equals:
|
|
40
|
+
params[name] = pvalue(raw_value)
|
|
41
|
+
else:
|
|
42
|
+
params[name] = None
|
|
43
|
+
|
|
44
|
+
result = TelURI(
|
|
45
|
+
number,
|
|
46
|
+
ext=fields.get("ext"),
|
|
47
|
+
isub=fields.get("isub"),
|
|
48
|
+
phone_context=fields.get("phone-context"),
|
|
49
|
+
params=params,
|
|
50
|
+
)
|
|
51
|
+
if strict and order != _canonical_order(order):
|
|
52
|
+
raise TelURIError("param-order", f"parameters out of order: {';'.join(order)}")
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _canonical_order(names: list[str]) -> list[str]:
|
|
57
|
+
first = [name for name in ("ext", "isub", "phone-context") if name in names]
|
|
58
|
+
return first + sorted(name for name in names if name not in _FIELDS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def equivalent(a: str | TelURI, b: str | TelURI) -> bool:
|
|
62
|
+
"""True when two tel: URIs are equivalent under RFC 3966 section 4.
|
|
63
|
+
|
|
64
|
+
Strings are parsed first, so a malformed one raises :class:`TelURIError`.
|
|
65
|
+
"""
|
|
66
|
+
left = a if isinstance(a, TelURI) else parse(a)
|
|
67
|
+
right = b if isinstance(b, TelURI) else parse(b)
|
|
68
|
+
return left == right
|
teluri/py.typed
ADDED
|
File without changes
|
|
@@ -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/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
teluri/__init__.py,sha256=O7nB3irzTJlyxWlQ63F6-B737eHvXFYrOdKAocUQj_o,405
|
|
2
|
+
teluri/__main__.py,sha256=nvn5Kw4d2dN1-qGGSakvjVKeRQeYtOuL4tp2F7UDgbE,144
|
|
3
|
+
teluri/_build.py,sha256=Je8nHgy-m_o6-xLTF1Lwa399Us6WI6Va3IfT3Fd_8i4,1231
|
|
4
|
+
teluri/_chars.py,sha256=aOibDNjBmcHhPexbDkKozo1BlN65H0EDkENCrfgrT0A,1879
|
|
5
|
+
teluri/_cli.py,sha256=ko8pNEL2FQvXDCx-T4d_hUe2gdi-S6MGNAFPjvmyOgc,3196
|
|
6
|
+
teluri/_errors.py,sha256=x3hb-_Ci3myOIMOPeSf6XqC7Sy8PHGW6v-PCul7fJvQ,860
|
|
7
|
+
teluri/_grammar.py,sha256=UuS2v0-eBuZg5V6vmLpvy8bTMOlYKKvVc7wQ9oCh8Vw,3256
|
|
8
|
+
teluri/_link.py,sha256=jBsUWUNlhApNKbbo-8lLN1ovaxvSw-WSrS8HItsHFVY,1970
|
|
9
|
+
teluri/_model.py,sha256=XPqFFuD2rm0-VdMX4EHGGzccNMFGeG1LBlGbvBjgCz4,5756
|
|
10
|
+
teluri/_params.py,sha256=jHgva-Pn5DIswNDbrDaDKmCsaYOfT8vglifH5pjuum0,1099
|
|
11
|
+
teluri/_parse.py,sha256=6PSAE86BLw88wsgBisX8juh12N23OPWbklvVOrQ2LtY,2489
|
|
12
|
+
teluri/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
teluri-0.2.0.dist-info/METADATA,sha256=WmAdqmrynZTolVtpHYxZpvo9XQVIhplzF8-Z2r4vP0I,9157
|
|
14
|
+
teluri-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
teluri-0.2.0.dist-info/entry_points.txt,sha256=Aizu9XLmP4ErZyChijfTnDMkTJhD4a3nWWCy68N80M0,44
|
|
16
|
+
teluri-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
17
|
+
teluri-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|