@@ -278,3 +278,186 @@ def test_interval_over_midnight_tz():
278278 new_end = start + interval
279279
280280 assert new_end == end
281+
282+
283+ @pytest .mark .parametrize ("method, sign" , [("add" , 1 ), ("subtract" , - 1 )])
284+ @pytest .mark .parametrize (
285+ "tz, start, calendar_units, fixed_units, expected" ,
286+ [
287+ pytest .param (
288+ "Europe/Sofia" ,
289+ (2024 , 4 , 1 , 4 ),
290+ {"days" : - 1 },
291+ {"seconds" : - 1 },
292+ "2024-03-31T02:59:59+02:00" ,
293+ id = "spring-forward-gap" ,
294+ ),
295+ pytest .param (
296+ "Europe/Paris" ,
297+ (2013 , 3 , 24 , 2 , 30 ),
298+ {"weeks" : 1 },
299+ {"hours" : 1 },
300+ "2013-03-31T04:30:00+02:00" ,
301+ id = "calendar-gap-normalization" ,
302+ ),
303+ pytest .param (
304+ "America/New_York" ,
305+ (2024 , 2 , 10 , 1 , 30 ),
306+ {"months" : 1 },
307+ {"hours" : 25 },
308+ "2024-03-11T03:30:00-04:00" ,
309+ id = "fixed-hours-over-one-day" ,
310+ ),
311+ pytest .param (
312+ "Europe/Paris" ,
313+ (2012 , 10 , 27 , 1 , 59 , 59 , 999999 ),
314+ {"years" : 1 },
315+ {"hours" : 1 , "microseconds" : 1 },
316+ "2013-10-27T02:00:00+01:00" ,
317+ id = "fall-back-forward-shift" ,
318+ ),
319+ pytest .param (
320+ "Europe/Paris" ,
321+ (2013 , 11 , 27 , 2 ),
322+ {"months" : - 1 },
323+ {"microseconds" : - 1 },
324+ "2013-10-27T02:59:59.999999+02:00" ,
325+ id = "fall-back-backward-shift" ,
326+ ),
327+ pytest .param (
328+ "Europe/Sofia" ,
329+ (2024 , 10 , 26 , 4 , 30 ),
330+ {"days" : 1 },
331+ {"hours" : - 1 },
332+ "2024-10-27T03:30:00+02:00" ,
333+ id = "fall-back-calendar-then-elapsed" ,
334+ ),
335+ pytest .param (
336+ "Europe/Sofia" ,
337+ (2024 , 10 , 20 , 3 , 30 ),
338+ {"weeks" : 1 },
339+ {"minutes" : - 60 },
340+ "2024-10-27T03:30:00+03:00" ,
341+ id = "ambiguous-time-fold" ,
342+ ),
343+ pytest .param (
344+ "Europe/Sofia" ,
345+ (2024 , 3 , 30 , 4 ),
346+ {"days" : 1 },
347+ {"seconds" : - 0.5 },
348+ "2024-03-31T02:59:59.500000+02:00" ,
349+ id = "opposite-directions-fractional-seconds" ,
350+ ),
351+ pytest .param (
352+ "America/New_York" ,
353+ (2025 , 11 , 3 , 0 , 30 ),
354+ {"years" : - 1 },
355+ {"hours" : 2 },
356+ "2024-11-03T01:30:00-05:00" ,
357+ id = "negative-calendar-positive-elapsed" ,
358+ ),
359+ pytest .param (
360+ "Australia/Lord_Howe" ,
361+ (2024 , 10 , 7 , 2 , 30 ),
362+ {"days" : - 1 },
363+ {"microseconds" : - 1 },
364+ "2024-10-06T01:59:59.999999+10:30" ,
365+ id = "half-hour-spring-forward" ,
366+ ),
367+ pytest .param (
368+ "Australia/Lord_Howe" ,
369+ (2024 , 4 , 14 , 1 ),
370+ {"weeks" : - 1 },
371+ {"hours" : 1 },
372+ "2024-04-07T01:30:00+10:30" ,
373+ id = "half-hour-fall-back" ,
374+ ),
375+ pytest .param (
376+ "Europe/Paris" ,
377+ (2024 , 2 , 29 , 1 , 30 ),
378+ {"years" : 1 , "months" : 1 , "days" : 1 },
379+ {"hours" : 2 },
380+ "2025-03-30T04:30:00+02:00" ,
381+ id = "multiple-calendar-units" ,
382+ ),
383+ pytest .param (
384+ "Europe/Sofia" ,
385+ (2024 , 4 , 1 , 16 ),
386+ {"days" : - 1.5 },
387+ {"seconds" : - 1 },
388+ "2024-03-31T02:59:59+02:00" ,
389+ id = "fractional-calendar-days" ,
390+ ),
391+ pytest .param (
392+ "UTC" ,
393+ (2024 , 4 , 1 , 4 ),
394+ {"days" : - 1 },
395+ {"seconds" : - 1 },
396+ "2024-03-31T03:59:59+00:00" ,
397+ id = "utc-control" ,
398+ ),
399+ pytest .param (
400+ None ,
401+ (2024 , 4 , 1 , 4 ),
402+ {"days" : - 1 },
403+ {"seconds" : - 1 },
404+ "2024-03-31T03:59:59" ,
405+ id = "naive-control" ,
406+ ),
407+ pytest .param (
408+ 5.5 ,
409+ (2024 , 1 , 31 , 4 ),
410+ {"months" : 1 },
411+ {"seconds" : - 0.25 , "microseconds" : - 1000001 },
412+ "2024-02-29T03:59:58.749999+05:30" ,
413+ id = "fixed-offset-month-clamping" ,
414+ ),
415+ ],
416+ )
417+ def test_mixed_arithmetic_applies_calendar_before_elapsed_time (
418+ method : str ,
419+ sign : int ,
420+ tz : str | float | None ,
421+ start : tuple [int , ...],
422+ calendar_units : dict [str , int | float ],
423+ fixed_units : dict [str , int | float ],
424+ expected : str ,
425+ ) -> None :
426+ dt = pendulum .DateTime .create (* start , tz = tz )
427+ calendar_args = {unit : sign * value for unit , value in calendar_units .items ()}
428+ fixed_args = {unit : sign * value for unit , value in fixed_units .items ()}
429+
430+ result = getattr (dt , method )(** calendar_args , ** fixed_args )
431+
432+ assert result .isoformat () == expected
433+ assert result .tzinfo is dt .tzinfo
434+
435+
436+ @pytest .mark .parametrize (
437+ "tz, expected" ,
438+ [
439+ ("Europe/Sofia" , "2024-03-31T02:59:59.500000+02:00" ),
440+ (None , "2024-03-31T03:59:59.500000" ),
441+ ],
442+ )
443+ def test_mixed_arithmetic_preserves_subclass (tz : str | None , expected : str ) -> None :
444+ class Subclass (pendulum .DateTime ):
445+ pass
446+
447+ dt = Subclass .create (2024 , 4 , 1 , 4 , tz = tz )
448+ result = dt .subtract (days = 1 , seconds = 0.5 )
449+
450+ assert type (result ) is Subclass
451+ assert result .tzinfo is dt .tzinfo
452+ assert result .isoformat () == expected
453+
454+
455+ def test_naive_mixed_arithmetic_rounds_fractional_units_once () -> None :
456+ dt = pendulum .naive (2024 , 4 , 1 , 4 )
457+ days = 0.6 / 86_400_000_000
458+ seconds = 0.0000006
459+
460+ result = dt .add (days = days , seconds = seconds ) # type: ignore[arg-type]
461+
462+ assert result .isoformat () == "2024-04-01T04:00:00.000001"
463+ assert result .tzinfo is None
0 commit comments