Skip to content

Commit 8adeccf

Browse files
ollz272davidhewitt
andauthored
feat: add build method back to multihosturl (#730)
Co-authored-by: Oli Parker <[email protected]> Co-authored-by: David Hewitt <[email protected]>
1 parent 75daffc commit 8adeccf

File tree

3 files changed

+261
-3
lines changed

3 files changed

+261
-3
lines changed

python/pydantic_core/_pydantic_core.pyi

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ from __future__ import annotations
22

33
import decimal
44
import sys
5-
from typing import Any, Callable, Generic, Type, TypeVar
5+
from typing import Any, Callable, Generic, Optional, Type, TypeVar
66

77
from pydantic_core import ErrorDetails, ErrorTypeInfo, InitErrorDetails, MultiHostHost
88
from pydantic_core.core_schema import CoreConfig, CoreSchema, ErrorType
@@ -191,6 +191,19 @@ class Url(SupportsAllComparisons):
191191
def __repr__(self) -> str: ...
192192
def __str__(self) -> str: ...
193193
def __deepcopy__(self, memo: dict) -> str: ...
194+
@classmethod
195+
def build(
196+
cls,
197+
*,
198+
scheme: str,
199+
username: Optional[str] = None,
200+
password: Optional[str] = None,
201+
host: str,
202+
port: Optional[int] = None,
203+
path: Optional[str] = None,
204+
query: Optional[str] = None,
205+
fragment: Optional[str] = None,
206+
) -> str: ...
194207

195208
class MultiHostUrl(SupportsAllComparisons):
196209
def __new__(cls, url: str) -> Self: ...
@@ -208,6 +221,20 @@ class MultiHostUrl(SupportsAllComparisons):
208221
def __repr__(self) -> str: ...
209222
def __str__(self) -> str: ...
210223
def __deepcopy__(self, memo: dict) -> Self: ...
224+
@classmethod
225+
def build(
226+
cls,
227+
*,
228+
scheme: str,
229+
username: Optional[str] = None,
230+
password: Optional[str] = None,
231+
host: Optional[str] = None,
232+
hosts: Optional[list[MultiHostHost]] = None,
233+
port: Optional[int] = None,
234+
path: Optional[str] = None,
235+
query: Optional[str] = None,
236+
fragment: Optional[str] = None,
237+
) -> str: ...
211238

212239
@final
213240
class SchemaError(Exception):

src/url.rs

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
use std::collections::hash_map::DefaultHasher;
2+
use std::fmt;
3+
use std::fmt::Formatter;
24
use std::hash::{Hash, Hasher};
35

46
use idna::punycode::decode_to_string;
7+
use pyo3::exceptions::PyValueError;
58
use pyo3::once_cell::GILOnceCell;
6-
use pyo3::prelude::*;
79
use pyo3::pyclass::CompareOp;
8-
use pyo3::types::PyDict;
10+
use pyo3::types::{PyDict, PyType};
11+
use pyo3::{intern, prelude::*};
912
use url::Url;
1013

14+
use crate::tools::SchemaDict;
1115
use crate::SchemaValidator;
1216

1317
static SCHEMA_DEFINITION_URL: GILOnceCell<SchemaValidator> = GILOnceCell::new();
@@ -150,6 +154,42 @@ impl PyUrl {
150154
fn __getnewargs__(&self) -> (&str,) {
151155
(self.__str__(),)
152156
}
157+
158+
#[classmethod]
159+
#[pyo3(signature=(*, scheme, host, username=None, password=None, port=None, path=None, query=None, fragment=None))]
160+
#[allow(clippy::too_many_arguments)]
161+
pub fn build<'a>(
162+
cls: &'a PyType,
163+
scheme: &str,
164+
host: &str,
165+
username: Option<&str>,
166+
password: Option<&str>,
167+
port: Option<u16>,
168+
path: Option<&str>,
169+
query: Option<&str>,
170+
fragment: Option<&str>,
171+
) -> PyResult<&'a PyAny> {
172+
let url_host = UrlHostParts {
173+
username: username.map(Into::into),
174+
password: password.map(Into::into),
175+
host: Some(host.into()),
176+
port,
177+
};
178+
let mut url = format!("{scheme}://{url_host}");
179+
if let Some(path) = path {
180+
url.push('/');
181+
url.push_str(path);
182+
}
183+
if let Some(query) = query {
184+
url.push('?');
185+
url.push_str(query);
186+
}
187+
if let Some(fragment) = fragment {
188+
url.push('#');
189+
url.push_str(fragment);
190+
}
191+
cls.call1((url,))
192+
}
153193
}
154194

155195
#[pyclass(name = "MultiHostUrl", module = "pydantic_core._pydantic_core", subclass)]
@@ -314,6 +354,114 @@ impl PyMultiHostUrl {
314354
fn __getnewargs__(&self) -> (String,) {
315355
(self.__str__(),)
316356
}
357+
358+
#[classmethod]
359+
#[pyo3(signature=(*, scheme, hosts=None, path=None, query=None, fragment=None, host=None, username=None, password=None, port=None))]
360+
#[allow(clippy::too_many_arguments)]
361+
pub fn build<'a>(
362+
cls: &'a PyType,
363+
scheme: &str,
364+
hosts: Option<Vec<UrlHostParts>>,
365+
path: Option<&str>,
366+
query: Option<&str>,
367+
fragment: Option<&str>,
368+
// convenience parameters to build with a single host
369+
host: Option<&str>,
370+
username: Option<&str>,
371+
password: Option<&str>,
372+
port: Option<u16>,
373+
) -> PyResult<&'a PyAny> {
374+
let mut url =
375+
if hosts.is_some() && (host.is_some() || username.is_some() || password.is_some() || port.is_some()) {
376+
return Err(PyValueError::new_err(
377+
"expected one of `hosts` or singular values to be set.",
378+
));
379+
} else if let Some(hosts) = hosts {
380+
// check all of host / user / password / port empty
381+
// build multi-host url
382+
let mut multi_url = format!("{scheme}://");
383+
for (index, single_host) in hosts.iter().enumerate() {
384+
if single_host.is_empty() {
385+
return Err(PyValueError::new_err(
386+
"expected one of 'host', 'username', 'password' or 'port' to be set",
387+
));
388+
}
389+
multi_url.push_str(&single_host.to_string());
390+
if index != hosts.len() - 1 {
391+
multi_url.push(',');
392+
};
393+
}
394+
multi_url
395+
} else if host.is_some() {
396+
let url_host = UrlHostParts {
397+
username: username.map(Into::into),
398+
password: password.map(Into::into),
399+
host: host.map(Into::into),
400+
port: port.map(Into::into),
401+
};
402+
format!("{scheme}://{url_host}")
403+
} else {
404+
return Err(PyValueError::new_err("expected either `host` or `hosts` to be set"));
405+
};
406+
407+
if let Some(path) = path {
408+
url.push('/');
409+
url.push_str(path);
410+
}
411+
if let Some(query) = query {
412+
url.push('?');
413+
url.push_str(query);
414+
}
415+
if let Some(fragment) = fragment {
416+
url.push('#');
417+
url.push_str(fragment);
418+
}
419+
cls.call1((url,))
420+
}
421+
}
422+
423+
pub struct UrlHostParts {
424+
username: Option<String>,
425+
password: Option<String>,
426+
host: Option<String>,
427+
port: Option<u16>,
428+
}
429+
430+
impl UrlHostParts {
431+
fn is_empty(&self) -> bool {
432+
self.host.is_none() && self.password.is_none() && self.host.is_none() && self.port.is_none()
433+
}
434+
}
435+
436+
impl FromPyObject<'_> for UrlHostParts {
437+
fn extract(ob: &'_ PyAny) -> PyResult<Self> {
438+
let py = ob.py();
439+
let dict = ob.downcast::<PyDict>()?;
440+
Ok(UrlHostParts {
441+
username: dict.get_as(intern!(py, "username"))?,
442+
password: dict.get_as(intern!(py, "password"))?,
443+
host: dict.get_as(intern!(py, "host"))?,
444+
port: dict.get_as(intern!(py, "port"))?,
445+
})
446+
}
447+
}
448+
449+
impl fmt::Display for UrlHostParts {
450+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
451+
match (&self.username, &self.password) {
452+
(Some(username), None) => write!(f, "{username}@")?,
453+
(None, Some(password)) => write!(f, ":{password}@")?,
454+
(Some(username), Some(password)) => write!(f, "{username}:{password}@")?,
455+
(None, None) => {}
456+
};
457+
if let Some(host) = &self.host {
458+
write!(f, "{host}")?;
459+
}
460+
if let Some(port) = self.port {
461+
write!(f, ":{port}")?;
462+
}
463+
Ok(())
464+
}
317465
}
318466

319467
fn host_to_dict<'a>(py: Python<'a>, lib_url: &Url) -> PyResult<&'a PyDict> {

tests/validators/test_url.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,3 +1216,86 @@ def test_url_hash() -> None:
12161216

12171217
def test_url_deepcopy() -> None:
12181218
assert deepcopy(Url('http://example.com')) == Url('http://example.com/')
1219+
1220+
1221+
def test_multi_url_build() -> None:
1222+
url = MultiHostUrl.build(
1223+
scheme='postgresql',
1224+
username='testuser',
1225+
password='testpassword',
1226+
host='127.0.0.1',
1227+
port=5432,
1228+
path='database',
1229+
query='sslmode=require',
1230+
fragment='test',
1231+
)
1232+
assert url == MultiHostUrl('postgresql://testuser:[email protected]:5432/database?sslmode=require#test')
1233+
assert str(url) == 'postgresql://testuser:[email protected]:5432/database?sslmode=require#test'
1234+
1235+
1236+
@pytest.mark.parametrize('field', ['host', 'password', 'username', 'port'])
1237+
def test_multi_url_build_hosts_set_with_single_value(field) -> None:
1238+
"""Hosts can't be provided with any single url values."""
1239+
hosts = [
1240+
{'host': '127.0.0.1', 'password': 'testpassword', 'username': 'testuser', 'port': 5432},
1241+
{'host': '127.0.0.1', 'password': 'testpassword', 'username': 'testuser', 'port': 5432},
1242+
]
1243+
kwargs = dict(scheme='postgresql', hosts=hosts, path='database', query='sslmode=require', fragment='test')
1244+
if field == 'port':
1245+
kwargs[field] = 5432
1246+
else:
1247+
kwargs[field] = 'test'
1248+
with pytest.raises(ValueError):
1249+
MultiHostUrl.build(**kwargs)
1250+
1251+
1252+
def test_multi_url_build_hosts_empty_host() -> None:
1253+
"""Hosts can't be provided with any single url values."""
1254+
hosts = [{}]
1255+
with pytest.raises(ValueError):
1256+
MultiHostUrl.build(scheme='postgresql', hosts=hosts, path='database', query='sslmode=require', fragment='test')
1257+
1258+
1259+
def test_multi_url_build_hosts() -> None:
1260+
"""Hosts can't be provided with any single url values."""
1261+
hosts = [
1262+
{'host': '127.0.0.1', 'password': 'testpassword', 'username': 'testuser', 'port': 5431},
1263+
{'host': '127.0.0.1', 'password': 'testpassword', 'username': 'testuser', 'port': 5433},
1264+
]
1265+
kwargs = dict(scheme='postgresql', hosts=hosts, path='database', query='sslmode=require', fragment='test')
1266+
url = MultiHostUrl.build(**kwargs)
1267+
assert url == MultiHostUrl(
1268+
'postgresql://testuser:[email protected]:5431,testuser:[email protected]:5433/database?sslmode=require#test'
1269+
)
1270+
assert (
1271+
str(url)
1272+
== 'postgresql://testuser:[email protected]:5431,testuser:[email protected]:5433/database?sslmode=require#test'
1273+
)
1274+
1275+
1276+
def test_multi_url_build_neither_host_and_hosts_set() -> None:
1277+
with pytest.raises(ValueError):
1278+
MultiHostUrl.build(
1279+
scheme='postgresql',
1280+
username='testuser',
1281+
password='testpassword',
1282+
port=5432,
1283+
path='database',
1284+
query='sslmode=require',
1285+
fragment='test',
1286+
)
1287+
1288+
1289+
def test_url_build() -> None:
1290+
url = Url.build(
1291+
scheme='postgresql',
1292+
username='testuser',
1293+
password='testpassword',
1294+
host='127.0.0.1',
1295+
port=5432,
1296+
path='database',
1297+
query='sslmode=require',
1298+
fragment='test',
1299+
)
1300+
assert url == Url('postgresql://testuser:[email protected]:5432/database?sslmode=require#test')
1301+
assert str(url) == 'postgresql://testuser:[email protected]:5432/database?sslmode=require#test'

0 commit comments

Comments
 (0)