Metadata-Version: 2.5
Name: teluri
Version: 0.2.0
Summary: Strict, typed, zero-dependency parser, builder and comparator for tel: URIs (RFC 3966), with a reader for real-world tel: links
Project-URL: Homepage, https://lianes8studio.com
Project-URL: Repository, https://github.com/JaviLianes8/teluri
Project-URL: Issues, https://github.com/JaviLianes8/teluri/issues
Project-URL: Changelog, https://github.com/JaviLianes8/teluri/blob/main/CHANGELOG.md
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
Author-email: Javier Lianes <javier@lianes8studio.com>
License-Expression: MIT
License-File: LICENSE
Keywords: e164,href,link,parser,phone,phone-number,rfc3966,sip,tel,telephone,uri
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: mypy; extra == 'dev'
Requires-Dist: pytest; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

# teluri

Strict, typed, zero-dependency parser, builder and comparator for `tel:` URIs
(RFC 3966), with a reader for the `tel:` links found in real web pages and apps.

## Install

```
pip install teluri
```

## Usage

### Parse

```python
from teluri import parse

uri = parse("tel:+1-201-555-0123;ext=1234")
print(uri.number)  # +12015550123
print(uri.is_global)  # True
print(uri.ext)  # 1234
print(uri.is_e164)  # True
print(uri)  # tel:+12015550123;ext=1234

local = parse("tel:863-1234;phone-context=+1-914-555")
print(local.number)  # 8631234
print(local.phone_context)  # +1914555
print(local.is_global)  # False
```

`parse` validates the whole RFC 3966 grammar and returns a frozen `TelURI`. Visual
separators (`-`, `.`, `(`, `)`) are removed from the number, the extension and a numeric
`phone-context`. Other parameters land in `params`, keyed by lower-case name, with
percent-decoded values and `None` for a parameter without a value. `str()` gives a
normalised form: separators removed, names in lower case, values re-encoded, and `ext` or
`isub` first, then `phone-context`, then the rest sorted by name, as section 3 asks. The
letter case of numbers, contexts and values is kept, so two equivalent URIs may still
print differently. Wrong types (`parse(None)`, `TelURI(123)`) raise `TypeError`.

### Errors

```python
from teluri import TelURIError, parse

reasons = []
for bad in ["tel:863-1234", "tel:+1;ext=1;isub=2", "tel:+1;b;a"]:
    try:
        parse(bad, strict=True)
    except TelURIError as error:
        reasons.append(error.reason)
print(reasons)  # ['missing-context', 'ext-and-isub', 'param-order']
```

The ABNF accepts parameters in any order, so `parse` does too; `strict=True` also
enforces the order rule of section 3, comparing names in lower case.

### Compare

```python
from teluri import equivalent, parse

print(equivalent("tel:+1-201-555-0123;EXT=1;a=X", "tel:+12015550123;a=x;ext=1"))  # True
domain = "tel:7042;phone-context=Example.COM."
print(equivalent(domain, "tel:7042;phone-context=example.com"))  # True
print(equivalent("tel:+1234", "tel:1234;phone-context=+1"))  # False
print(len({parse("tel:+1;b;a"), parse("tel:+1;A;B")}))  # 1
```

`==`, `hash` and `equivalent` follow section 4: global and local numbers never match,
separators are ignored, a numeric context is compared digit by digit and a domain context
as a host name (a final root dot is ignored), parameters are compared by name in any
order, and the comparison is case-insensitive. Values, including `isub`, are compared as
their percent-decoded octets with ASCII letters folded, so `%2F` equals `/` and `%41`
equals `a`, while `%C3%A9` and `%C3%89` stay different.

### Build

```python
from teluri import build

print(build("+1 (201) 555-0123", ext="1234"))  # tel:+12015550123;ext=1234
print(build("7042", phone_context="example.com"))  # tel:7042;phone-context=example.com
print(build("+1 201 555 0123", params={"note": "a;b c"}))  # tel:+12015550123;note=a%3Bb%20c
```

`build` takes human input: it removes spaces and separators from the numbers, encodes
parameter values, refuses `ext`, `isub` and `phone-context` inside `params`, and always
writes the canonical order. The result always parses back with `strict=True`.

### Links in the wild

```python
from teluri import parse_link

print(parse_link("tel:+1 201 555 0123"))  # tel:+12015550123
print(parse_link("tel:1-408-555-5555", context="+1"))  # tel:14085555555;phone-context=+1
print(parse_link("tel:*31%23", context="example.com").href)  # tel:*31%23;phone-context=example.com
```

Web pages and apps often write `tel:` links that RFC 3966 does not allow: spaces as in
printed numbers, percent-encoded numbers, and local numbers without a `phone-context`
(Apple's Phone Links reference shows `tel:1-408-555-5555`). `parse_link` decodes and
cleans the number, adds `context` (checked as a single phone-context descriptor) to a
local number that has none, then validates the result as RFC 3966. In the other direction, `TelURI.href` writes `#` as `%23`: an HTML
URL parser would otherwise read it as the start of a fragment. Apple also documents that
its Phone app does not dial a link containing `*` or `#`.

### Command line

```
$ teluri "tel:+1-201-555-0123;Ext=9" "tel:863-1234"
tel:+12015550123;ext=9
invalid (missing-context): tel:863-1234
```

One URI per argument, or one per line on standard input. `--json` prints every field,
`--strict` enforces the parameter order, `--link` reads links as above (not with
`--strict`) and `--context` gives local links their context. Output is ASCII, so any
console can print it. The exit status is 1 when any input is invalid, 2 for bad usage.

## API

| Name | What it does |
| --- | --- |
| `parse(uri, *, strict=False)` | Parse and validate a `tel:` URI, return a `TelURI` |
| `parse_link(href, *, context=None)` | Read a real-world `tel:` link, return a `TelURI` |
| `build(number, *, ext, isub, phone_context, params)` | Canonical `tel:` URI from human input |
| `equivalent(a, b)` | Section 4 comparison of two URIs (strings or `TelURI`) |
| `TelURI` | Frozen, hashable result: `number`, `ext`, `isub`, `phone_context`, `params`, `is_global`, `is_e164`, `unknown_mandatory`, `href` |
| `TelURIError` | `ValueError` with a `reason` from `Reason` |
| `Reason` | The closed set of reasons, listed below |

`is_e164` checks the E.164 limits only: a global number of at most 15 digits whose
country code does not start with 0. `unknown_mandatory` lists `m-` parameters; section
5.4 says not to use a URI whose mandatory parameters you do not understand. `isub` is
kept in its encoded form because a subaddress may carry any octet.

### Reasons

- `scheme`: not a `tel:` URI
- `number`: the number is empty or has a character the grammar does not allow
- `ext`: the extension has no digit, or a character other than digits and separators
- `isub`: the subaddress is empty or not `1*paramchar` (erratum 203)
- `ext-and-isub`: both are present, which section 5.3 forbids
- `phone-context`: the context is neither a global number prefix nor a domain name
- `missing-context`: a local number without `phone-context`
- `global-context`: a global number with `phone-context`
- `param-name`: a parameter name is empty, malformed, or reserved for a field
- `param-value`: a value is empty, malformed, badly percent-encoded or not UTF-8
- `duplicate-param`: a parameter appears twice (names compare case-insensitively)
- `param-order`: with `strict=True`, parameters are not in the section 3 order

## Why

RFC 3966 `tel:` URIs are their own grammar: global numbers with a leading `+`, local
numbers that must carry a `phone-context`, visual separators, `ext` and `isub`, and a
comparison rule that ignores separators, case and parameter order. The standard library
does not model any of it, general URI libraries stop at RFC 3986, and `phonenumbers` is a
number-validation database rather than a URI codec. teluri is a small, strict, typed one,
with the browser and phone dialect handled explicitly instead of by accident.

## Sources

RFC 3966 and its errata, the IANA tel URI parameter registry, RFC 4694, 4715, 4759 and
4904 for the registered parameters. Their examples are checked by the test suite; run
`python scripts/fetch_upstream.py` to fetch the texts and enable the tests that compare
against them.

## License

MIT. See [LICENSE](LICENSE).

## Links

- Website: https://lianes8studio.com
- GitHub: https://github.com/JaviLianes8
- LinkedIn: https://www.linkedin.com/in/jlianes/
