use std::borrow::Cow;
use monty_types::{
ExcType, FileMode, MontyDateTime, MontyFileHandle, MontyTime, MontyTimeDelta, MontyTimeZone, MontyType, StringRepr,
unstable::MontyNode,
};
use pyo3::{
exceptions::{PyTypeError, PyValueError},
intern,
prelude::*,
sync::PyOnceLock,
types::{
PyDateAccess, PyDateTime, PyDelta, PyDeltaAccess, PyModule, PyTime, PyTimeAccess, PyTuple, PyType, PyTzInfo,
PyTzInfoAccess,
},
};
use strum::{IntoEnumIterator, VariantNames};
use super::exceptions::exc_class_to_py;
pub(super) fn py_type_object_to_monty(ty: &Bound<'_, PyType>) -> PyResult<Option<MontyType>> {
let py = ty.py();
for (obj, t) in round_trip_type_table(py)? {
if ty.is(obj) {
return Ok(Some(*t));
}
}
Ok(ty.is_subclass(get_pure_path(py)?)?.then_some(MontyType::Path))
}
fn round_trip_type_table(py: Python<'_>) -> PyResult<&'static Vec<(Py<PyAny>, MontyType)>> {
static TABLE: PyOnceLock<Vec<(Py<PyAny>, MontyType)>> = PyOnceLock::new();
TABLE.get_or_try_init(py, || {
MontyType::iter()
.filter(|t| !matches!(t, MontyType::Exception(_)))
.chain(
ExcType::VARIANTS
.iter()
.filter_map(|name| name.parse().ok())
.map(MontyType::Exception),
)
.filter_map(|t| host_type_object(py, t).map(|obj| obj.map(|obj| (obj, t))).transpose())
.collect()
})
}
pub fn import_builtins(py: Python<'_>) -> PyResult<&Py<PyModule>> {
static BUILTINS: PyOnceLock<Py<PyModule>> = PyOnceLock::new();
BUILTINS.get_or_try_init(py, || py.import("builtins").map(Bound::unbind))
}
pub(super) fn host_type_object(py: Python<'_>, t: MontyType) -> PyResult<Option<Py<PyAny>>> {
macro_rules! cached {
($module:literal, $name:literal) => {{
static LOCK: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
LOCK.import(py, $module, $name).map(|b| b.clone().unbind())
}};
}
let obj = match t {
MontyType::Type
| MontyType::Object
| MontyType::Bool
| MontyType::Int
| MontyType::Float
| MontyType::Str
| MontyType::Bytes
| MontyType::List
| MontyType::Tuple
| MontyType::Dict
| MontyType::Set
| MontyType::FrozenSet
| MontyType::Range
| MontyType::Slice => import_builtins(py)?.getattr(py, t.to_string()),
MontyType::NoneType => Ok(py.None().bind(py).get_type().into_any().unbind()),
MontyType::Ellipsis => Ok(py.Ellipsis().bind(py).get_type().into_any().unbind()),
MontyType::NotImplementedType => Ok(py.NotImplemented().bind(py).get_type().into_any().unbind()),
MontyType::Date => cached!("datetime", "date"),
MontyType::DateTime => cached!("datetime", "datetime"),
MontyType::Time => cached!("datetime", "time"),
MontyType::TimeDelta => cached!("datetime", "timedelta"),
MontyType::TimeZone => cached!("datetime", "timezone"),
MontyType::Deque => cached!("collections", "deque"),
MontyType::Path => get_pure_posix_path(py).map(|b| b.clone().unbind()),
MontyType::RePattern => cached!("re", "Pattern"),
MontyType::ReMatch => cached!("re", "Match"),
MontyType::GenericAlias => cached!("types", "GenericAlias"),
MontyType::Union => cached!("types", "UnionType"),
MontyType::Exception(exc_type) => exc_class_to_py(py, exc_type),
_ => return Ok(None),
};
obj.map(Some)
}
pub(super) fn py_timedelta_to_monty(delta: &Bound<'_, PyDelta>) -> MontyTimeDelta {
MontyTimeDelta {
days: delta.get_days(),
seconds: delta.get_seconds(),
microseconds: delta.get_microseconds(),
}
}
pub(super) fn monty_timezone_to_py(py: Python<'_>, timezone: &MontyTimeZone) -> PyResult<Py<PyAny>> {
if timezone.offset_seconds == 0 && timezone.name.is_none() {
return Ok(PyTzInfo::utc(py)?.to_owned().into_any().unbind());
}
let offset = PyDelta::new(py, 0, timezone.offset_seconds, 0, true)?;
match timezone.name.as_deref() {
None => PyTzInfo::fixed_offset(py, offset)
.map(Bound::into_any)
.map(Bound::unbind),
Some(name) => get_datetime_timezone_type(py)?.call1((offset, name)).map(Bound::unbind),
}
}
pub(super) fn py_timezone_to_monty(obj: &Bound<'_, PyAny>) -> PyResult<MontyTimeZone> {
if obj.is(get_datetime_timezone_utc(obj.py())?) {
return Ok(MontyTimeZone {
offset_seconds: 0,
name: None,
});
}
let init_args = obj.call_method0(intern!(obj.py(), "__getinitargs__"))?;
let init_args = init_args.cast::<PyTuple>()?;
Ok(MontyTimeZone {
offset_seconds: timezone_offset_seconds(&py_timedelta_to_monty(
&init_args.get_item(0)?.cast_into::<PyDelta>()?,
))?,
name: init_args.get_item(1).and_then(|n| n.extract::<String>()).ok(),
})
}
pub(super) fn monty_time_to_py(py: Python<'_>, time: &MontyTime) -> PyResult<Py<PyAny>> {
let tzinfo_obj = match (time.offset_seconds, &time.timezone_name) {
(None, None) => None,
(Some(offset_seconds), timezone_name) => Some(monty_timezone_to_py(
py,
&MontyTimeZone {
offset_seconds,
name: timezone_name.clone(),
},
)?),
(None, Some(_)) => {
return Err(PyTypeError::new_err("invalid Monty time: timezone name without offset"));
}
};
let tzinfo = tzinfo_obj
.as_ref()
.map(|obj| obj.bind(py).cast::<PyTzInfo>())
.transpose()?;
PyTime::new_with_fold(
py,
time.hour,
time.minute,
time.second,
time.microsecond,
tzinfo,
time.fold != 0,
)
.map(Bound::into_any)
.map(Bound::unbind)
}
pub(super) fn monty_datetime_to_py(py: Python<'_>, datetime: &MontyDateTime) -> PyResult<Py<PyAny>> {
match (datetime.offset_seconds, &datetime.timezone_name) {
(None, None) => PyDateTime::new(
py,
datetime.year,
datetime.month,
datetime.day,
datetime.hour,
datetime.minute,
datetime.second,
datetime.microsecond,
None,
)
.map(Bound::into_any)
.map(Bound::unbind),
(Some(offset_seconds), timezone_name) => {
let tzinfo_obj = monty_timezone_to_py(
py,
&MontyTimeZone {
offset_seconds,
name: timezone_name.clone(),
},
)?;
let tzinfo = tzinfo_obj.bind(py).cast::<PyTzInfo>()?;
PyDateTime::new(
py,
datetime.year,
datetime.month,
datetime.day,
datetime.hour,
datetime.minute,
datetime.second,
datetime.microsecond,
Some(tzinfo),
)
.map(Bound::into_any)
.map(Bound::unbind)
}
(None, Some(_)) => Err(PyTypeError::new_err(
"invalid Monty datetime: timezone name without offset",
)),
}
}
pub(super) fn py_datetime_to_monty(datetime: &Bound<'_, PyDateTime>) -> PyResult<MontyNode> {
let (offset_seconds, timezone_name) = if let Some(tzinfo) = datetime.get_tzinfo() {
if tzinfo.is_instance(get_datetime_timezone_type(tzinfo.py())?)? {
let timezone = py_timezone_to_monty(&tzinfo)?;
(Some(timezone.offset_seconds), timezone.name)
} else {
py_tzinfo_via_utcoffset(datetime, &tzinfo)?
}
} else {
(None, None)
};
Ok(MontyNode::DateTime(MontyDateTime {
year: datetime.get_year(),
month: datetime.get_month(),
day: datetime.get_day(),
hour: datetime.get_hour(),
minute: datetime.get_minute(),
second: datetime.get_second(),
microsecond: datetime.get_microsecond(),
offset_seconds,
timezone_name,
}))
}
pub(super) fn py_time_to_monty(time: &Bound<'_, PyTime>) -> PyResult<MontyNode> {
let (offset_seconds, timezone_name) = match time.get_tzinfo() {
Some(tzinfo) if tzinfo.is_instance(get_datetime_timezone_type(tzinfo.py())?)? => {
let timezone = py_timezone_to_monty(&tzinfo)?;
(Some(timezone.offset_seconds), timezone.name)
}
Some(tzinfo) => {
return Err(PyTypeError::new_err(format!(
"cannot convert datetime.time with tzinfo of type '{}' to a Monty value",
tzinfo.get_type().name()?
)));
}
None => (None, None),
};
Ok(MontyNode::Time(MontyTime {
hour: time.get_hour(),
minute: time.get_minute(),
second: time.get_second(),
microsecond: time.get_microsecond(),
offset_seconds,
timezone_name,
fold: u8::from(time.get_fold()),
}))
}
fn py_tzinfo_via_utcoffset(
datetime: &Bound<'_, PyDateTime>,
tzinfo: &Bound<'_, PyAny>,
) -> PyResult<(Option<i32>, Option<String>)> {
let py = tzinfo.py();
let utcoffset = tzinfo
.call_method1(intern!(py, "utcoffset"), (datetime,))?
.cast_into::<PyDelta>()?;
let offset = py_timedelta_to_monty(&utcoffset);
let offset_seconds = timezone_offset_seconds(&offset)?;
let name = tzinfo
.call_method1(intern!(py, "tzname"), (datetime,))?
.extract::<Option<String>>()?;
Ok((Some(offset_seconds), name))
}
fn timezone_offset_seconds(delta: &MontyTimeDelta) -> PyResult<i32> {
if delta.microseconds != 0 {
return Err(PyTypeError::new_err(
"datetime.timezone offset must be an exact number of whole seconds",
));
}
let total_seconds = i64::from(delta.days)
.checked_mul(86_400)
.and_then(|days| days.checked_add(i64::from(delta.seconds)))
.ok_or_else(|| PyTypeError::new_err("datetime.timezone offset is out of range"))?;
i32::try_from(total_seconds).map_err(|_| PyTypeError::new_err("datetime.timezone offset is out of range"))
}
pub(super) fn get_datetime_timezone_type(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static TIMEZONE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
TIMEZONE.import(py, "datetime", "timezone")
}
fn get_datetime_timezone_utc(py: Python<'_>) -> PyResult<&Py<PyAny>> {
static TIMEZONE_UTC: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
TIMEZONE_UTC.get_or_try_init(py, || {
get_datetime_timezone_type(py)?
.getattr(intern!(py, "utc"))
.map(Bound::unbind)
})
}
pub(super) fn get_namedtuple(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static NAMEDTUPLE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
NAMEDTUPLE.import(py, "collections", "namedtuple")
}
pub(super) fn get_pure_posix_path(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static PUREPOSIX: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
PUREPOSIX.import(py, "pathlib", "PurePosixPath")
}
fn get_pure_path(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static PUREPATH: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
PUREPATH.import(py, "pathlib", "PurePath")
}
#[pyclass(name = "MontyFileHandle", module = "pydantic_monty", frozen)]
pub struct PyMontyFileHandle(MontyFileHandle);
impl PyMontyFileHandle {
pub(crate) fn from_inner(inner: MontyFileHandle) -> Self {
Self(inner)
}
pub(super) fn inner(&self) -> &MontyFileHandle {
&self.0
}
}
#[pymethods]
impl PyMontyFileHandle {
#[new]
#[pyo3(signature = (path, mode, *, position = 0))]
fn py_new(path: String, mode: &str, position: u64) -> PyResult<Self> {
let mode: FileMode = mode
.parse()
.map_err(|e: Cow<'static, str>| PyValueError::new_err(e.to_string()))?;
Ok(Self::from_inner(MontyFileHandle { path, mode, position }))
}
#[getter]
fn path(&self) -> &str {
&self.0.path
}
#[getter]
fn mode(&self) -> &'static str {
self.0.mode.as_str()
}
#[getter]
fn position(&self) -> u64 {
self.0.position
}
#[getter]
fn binary(&self) -> bool {
self.0.mode.is_binary()
}
#[getter]
fn readable(&self) -> bool {
self.0.mode.readable()
}
#[getter]
fn writable(&self) -> bool {
self.0.mode.writable()
}
fn __repr__(&self) -> String {
format!(
"MontyFileHandle(path={}, mode={})",
StringRepr(&self.0.path),
StringRepr(self.0.mode.as_str())
)
}
}
pub fn get_name(f: &Bound<'_, PyAny>) -> String {
f.getattr(intern!(f.py(), "__name__"))
.and_then(|n| n.extract::<String>())
.unwrap_or_else(|_| "<unknown>".to_string())
}
pub fn get_docstring(f: &Bound<'_, PyAny>) -> Option<String> {
f.getattr(intern!(f.py(), "__doc__"))
.and_then(|d| d.extract::<String>())
.ok()
}