Skip to content

Commit

Permalink
Add support for parsing tel URIs
Browse files Browse the repository at this point in the history
  • Loading branch information
jlaine committed Oct 10, 2024
1 parent bfe732d commit 6fc1cdb
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 31 deletions.
93 changes: 62 additions & 31 deletions src/sipmessage/uri.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@

import dataclasses
import re
import string
import urllib.parse

from . import grammar
from .parameters import Parameters

URI_PATTERN = re.compile(
C_VISUAL_SEPARATOR = "-.()"

PHONEDIGIT = grammar.cset(string.digits + C_VISUAL_SEPARATOR)
PHONEDIGIT_HEX = grammar.cset(string.hexdigits + "*#" + C_VISUAL_SEPARATOR)
GLOBAL_NUMBER_DIGITS = f"\\+{PHONEDIGIT}*{grammar.DIGIT}{PHONEDIGIT}*"
LOCAL_NUMBER_DIGITS = (
f"{PHONEDIGIT_HEX}*{grammar.cset(string.hexdigits + '*#')}{PHONEDIGIT_HEX}*"
)

SIP_URI_PATTERN = re.compile(
"^"
"(?P<scheme>sip|sips):"
f"(?:(?P<user>{grammar.USER})(?::(?P<password>{grammar.PASSWORD}))?@)?"
Expand All @@ -19,16 +29,23 @@
f"(?P<parameters>(?:;{grammar.URI_PARAM})*)"
"$"
)
TEL_URI_PATTERN = re.compile(
"^"
"(?P<scheme>tel):"
f"(?P<subscriber>(?:{GLOBAL_NUMBER_DIGITS}|{LOCAL_NUMBER_DIGITS}))"
f"(?P<parameters>(?:;{grammar.URI_PARAM})*)"
"$"
)


@dataclasses.dataclass(frozen=True)
class URI:
"""
A SIP or SIPS URL as described by RFC3261.
A SIP, SIPS or TEL URI as described by RFC3261 and RFC3966.
"""

scheme: str
"The URL scheme specifier."
"The URI scheme specifier."

host: str
"The host providing the SIP resource."
Expand All @@ -52,24 +69,32 @@ def parse(cls, value: str) -> "URI":
If parsing fails, a :class:`ValueError` is raised.
"""
m = URI_PATTERN.match(value)
if not m:
if m := SIP_URI_PATTERN.match(value):
port = m.group("port")
user = m.group("user")
password = m.group("password")
parameters = m.group("parameters")

return cls(
scheme=m.group("scheme"),
host=m.group("host"),
port=int(port) if port else None,
user=urllib.parse.unquote(user) if user else None,
password=urllib.parse.unquote(password) if password else None,
parameters=Parameters.parse(parameters),
)
elif m := TEL_URI_PATTERN.match(value):
parameters = m.group("parameters")

return cls(
scheme=m.group("scheme"),
host="",
user=m.group("subscriber"),
parameters=Parameters.parse(parameters),
)
else:
raise ValueError("URI is not valid")

port = m.group("port")
user = m.group("user")
password = m.group("password")
parameters = m.group("parameters")

return cls(
scheme=m.group("scheme"),
host=m.group("host"),
port=int(port) if port else None,
user=urllib.parse.unquote(user) if user else None,
password=urllib.parse.unquote(password) if password else None,
parameters=Parameters.parse(parameters),
)

@property
def global_phone_number(self) -> str | None:
"""
Expand All @@ -80,25 +105,31 @@ def global_phone_number(self) -> str | None:
> userinfo = user | telephone-subscriber
> telephone-subscriber = global-phone-number | local-phone-number
The phone number is returned without any visual separators.
"""
if self.user is None:
return None
if self.user[0] == "+" and self.user[1:].isnumeric():
return self.user
if re.match(GLOBAL_NUMBER_DIGITS, self.user):
return re.sub(grammar.cset(C_VISUAL_SEPARATOR), "", self.user)
else:
return None

def __str__(self) -> str:
s = self.scheme + ":"
if self.user is not None:
s += urllib.parse.quote(self.user, safe=grammar.C_USER_SAFE)
if self.password is not None:
s += ":" + urllib.parse.quote(
self.password, safe=grammar.C_PASSWORD_SAFE
)
s += "@"
s += self.host
if self.port is not None:
s += f":{self.port}"
if self.scheme == "tel":
assert self.user is not None
s += self.user
else:
if self.user is not None:
s += urllib.parse.quote(self.user, safe=grammar.C_USER_SAFE)
if self.password is not None:
s += ":" + urllib.parse.quote(
self.password, safe=grammar.C_PASSWORD_SAFE
)
s += "@"
s += self.host
if self.port is not None:
s += f":{self.port}"
s += str(self.parameters)
return s
37 changes: 37 additions & 0 deletions tests/test_uri.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def test_password(self) -> None:
self.assertEqual(uri.password, "secret")
self.assertEqual(uri.parameters, {})

self.assertEqual(uri.global_phone_number, None)
self.assertEqual(str(uri), "sip:alice:[email protected]")

def test_password_escaped(self) -> None:
Expand All @@ -178,6 +179,7 @@ def test_password_escaped(self) -> None:
self.assertEqual(uri.password, "sips:[email protected]")
self.assertEqual(uri.parameters, {})

self.assertEqual(uri.global_phone_number, None)
self.assertEqual(str(uri), "sip:alice:sips%3Auser%[email protected]")

def test_rfc4475_wide_range_of_valid_characters(self) -> None:
Expand All @@ -189,7 +191,42 @@ def test_rfc4475_wide_range_of_valid_characters(self) -> None:
self.assertEqual(uri.user, "1_unusual.URI~(to-be!sure)&isn't+it$/crazy?,/;;*")
self.assertEqual(uri.password, "&it+has=1,weird!*pas$wo~d_too.(doesn't-it)")
self.assertEqual(uri.parameters, {})

self.assertEqual(uri.global_phone_number, None)
self.assertEqual(
str(uri),
"sip:1_unusual.URI~(to-be!sure)&isn't+it$/crazy?,/;;*:&it+has=1,weird!*pas$wo~d_too.(doesn't-it)@example.com",
)

def test_tel_global_phone_number(self) -> None:
uri = URI.parse("tel:+12015550123")
self.assertEqual(uri.host, "")
self.assertEqual(uri.user, "+12015550123")
self.assertEqual(uri.password, None)
self.assertEqual(uri.parameters, {})

self.assertEqual(uri.global_phone_number, "+12015550123")
self.assertEqual(str(uri), "tel:+12015550123")

def test_tel_uri_global_phone_number_with_visual_separators(self) -> None:
uri = URI.parse("tel:+1-201-555-0123")
self.assertEqual(uri.host, "")
self.assertEqual(uri.user, "+1-201-555-0123")
self.assertEqual(uri.password, None)
self.assertEqual(uri.parameters, {})

self.assertEqual(uri.global_phone_number, "+12015550123")
self.assertEqual(str(uri), "tel:+1-201-555-0123")

def test_tel_uri_local_phone_number(self) -> None:
uri = URI.parse("tel:7042;phone-context=example.com")
self.assertEqual(uri.host, "")
self.assertEqual(uri.user, "7042")
self.assertEqual(uri.password, None)
self.assertEqual(
uri.parameters,
{"phone-context": "example.com"},
)

self.assertEqual(uri.global_phone_number, None)
self.assertEqual(str(uri), "tel:7042;phone-context=example.com")

0 comments on commit 6fc1cdb

Please sign in to comment.