Skip to content

Commit 37757a6

Browse files
Unquote username and password in DatabaseURL #247 (#248)
* Unquote username and password in DatabaseURL #247 * fix userinfo property to be the same as httpx * encode in utf-8 instead of ascii * improve test coverage on core * explicit str type for _url * fix black linting Co-authored-by: Vadim Markovtsev <[email protected]>
1 parent 1358aaa commit 37757a6

File tree

2 files changed

+56
-7
lines changed

2 files changed

+56
-7
lines changed

databases/core.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import sys
66
import typing
77
from types import TracebackType
8-
from urllib.parse import SplitResult, parse_qsl, urlsplit
8+
from urllib.parse import SplitResult, parse_qsl, urlsplit, unquote
99

1010
from sqlalchemy import text
1111
from sqlalchemy.sql import ClauseElement
@@ -389,7 +389,14 @@ def __bool__(self) -> bool:
389389

390390
class DatabaseURL:
391391
def __init__(self, url: typing.Union[str, "DatabaseURL"]):
392-
self._url = str(url)
392+
if isinstance(url, DatabaseURL):
393+
self._url: str = url._url
394+
elif isinstance(url, str):
395+
self._url = url
396+
else:
397+
raise TypeError(
398+
f"Invalid type for DatabaseURL. Expected str or DatabaseURL, got {type(url)}"
399+
)
393400

394401
@property
395402
def components(self) -> SplitResult:
@@ -411,13 +418,26 @@ def driver(self) -> str:
411418
return ""
412419
return self.components.scheme.split("+", 1)[1]
413420

421+
@property
422+
def userinfo(self) -> typing.Optional[bytes]:
423+
if self.components.username:
424+
info = self.components.username
425+
if self.components.password:
426+
info += ":" + self.components.password
427+
return info.encode("utf-8")
428+
return None
429+
414430
@property
415431
def username(self) -> typing.Optional[str]:
416-
return self.components.username
432+
if self.components.username is None:
433+
return None
434+
return unquote(self.components.username)
417435

418436
@property
419437
def password(self) -> typing.Optional[str]:
420-
return self.components.password
438+
if self.components.password is None:
439+
return None
440+
return unquote(self.components.password)
421441

422442
@property
423443
def hostname(self) -> typing.Optional[str]:
@@ -436,7 +456,7 @@ def database(self) -> str:
436456
path = self.components.path
437457
if path.startswith("/"):
438458
path = path[1:]
439-
return path
459+
return unquote(path)
440460

441461
@property
442462
def options(self) -> dict:
@@ -453,8 +473,8 @@ def replace(self, **kwargs: typing.Any) -> "DatabaseURL":
453473
):
454474
hostname = kwargs.pop("hostname", self.hostname)
455475
port = kwargs.pop("port", self.port)
456-
username = kwargs.pop("username", self.username)
457-
password = kwargs.pop("password", self.password)
476+
username = kwargs.pop("username", self.components.username)
477+
password = kwargs.pop("password", self.components.password)
458478

459479
netloc = hostname
460480
if port is not None:

tests/test_database_url.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from databases import DatabaseURL
2+
from urllib.parse import quote
3+
import pytest
24

35

46
def test_database_url_repr():
@@ -11,6 +13,9 @@ def test_database_url_repr():
1113
u = DatabaseURL("postgresql://username:password@localhost/name")
1214
assert repr(u) == "DatabaseURL('postgresql://username:********@localhost/name')"
1315

16+
u = DatabaseURL(f"postgresql://username:{quote('[password')}@localhost/name")
17+
assert repr(u) == "DatabaseURL('postgresql://username:********@localhost/name')"
18+
1419

1520
def test_database_url_properties():
1621
u = DatabaseURL("postgresql+asyncpg://username:password@localhost:123/mydatabase")
@@ -23,6 +28,27 @@ def test_database_url_properties():
2328
assert u.database == "mydatabase"
2429

2530

31+
def test_database_url_escape():
32+
u = DatabaseURL(f"postgresql://username:{quote('[password')}@localhost/mydatabase")
33+
assert u.username == "username"
34+
assert u.password == "[password"
35+
assert u.userinfo == f"username:{quote('[password')}".encode("utf-8")
36+
37+
u2 = DatabaseURL(u)
38+
assert u2.password == "[password"
39+
40+
u3 = DatabaseURL(str(u))
41+
assert u3.password == "[password"
42+
43+
44+
def test_database_url_constructor():
45+
with pytest.raises(TypeError):
46+
DatabaseURL(("postgresql", "username", "password", "localhost", "mydatabase"))
47+
48+
u = DatabaseURL("postgresql+asyncpg://username:password@localhost:123/mydatabase")
49+
assert DatabaseURL(u) == u
50+
51+
2652
def test_database_url_options():
2753
u = DatabaseURL("postgresql://localhost/mydatabase?pool_size=20&ssl=true")
2854
assert u.options == {"pool_size": "20", "ssl": "true"}
@@ -46,6 +72,9 @@ def test_replace_database_url_components():
4672
assert new.port == 123
4773
assert str(new) == "postgresql://localhost:123/mydatabase"
4874

75+
assert u.username is None
76+
assert u.userinfo is None
77+
4978
u = DatabaseURL("sqlite:///mydatabase")
5079
assert u.database == "mydatabase"
5180
new = u.replace(database="test_" + u.database)

0 commit comments

Comments
 (0)