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 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
@@ -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())
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ teluri = teluri._cli:main
@@ -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.