diff --git a/bson/_cbsonmodule.c b/bson/_cbsonmodule.c index 3b3aecc441..1619b9f301 100644 --- a/bson/_cbsonmodule.c +++ b/bson/_cbsonmodule.c @@ -53,8 +53,6 @@ struct module_state { PyObject* Decimal128; PyObject* Mapping; PyObject* DatetimeMS; - PyObject* _min_datetime_ms; - PyObject* _max_datetime_ms; PyObject* _type_marker_str; PyObject* _flags_str; PyObject* _pattern_str; @@ -80,6 +78,10 @@ struct module_state { PyObject* _from_uuid_str; PyObject* _as_uuid_str; PyObject* _from_bid_str; + PyObject* min_datetime; + PyObject* max_datetime; + int64_t min_millis; + int64_t max_millis; }; #define GETSTATE(m) ((struct module_state*)PyModule_GetState(m)) @@ -253,7 +255,7 @@ static PyObject* datetime_from_millis(long long millis) { * 2. Multiply that by 1000: 253402300799000 * 3. Add in microseconds divided by 1000 253402300799999 * - * (Note: BSON doesn't support microsecond accuracy, hence the rounding.) + * (Note: BSON doesn't support microsecond accuracy, hence the truncation.) * * To decode we could do: * 1. Get seconds: timestamp / 1000: 253402300799 @@ -376,6 +378,110 @@ static int millis_from_datetime_ms(PyObject* dt, long long* out){ return 1; } +static PyObject* decode_datetime(PyObject* self, long long millis, const codec_options_t* options){ + PyObject* naive = NULL; + PyObject* replace = NULL; + PyObject* args = NULL; + PyObject* kwargs = NULL; + PyObject* value = NULL; + struct module_state *state = GETSTATE(self); + if (options->datetime_conversion == DATETIME_MS){ + return datetime_ms_from_millis(self, millis); + } + + int dt_clamp = options->datetime_conversion == DATETIME_CLAMP; + int dt_auto = options->datetime_conversion == DATETIME_AUTO; + + if (dt_clamp || dt_auto){ + if (dt_clamp) { + if (millis < state->min_millis) { + millis = state->min_millis; + } else if (millis > state->max_millis) { + millis = state->max_millis; + } + // Continues from here to return a datetime. + } else { // dt_auto + if (millis < state->min_millis || millis > state->max_millis){ + return datetime_ms_from_millis(self, millis); + } + } + } + + naive = datetime_from_millis(millis); + if (!naive) { + goto invalid; + } + + if (!options->tz_aware) { /* In the naive case, we're done here. */ + return naive; + } + replace = PyObject_GetAttr(naive, state->_replace_str); + if (!replace) { + goto invalid; + } + args = PyTuple_New(0); + if (!args) { + goto invalid; + } + kwargs = PyDict_New(); + if (!kwargs) { + goto invalid; + } + if (PyDict_SetItem(kwargs, state->_tzinfo_str, state->UTC) == -1) { + goto invalid; + } + value = PyObject_Call(replace, args, kwargs); + if (!value) { + goto invalid; + } + + /* convert to local time */ + if (options->tzinfo != Py_None) { + PyObject* temp = PyObject_CallMethodObjArgs(value, state->_astimezone_str, options->tzinfo, NULL); + Py_DECREF(value); + value = temp; + if (!value && (dt_clamp || dt_auto)) { + PyObject *etype = NULL, *evalue = NULL, *etrace = NULL; + // Calling PyErr_Fetch clears the error state. + PyErr_Fetch(&etype, &evalue, &etrace); + // Catch overflow due to timezone via PyExc_ArithmeticError exceptions. + if (!PyErr_GivenExceptionMatches(etype, PyExc_ArithmeticError)) { + // Steals references to args. + PyErr_Restore(etype, evalue, etrace); + goto invalid; + } + Py_XDECREF(etype); + Py_XDECREF(evalue); + Py_XDECREF(etrace); + if (dt_clamp) { + PyObject* dtm; + Py_XDECREF(replace); + if (millis < 0) { + dtm = state->min_datetime; + } else { + dtm = state->max_datetime; + } + if (PyDict_SetItem(kwargs, state->_tzinfo_str, options->tzinfo) == -1) { + goto invalid; + } + replace = PyObject_GetAttr(dtm, state->_replace_str); + if (!replace) { + goto invalid; + } + value = PyObject_Call(replace, args, kwargs); + } else { // dt_auto + value = datetime_ms_from_millis(self, millis); + } + } + } +invalid: + Py_XDECREF(naive); + Py_XDECREF(replace); + Py_XDECREF(args); + Py_XDECREF(kwargs); + return value; +} + /* Just make this compatible w/ the old API. */ int buffer_write_bytes(buffer_t buffer, const char* data, int size) { if (pymongo_buffer_write(buffer, data, size)) { @@ -482,6 +588,8 @@ static int _load_python_objects(PyObject* module) { PyObject* empty_string = NULL; PyObject* re_compile = NULL; PyObject* compiled = NULL; + PyObject* min_datetime_ms = NULL; + PyObject* max_datetime_ms = NULL; struct module_state *state = GETSTATE(module); if (!state) { return 1; @@ -530,10 +638,21 @@ static int _load_python_objects(PyObject* module) { _load_object(&state->UUID, "uuid", "UUID") || _load_object(&state->Mapping, "collections.abc", "Mapping") || _load_object(&state->DatetimeMS, "bson.datetime_ms", "DatetimeMS") || - _load_object(&state->_min_datetime_ms, "bson.datetime_ms", "_min_datetime_ms") || - _load_object(&state->_max_datetime_ms, "bson.datetime_ms", "_max_datetime_ms")) { + _load_object(&min_datetime_ms, "bson.datetime_ms", "_MIN_DATETIME_MS") || + _load_object(&max_datetime_ms, "bson.datetime_ms", "_MAX_DATETIME_MS") || + _load_object(&state->min_datetime, "bson.datetime_ms", "_MIN_DATETIME") || + _load_object(&state->max_datetime, "bson.datetime_ms", "_MAX_DATETIME")) { + return 1; + } + + state->min_millis = PyLong_AsLongLong(min_datetime_ms); + state->max_millis = PyLong_AsLongLong(max_datetime_ms); + Py_DECREF(min_datetime_ms); + Py_DECREF(max_datetime_ms); + if ((state->min_millis == -1 || state->max_millis == -1) && PyErr_Occurred()) { return 1; } + /* Reload our REType hack too. */ empty_string = PyBytes_FromString(""); if (empty_string == NULL) { @@ -1241,21 +1360,29 @@ static int _write_element_to_buffer(PyObject* self, buffer_t buffer, *(pymongo_buffer_get_buffer(buffer) + type_byte) = 0x02; return write_unicode(buffer, value); } else if (PyDateTime_Check(value)) { - long long millis; + long long millis = millis_from_datetime(value); PyObject* utcoffset = PyObject_CallMethodObjArgs(value, state->_utcoffset_str , NULL); if (utcoffset == NULL) return 0; if (utcoffset != Py_None) { - PyObject* result = PyNumber_Subtract(value, utcoffset); - Py_DECREF(utcoffset); - if (!result) { + if (!PyDelta_Check(utcoffset)) { + PyObject* BSONError = _error("BSONError"); + if (BSONError) { + PyErr_SetString(BSONError, + "datetime.utcoffset() did not return a datetime.timedelta"); + Py_DECREF(BSONError); + } + Py_DECREF(utcoffset); return 0; } - millis = millis_from_datetime(result); - Py_DECREF(result); - } else { - millis = millis_from_datetime(value); + PyDateTime_DELTA_GET_DAYS(utcoffset); + PyDateTime_DELTA_GET_SECONDS(utcoffset); + PyDateTime_DELTA_GET_MICROSECONDS(utcoffset); + millis -= (PyDateTime_DELTA_GET_DAYS(utcoffset) * 86400 + + PyDateTime_DELTA_GET_SECONDS(utcoffset)) * 1000 + + (PyDateTime_DELTA_GET_MICROSECONDS(utcoffset) / 1000); } + Py_DECREF(utcoffset); *(pymongo_buffer_get_buffer(buffer) + type_byte) = 0x09; return buffer_write_int64(buffer, (int64_t)millis); } else if (PyObject_TypeCheck(value, state->REType)) { @@ -2043,11 +2170,6 @@ static PyObject* get_value(PyObject* self, PyObject* name, const char* buffer, } case 9: { - PyObject* naive; - PyObject* replace; - PyObject* args; - PyObject* kwargs; - PyObject* astimezone; int64_t millis; if (max < 8) { goto invalid; @@ -2056,120 +2178,7 @@ static PyObject* get_value(PyObject* self, PyObject* name, const char* buffer, millis = (int64_t)BSON_UINT64_FROM_LE(millis); *position += 8; - if (options->datetime_conversion == DATETIME_MS){ - value = datetime_ms_from_millis(self, millis); - break; - } - - int dt_clamp = options->datetime_conversion == DATETIME_CLAMP; - int dt_auto = options->datetime_conversion == DATETIME_AUTO; - - - if (dt_clamp || dt_auto){ - PyObject *min_millis_fn_res; - PyObject *max_millis_fn_res; - int64_t min_millis; - int64_t max_millis; - - if (options->tz_aware){ - PyObject* tzinfo = options->tzinfo; - if (tzinfo == Py_None) { - // Default to UTC. - tzinfo = state->UTC; - } - min_millis_fn_res = PyObject_CallFunctionObjArgs(state->_min_datetime_ms, tzinfo, NULL); - max_millis_fn_res = PyObject_CallFunctionObjArgs(state->_max_datetime_ms, tzinfo, NULL); - } else { - min_millis_fn_res = PyObject_CallObject(state->_min_datetime_ms, NULL); - max_millis_fn_res = PyObject_CallObject(state->_max_datetime_ms, NULL); - } - - if (!min_millis_fn_res || !max_millis_fn_res){ - Py_XDECREF(min_millis_fn_res); - Py_XDECREF(max_millis_fn_res); - goto invalid; - } - - min_millis = PyLong_AsLongLong(min_millis_fn_res); - max_millis = PyLong_AsLongLong(max_millis_fn_res); - - if ((min_millis == -1 || max_millis == -1) && PyErr_Occurred()) - { - // min/max_millis check - goto invalid; - } - - if (dt_clamp) { - if (millis < min_millis) { - millis = min_millis; - } else if (millis > max_millis) { - millis = max_millis; - } - // Continues from here to return a datetime. - } else { // dt_auto - if (millis < min_millis || millis > max_millis){ - value = datetime_ms_from_millis(self, millis); - break; // Out-of-range so done. - } - } - } - - naive = datetime_from_millis(millis); - if (!options->tz_aware) { /* In the naive case, we're done here. */ - value = naive; - break; - } - - if (!naive) { - goto invalid; - } - replace = PyObject_GetAttr(naive, state->_replace_str); - Py_DECREF(naive); - if (!replace) { - goto invalid; - } - args = PyTuple_New(0); - if (!args) { - Py_DECREF(replace); - goto invalid; - } - kwargs = PyDict_New(); - if (!kwargs) { - Py_DECREF(replace); - Py_DECREF(args); - goto invalid; - } - if (PyDict_SetItem(kwargs, state->_tzinfo_str, state->UTC) == -1) { - Py_DECREF(replace); - Py_DECREF(args); - Py_DECREF(kwargs); - goto invalid; - } - value = PyObject_Call(replace, args, kwargs); - if (!value) { - Py_DECREF(replace); - Py_DECREF(args); - Py_DECREF(kwargs); - goto invalid; - } - - /* convert to local time */ - if (options->tzinfo != Py_None) { - astimezone = PyObject_GetAttr(value, state->_astimezone_str); - Py_DECREF(value); - if (!astimezone) { - Py_DECREF(replace); - Py_DECREF(args); - Py_DECREF(kwargs); - goto invalid; - } - value = PyObject_CallFunctionObjArgs(astimezone, options->tzinfo, NULL); - Py_DECREF(astimezone); - } - - Py_DECREF(replace); - Py_DECREF(args); - Py_DECREF(kwargs); + value = decode_datetime(self, millis, options); break; } case 11: @@ -3041,6 +3050,8 @@ static int _cbson_traverse(PyObject *m, visitproc visit, void *arg) { Py_VISIT(state->_from_uuid_str); Py_VISIT(state->_as_uuid_str); Py_VISIT(state->_from_bid_str); + Py_VISIT(state->min_datetime); + Py_VISIT(state->max_datetime); return 0; } @@ -3085,6 +3096,8 @@ static int _cbson_clear(PyObject *m) { Py_CLEAR(state->_from_uuid_str); Py_CLEAR(state->_as_uuid_str); Py_CLEAR(state->_from_bid_str); + Py_CLEAR(state->min_datetime); + Py_CLEAR(state->max_datetime); return 0; } diff --git a/bson/datetime_ms.py b/bson/datetime_ms.py index 112871a16c..a2053c9ed6 100644 --- a/bson/datetime_ms.py +++ b/bson/datetime_ms.py @@ -18,9 +18,7 @@ """ from __future__ import annotations -import calendar import datetime -import functools from typing import Any, Union, cast from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, DatetimeConversion @@ -114,19 +112,6 @@ def __int__(self) -> int: return self._value -# Inclusive and exclusive min and max for timezones. -# Timezones are hashed by their offset, which is a timedelta -# and therefore there are more than 24 possible timezones. -@functools.lru_cache(maxsize=None) -def _min_datetime_ms(tz: datetime.timezone = datetime.timezone.utc) -> int: - return _datetime_to_millis(datetime.datetime.min.replace(tzinfo=tz)) - - -@functools.lru_cache(maxsize=None) -def _max_datetime_ms(tz: datetime.timezone = datetime.timezone.utc) -> int: - return _datetime_to_millis(datetime.datetime.max.replace(tzinfo=tz)) - - def _millis_to_datetime( millis: int, opts: CodecOptions[Any] ) -> Union[datetime.datetime, DatetimeMS]: @@ -138,9 +123,9 @@ def _millis_to_datetime( ): tz = opts.tzinfo or datetime.timezone.utc if opts.datetime_conversion == DatetimeConversion.DATETIME_CLAMP: - millis = max(_min_datetime_ms(tz), min(millis, _max_datetime_ms(tz))) + millis = max(_MIN_DATETIME_MS, min(millis, _MAX_DATETIME_MS)) elif opts.datetime_conversion == DatetimeConversion.DATETIME_AUTO: - if not (_min_datetime_ms(tz) <= millis <= _max_datetime_ms(tz)): + if not (_MIN_DATETIME_MS <= millis <= _MAX_DATETIME_MS): return DatetimeMS(millis) diff = ((millis % 1000) + 1000) % 1000 @@ -156,6 +141,13 @@ def _millis_to_datetime( else: return EPOCH_NAIVE + datetime.timedelta(seconds=seconds, microseconds=micros) except ArithmeticError as err: + # Account for min/max edge cases in timezones. + if opts.datetime_conversion == DatetimeConversion.DATETIME_CLAMP: + if millis < 0: + return _MIN_DATETIME.replace(tzinfo=opts.tzinfo) + return _MAX_DATETIME.replace(tzinfo=opts.tzinfo) + elif opts.datetime_conversion == DatetimeConversion.DATETIME_AUTO: + return DatetimeMS(millis) raise InvalidBSON(f"{err} {_DATETIME_ERROR_SUGGESTION}") from err elif opts.datetime_conversion == DatetimeConversion.DATETIME_MS: @@ -166,6 +158,16 @@ def _millis_to_datetime( def _datetime_to_millis(dtm: datetime.datetime) -> int: """Convert datetime to milliseconds since epoch UTC.""" - if dtm.utcoffset() is not None: - dtm = dtm - dtm.utcoffset() # type: ignore - return int(calendar.timegm(dtm.timetuple()) * 1000 + dtm.microsecond // 1000) + if dtm.tzinfo is not None: + delta = dtm - EPOCH_AWARE + else: + delta = dtm - EPOCH_NAIVE + return (delta.days * 86400 + delta.seconds) * 1000 + (delta.microseconds // 1000) + + +# Inclusive min and max for UTC timezones +_MIN_DATETIME = datetime.datetime.min.replace(tzinfo=utc) +# BSON truncates the microsecond field to at most 999 milliseconds. +_MAX_DATETIME = datetime.datetime.max.replace(tzinfo=utc, microsecond=999000) +_MIN_DATETIME_MS = _datetime_to_millis(_MIN_DATETIME) +_MAX_DATETIME_MS = _datetime_to_millis(_MAX_DATETIME) diff --git a/bson/json_util.py b/bson/json_util.py index 6c5197c75a..de5121d3ff 100644 --- a/bson/json_util.py +++ b/bson/json_util.py @@ -125,10 +125,10 @@ from bson.code import Code from bson.codec_options import CodecOptions, DatetimeConversion from bson.datetime_ms import ( + _MAX_DATETIME_MS, EPOCH_AWARE, DatetimeMS, _datetime_to_millis, - _max_datetime_ms, _millis_to_datetime, ) from bson.dbref import DBRef @@ -844,7 +844,7 @@ def _encode_binary(data: bytes, subtype: int, json_options: JSONOptions) -> Any: def _encode_datetimems(obj: Any, json_options: JSONOptions) -> dict: if ( json_options.datetime_representation == DatetimeRepresentation.ISO8601 - and 0 <= int(obj) <= _max_datetime_ms() + and 0 <= int(obj) <= _MAX_DATETIME_MS ): return _encode_datetime(obj.as_datetime(), json_options) elif json_options.datetime_representation == DatetimeRepresentation.LEGACY: @@ -926,7 +926,8 @@ def _encode_datetime(obj: datetime.datetime, json_options: JSONOptions) -> dict: tz_string = "Z" else: tz_string = obj.strftime("%z") - millis = int(obj.microsecond / 1000) + # TODO: write test case + millis = obj.microsecond // 1000 fracsecs = ".%03d" % (millis,) if millis else "" return { "$date": "{}{}{}".format(obj.strftime("%Y-%m-%dT%H:%M:%S"), fracsecs, tz_string) diff --git a/bson/objectid.py b/bson/objectid.py index a5500872da..970c4e52e8 100644 --- a/bson/objectid.py +++ b/bson/objectid.py @@ -16,7 +16,6 @@ from __future__ import annotations import binascii -import calendar import datetime import os import struct @@ -25,6 +24,7 @@ from random import SystemRandom from typing import Any, NoReturn, Optional, Type, Union +from bson.datetime_ms import _datetime_to_millis from bson.errors import InvalidId from bson.tz_util import utc @@ -131,11 +131,10 @@ def from_datetime(cls: Type[ObjectId], generation_time: datetime.datetime) -> Ob :param generation_time: :class:`~datetime.datetime` to be used as the generation time for the resulting ObjectId. """ - offset = generation_time.utcoffset() - if offset is not None: - generation_time = generation_time - offset - timestamp = calendar.timegm(generation_time.timetuple()) - oid = _PACK_INT(int(timestamp)) + b"\x00\x00\x00\x00\x00\x00\x00\x00" + oid = ( + _PACK_INT(_datetime_to_millis(generation_time) // 1000) + + b"\x00\x00\x00\x00\x00\x00\x00\x00" + ) return cls(oid) @classmethod diff --git a/bson/timestamp.py b/bson/timestamp.py index 3e76e7baad..1fb6401e11 100644 --- a/bson/timestamp.py +++ b/bson/timestamp.py @@ -15,11 +15,11 @@ """Tools for representing MongoDB internal Timestamps.""" from __future__ import annotations -import calendar import datetime from typing import Any, Union from bson._helpers import _getstate_slots, _setstate_slots +from bson.datetime_ms import _datetime_to_millis from bson.tz_util import utc UPPERBOUND = 4294967296 @@ -53,10 +53,7 @@ def __init__(self, time: Union[datetime.datetime, int], inc: int) -> None: :param inc: the incrementing counter """ if isinstance(time, datetime.datetime): - offset = time.utcoffset() - if offset is not None: - time = time - offset - time = int(calendar.timegm(time.timetuple())) + time = _datetime_to_millis(time) // 1000 if not isinstance(time, int): raise TypeError("time must be an instance of int") if not isinstance(inc, int): diff --git a/doc/examples/datetimes.rst b/doc/examples/datetimes.rst index 5571880e94..1790506423 100644 --- a/doc/examples/datetimes.rst +++ b/doc/examples/datetimes.rst @@ -98,7 +98,7 @@ out of MongoDB in US/Pacific time: >>> aware_times = db.times.with_options(codec_options=CodecOptions( ... tz_aware=True, ... tzinfo=pytz.timezone('US/Pacific'))) - >>> result = aware_times.find_one() + >>> result = aware_times.find_one()['date'] datetime.datetime(2002, 10, 27, 6, 0, # doctest: +NORMALIZE_WHITESPACE tzinfo=) diff --git a/test/test_bson.py b/test/test_bson.py index fec84090d2..698d05ae7d 100644 --- a/test/test_bson.py +++ b/test/test_bson.py @@ -1247,54 +1247,124 @@ def test_class_conversions(self): def test_clamping(self): # Test clamping from below and above. - opts1 = CodecOptions( + opts = CodecOptions( datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True, tzinfo=datetime.timezone.utc, ) below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 1)}) - dec_below = decode(below, opts1) + dec_below = decode(below, opts) self.assertEqual( dec_below["x"], datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) ) above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 1)}) - dec_above = decode(above, opts1) + dec_above = decode(above, opts) self.assertEqual( dec_above["x"], datetime.datetime.max.replace(tzinfo=datetime.timezone.utc, microsecond=999000), ) - def test_tz_clamping(self): + def test_tz_clamping_local(self): # Naive clamping to local tz. - opts1 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=False) + opts = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=False) below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 24 * 60 * 60)}) - dec_below = decode(below, opts1) + dec_below = decode(below, opts) self.assertEqual(dec_below["x"], datetime.datetime.min) above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 24 * 60 * 60)}) - dec_above = decode(above, opts1) + dec_above = decode(above, opts) self.assertEqual( dec_above["x"], datetime.datetime.max.replace(microsecond=999000), ) - # Aware clamping. - opts2 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True) + def test_tz_clamping_utc(self): + # Aware clamping default utc. + opts = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True) below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 24 * 60 * 60)}) - dec_below = decode(below, opts2) + dec_below = decode(below, opts) self.assertEqual( dec_below["x"], datetime.datetime.min.replace(tzinfo=datetime.timezone.utc) ) above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 24 * 60 * 60)}) - dec_above = decode(above, opts2) + dec_above = decode(above, opts) self.assertEqual( dec_above["x"], datetime.datetime.max.replace(tzinfo=datetime.timezone.utc, microsecond=999000), ) + def test_tz_clamping_non_utc(self): + # Aware clamping non-utc. + tz = FixedOffset(60, "Custom") + opts = CodecOptions( + datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True, tzinfo=tz + ) + # Min/max values in this timezone which can be represented in both BSON and datetime UTC. + min_tz = datetime.datetime.min.replace(tzinfo=utc).astimezone(tz) + max_tz = ( + (datetime.datetime.max - datetime.timedelta(minutes=60)) + .replace(tzinfo=utc) + .astimezone(tz) + .replace(microsecond=999000) + ) + for in_range in [ + min_tz, + min_tz + datetime.timedelta(milliseconds=1), + max_tz - datetime.timedelta(milliseconds=1), + max_tz, + ]: + doc = decode(encode({"x": in_range}), opts) + self.assertEqual(doc["x"], in_range) + + for too_low in [ + min_tz - datetime.timedelta(microseconds=1), + min_tz - datetime.timedelta(milliseconds=1), + min_tz - datetime.timedelta(hours=1), + DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 1), + DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 100000), + ]: + doc = decode(encode({"x": too_low}), opts) + self.assertEqual(doc["x"], min_tz) + + # 253402300799999 + for too_high in [ + max_tz + datetime.timedelta(microseconds=1), + max_tz + datetime.timedelta(microseconds=999), + datetime.datetime.max.replace(tzinfo=tz), + DatetimeMS(_datetime_to_millis(max_tz) + 1), # 253402297200000 + DatetimeMS(_datetime_to_millis(max_tz) + 1000), # 253402297200999 + ]: + doc = decode(encode({"x": too_high}), opts) + self.assertEqual(doc["x"], max_tz) + + def test_tz_clamping_non_hashable(self): + class NonHashableTZ(FixedOffset): + __hash__ = None + + tz = NonHashableTZ(0, "UTC-non-hashable") + self.assertRaises(TypeError, hash, tz) + # Aware clamping. + opts = CodecOptions( + datetime_conversion=DatetimeConversion.DATETIME_CLAMP, tz_aware=True, tzinfo=tz + ) + below = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.min) - 24 * 60 * 60)}) + dec_below = decode(below, opts) + self.assertEqual(dec_below["x"], datetime.datetime.min.replace(tzinfo=tz)) + + within = encode({"x": EPOCH_AWARE.astimezone(tz)}) + dec_within = decode(within, opts) + self.assertEqual(dec_within["x"], EPOCH_AWARE.astimezone(tz)) + + above = encode({"x": DatetimeMS(_datetime_to_millis(datetime.datetime.max) + 24 * 60 * 60)}) + dec_above = decode(above, opts) + self.assertEqual( + dec_above["x"], + datetime.datetime.max.replace(tzinfo=tz, microsecond=999000), + ) + def test_datetime_auto(self): # Naive auto, in range. opts1 = CodecOptions(datetime_conversion=DatetimeConversion.DATETIME_AUTO) diff --git a/test/test_json_util.py b/test/test_json_util.py index 0f73a8efd9..3568d1e678 100644 --- a/test/test_json_util.py +++ b/test/test_json_util.py @@ -39,7 +39,7 @@ UuidRepresentation, ) from bson.code import Code -from bson.datetime_ms import _max_datetime_ms +from bson.datetime_ms import _MAX_DATETIME_MS from bson.dbref import DBRef from bson.decimal128 import Decimal128 from bson.int64 import Int64 @@ -257,7 +257,7 @@ def test_datetime(self): def test_datetime_ms(self): # Test ISO8601 in-range dat_min: dict[str, Any] = {"x": DatetimeMS(0)} - dat_max: dict[str, Any] = {"x": DatetimeMS(_max_datetime_ms())} + dat_max: dict[str, Any] = {"x": DatetimeMS(_MAX_DATETIME_MS)} opts = JSONOptions(datetime_representation=DatetimeRepresentation.ISO8601) self.assertEqual( @@ -271,7 +271,7 @@ def test_datetime_ms(self): # Test ISO8601 out-of-range dat_min = {"x": DatetimeMS(-1)} - dat_max = {"x": DatetimeMS(_max_datetime_ms() + 1)} + dat_max = {"x": DatetimeMS(_MAX_DATETIME_MS + 1)} self.assertEqual('{"x": {"$date": {"$numberLong": "-1"}}}', json_util.dumps(dat_min)) self.assertEqual( @@ -302,7 +302,7 @@ def test_datetime_ms(self): # Test decode from datetime.datetime to DatetimeMS dat_min = {"x": datetime.datetime.min} - dat_max = {"x": DatetimeMS(_max_datetime_ms()).as_datetime(CodecOptions(tz_aware=False))} + dat_max = {"x": DatetimeMS(_MAX_DATETIME_MS).as_datetime(CodecOptions(tz_aware=False))} opts = JSONOptions( datetime_representation=DatetimeRepresentation.ISO8601, datetime_conversion=DatetimeConversion.DATETIME_MS, diff --git a/test/test_objectid.py b/test/test_objectid.py index 771ba09422..26670832f6 100644 --- a/test/test_objectid.py +++ b/test/test_objectid.py @@ -95,9 +95,6 @@ def test_generation_time(self): self.assertTrue(d2 - d1 < datetime.timedelta(seconds=2)) def test_from_datetime(self): - if "PyPy 1.8.0" in sys.version: - # See https://bugs.pypy.org/issue1092 - raise SkipTest("datetime.timedelta is broken in pypy 1.8.0") d = datetime.datetime.now(tz=datetime.timezone.utc).replace(tzinfo=None) d = d - datetime.timedelta(microseconds=d.microsecond) oid = ObjectId.from_datetime(d)