From dc922c276cc9d4315d6f8105c75c497bfc5fa04b Mon Sep 17 00:00:00 2001 From: youdie006 Date: Tue, 15 Sep 2026 01:00:29 +0900 Subject: [PATCH 1/2] Deprecate the fraction of an hour in a zone offset Date._parse and friends honour a fractional hour, so ignoring it later will change what they return; they warn whenever the fraction is not zero. DateTime.civil goes through offset_to_sec, which already drops a zone whose fraction makes the offset non-integral. It warns only where the offset it returns today differs from the one the whole hour alone would give, so '+00.123' stays quiet while '+01.123' does not. date_zone_to_diff keeps its signature and warns; offset_to_sec uses a new date_zone_to_diff_frac that reports the fraction instead. --- ext/date/date_core.c | 23 ++++++++++++++------- ext/date/date_parse.c | 20 +++++++++++++++++- test/date/test_date_new.rb | 40 ++++++++++++++++++++++++++++++++++++ test/date/test_date_parse.rb | 20 ++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/ext/date/date_core.c b/ext/date/date_core.c index 0d69633..cd3567a 100644 --- a/ext/date/date_core.c +++ b/ext/date/date_core.c @@ -2562,6 +2562,7 @@ valid_nth_kday_p(VALUE y, int m, int n, int k, double sg, #endif VALUE date_zone_to_diff(VALUE); +VALUE date_zone_to_diff_frac(VALUE, int *, long *); static int offset_to_sec(VALUE vof, int *rof) @@ -2633,13 +2634,21 @@ offset_to_sec(VALUE vof, int *rof) } case T_STRING: { - VALUE vs = date_zone_to_diff(vof); - long n; - - if (!FIXNUM_P(vs)) - return 0; - n = FIX2LONG(vs); - if (n < -DAY_IN_SECONDS || n > DAY_IN_SECONDS) + int frac, ok = 0; + long whole, n = 0; + VALUE vs = date_zone_to_diff_frac(vof, &frac, &whole); + + if (FIXNUM_P(vs)) { + n = FIX2LONG(vs); + if (n >= -DAY_IN_SECONDS && n <= DAY_IN_SECONDS) + ok = 1; + } + /* warn only where dropping the fraction would give an offset + * different from the one this returns today */ + if (frac && (ok ? n : 0) != whole) + rb_warning("fraction of hour in a zone offset is deprecated" + " and will be ignored"); + if (!ok) return 0; *rof = (int)n; return 1; diff --git a/ext/date/date_parse.c b/ext/date/date_parse.c index 37748a1..0a468a8 100644 --- a/ext/date/date_parse.c +++ b/ext/date/date_parse.c @@ -413,12 +413,15 @@ shrink_space(char *d, const char *s, long l) } VALUE -date_zone_to_diff(VALUE str) +date_zone_to_diff_frac(VALUE str, int *fracp, long *whole) { VALUE offset = Qnil; long l = RSTRING_LEN(str); const char *s = RSTRING_PTR(str); + *fracp = 0; + *whole = 0; + { int dst = 0; int w; @@ -533,6 +536,8 @@ date_zone_to_diff(VALUE str) offset = rb_rational_num(offset); } } + *fracp = sec != 0; + *whole = hour * 3600; goto ok; } else if (l > 2) { @@ -558,6 +563,19 @@ date_zone_to_diff(VALUE str) return offset; } +VALUE +date_zone_to_diff(VALUE str) +{ + int frac; + long whole; + VALUE offset = date_zone_to_diff_frac(str, &frac, &whole); + + if (frac) + rb_warning("fraction of hour in a zone offset is deprecated" + " and will be ignored"); + return offset; +} + static int day_num(VALUE s) { diff --git a/test/date/test_date_new.rb b/test/date/test_date_new.rb index b839c52..0081052 100644 --- a/test/date/test_date_new.rb +++ b/test/date/test_date_new.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test/unit' require 'date' +require 'stringio' class TestDateNew < Test::Unit::TestCase @@ -213,6 +214,45 @@ def test_civil__offset assert_equal(0, d.offset) end + def test_civil__fractional_zone_offset_deprecated + # A fraction that makes the offset non-integral is already dropped whole, + # so ignoring the fraction will not change these; only the pre-existing + # "invalid offset" warning may appear. + ['+00.123', '-00.123'].each do |z| + out = capture_zone_warning { assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, z).offset) } + assert_match(/invalid offset is ignored/, out) + refute_match(/fraction of hour/, out) + end + + # Here the fraction reaches the result, or dropping it would give an + # offset different from the 0 returned today. + out = capture_zone_warning do + assert_equal(Rational(1800, 86400), DateTime.civil(2001,2,3, 0,0,0, '+00.5').offset) + end + assert_match(/fraction of hour/, out) + + out = capture_zone_warning do + assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, '+01.123').offset) + end + assert_match(/fraction of hour/, out) + + # A zero fraction and the colon forms change nothing. + ['+01.0', '+00:30'].each do |z| + out = capture_zone_warning { DateTime.civil(2001,2,3, 0,0,0, z) } + refute_match(/fraction of hour/, out) + end + end + + def capture_zone_warning + verbose, $VERBOSE = $VERBOSE, true + err, $stderr = $stderr, StringIO.new + yield + $stderr.string + ensure + $stderr = err + $VERBOSE = verbose + end + def test_civil__reform d = Date.jd(Date::ENGLAND, Date::ENGLAND) dt = DateTime.jd(Date::ENGLAND, 0,0,0,0, Date::ENGLAND) diff --git a/test/date/test_date_parse.rb b/test/date/test_date_parse.rb index 8308f25..4a73cc8 100644 --- a/test/date/test_date_parse.rb +++ b/test/date/test_date_parse.rb @@ -5,6 +5,26 @@ class TestDateParse < Test::Unit::TestCase + def test__parse_fractional_zone_offset_deprecated + # The fractional hour is honoured today and will be ignored; warn only + # where dropping it changes the answer this returns now. + assert_warning(/fraction of hour/) do + assert_equal(Rational(2214, 5), Date._parse('2001-02-03T00:00:00+00.123')[:offset]) + end + assert_warning(/fraction of hour/) do + assert_equal(1800, Date._parse('2001-02-03T00:00:00+00.5')[:offset]) + end + # A zero fraction changes nothing, so it must stay quiet. + assert_warning('') do + assert_equal(3600, Date._parse('2001-02-03T00:00:00+01.0')[:offset]) + end + # The colon forms have no fractional hour at all. + assert_warning('') do + assert_equal(1800, Date._parse('2001-02-03T00:00:00+00:30')[:offset]) + assert_equal(86399, Date._parse('2001-02-03T00:00:00+23:59:59')[:offset]) + end + end + def test__parse [ # ctime(3), asctime(3) From 37182a39392615b80499981127d672c59aacf8b5 Mon Sep 17 00:00:00 2001 From: youdie006 Date: Sun, 18 Oct 2026 23:44:16 +0900 Subject: [PATCH 2/2] Use the deprecated warning category for fractional hour offsets Address review: warn through rb_category_warn(RB_WARN_CATEGORY_DEPRECATED), say the fraction will be ignored in a future version, and warn from the parse methods for a zero fraction too, since :zone will change. Tests use assert_warning, cover Date._strptime, and the existing fractional-offset rows of the parse and strptime tables move into the deprecation tests. --- ext/date/date_core.c | 12 ++--- ext/date/date_parse.c | 10 ++-- test/date/test_date_new.rb | 59 ++++++++++------------ test/date/test_date_parse.rb | 88 +++++++++++++++++++++++++-------- test/date/test_date_strptime.rb | 3 -- 5 files changed, 107 insertions(+), 65 deletions(-) diff --git a/ext/date/date_core.c b/ext/date/date_core.c index cd3567a..7a9e01f 100644 --- a/ext/date/date_core.c +++ b/ext/date/date_core.c @@ -2561,6 +2561,10 @@ valid_nth_kday_p(VALUE y, int m, int n, int k, double sg, } #endif +#ifndef HAVE_RB_CATEGORY_WARN +#define rb_category_warn(category, fmt) rb_warn(fmt) +#endif + VALUE date_zone_to_diff(VALUE); VALUE date_zone_to_diff_frac(VALUE, int *, long *); @@ -2646,8 +2650,8 @@ offset_to_sec(VALUE vof, int *rof) /* warn only where dropping the fraction would give an offset * different from the one this returns today */ if (frac && (ok ? n : 0) != whole) - rb_warning("fraction of hour in a zone offset is deprecated" - " and will be ignored"); + rb_category_warn(RB_WARN_CATEGORY_DEPRECATED, "fraction of hour in a zone offset is deprecated" + " and will be ignored in a future version"); if (!ok) return 0; *rof = (int)n; @@ -4687,10 +4691,6 @@ get_limit(VALUE opt) return 128; } -#ifndef HAVE_RB_CATEGORY_WARN -#define rb_category_warn(category, fmt) rb_warn(fmt) -#endif - static VALUE check_limit(VALUE str, VALUE opt) { diff --git a/ext/date/date_parse.c b/ext/date/date_parse.c index 0a468a8..d7433d4 100644 --- a/ext/date/date_parse.c +++ b/ext/date/date_parse.c @@ -7,6 +7,10 @@ #include "ruby/re.h" #include +#ifndef HAVE_RB_CATEGORY_WARN +#define rb_category_warn(category, fmt) rb_warn(fmt) +#endif + #undef strncasecmp #define strncasecmp STRNCASECMP @@ -536,7 +540,7 @@ date_zone_to_diff_frac(VALUE str, int *fracp, long *whole) offset = rb_rational_num(offset); } } - *fracp = sec != 0; + *fracp = 1; *whole = hour * 3600; goto ok; } @@ -571,8 +575,8 @@ date_zone_to_diff(VALUE str) VALUE offset = date_zone_to_diff_frac(str, &frac, &whole); if (frac) - rb_warning("fraction of hour in a zone offset is deprecated" - " and will be ignored"); + rb_category_warn(RB_WARN_CATEGORY_DEPRECATED, "fraction of hour in a zone offset is deprecated" + " and will be ignored in a future version"); return offset; } diff --git a/test/date/test_date_new.rb b/test/date/test_date_new.rb index 0081052..4a0f9f1 100644 --- a/test/date/test_date_new.rb +++ b/test/date/test_date_new.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'test/unit' require 'date' -require 'stringio' class TestDateNew < Test::Unit::TestCase @@ -215,42 +214,38 @@ def test_civil__offset end def test_civil__fractional_zone_offset_deprecated - # A fraction that makes the offset non-integral is already dropped whole, - # so ignoring the fraction will not change these; only the pre-existing - # "invalid offset" warning may appear. - ['+00.123', '-00.123'].each do |z| - out = capture_zone_warning { assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, z).offset) } - assert_match(/invalid offset is ignored/, out) - refute_match(/fraction of hour/, out) - end - - # Here the fraction reaches the result, or dropping it would give an - # offset different from the 0 returned today. - out = capture_zone_warning do - assert_equal(Rational(1800, 86400), DateTime.civil(2001,2,3, 0,0,0, '+00.5').offset) - end - assert_match(/fraction of hour/, out) - - out = capture_zone_warning do - assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, '+01.123').offset) - end - assert_match(/fraction of hour/, out) - - # A zero fraction and the colon forms change nothing. - ['+01.0', '+00:30'].each do |z| - out = capture_zone_warning { DateTime.civil(2001,2,3, 0,0,0, z) } - refute_match(/fraction of hour/, out) + with_deprecation_warnings do + # A fraction that makes the offset non-integral is already dropped whole, + # so ignoring the fraction will not change these. + ['+00.123', '-00.123'].each do |z| + assert_warning(/\A(?!.*fraction of hour).*invalid offset is ignored/m) do + assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, z).offset) + end + end + + # Here the fraction reaches the result, or dropping it would give an + # offset different from the 0 returned today. + assert_warning(/fraction of hour/) do + assert_equal(Rational(1800, 86400), DateTime.civil(2001,2,3, 0,0,0, '+00.5').offset) + end + assert_warning(/fraction of hour/) do + assert_equal(0, DateTime.civil(2001,2,3, 0,0,0, '+01.123').offset) + end + + # A zero fraction and the colon forms change nothing. + assert_warning('') do + assert_equal(Rational(3600, 86400), DateTime.civil(2001,2,3, 0,0,0, '+01.0').offset) + assert_equal(Rational(1800, 86400), DateTime.civil(2001,2,3, 0,0,0, '+00:30').offset) + end end end - def capture_zone_warning - verbose, $VERBOSE = $VERBOSE, true - err, $stderr = $stderr, StringIO.new + def with_deprecation_warnings + deprecated = Warning[:deprecated] if Warning.respond_to?(:[]) + Warning[:deprecated] = true if Warning.respond_to?(:[]=) yield - $stderr.string ensure - $stderr = err - $VERBOSE = verbose + Warning[:deprecated] = deprecated if Warning.respond_to?(:[]=) end def test_civil__reform diff --git a/test/date/test_date_parse.rb b/test/date/test_date_parse.rb index 4a73cc8..8994fa9 100644 --- a/test/date/test_date_parse.rb +++ b/test/date/test_date_parse.rb @@ -6,25 +6,77 @@ class TestDateParse < Test::Unit::TestCase def test__parse_fractional_zone_offset_deprecated - # The fractional hour is honoured today and will be ignored; warn only - # where dropping it changes the answer this returns now. - assert_warning(/fraction of hour/) do - assert_equal(Rational(2214, 5), Date._parse('2001-02-03T00:00:00+00.123')[:offset]) - end - assert_warning(/fraction of hour/) do - assert_equal(1800, Date._parse('2001-02-03T00:00:00+00.5')[:offset]) - end - # A zero fraction changes nothing, so it must stay quiet. - assert_warning('') do - assert_equal(3600, Date._parse('2001-02-03T00:00:00+01.0')[:offset]) + with_deprecation_warnings do + # The fractional hour is honoured today and will be ignored, which + # changes :offset, or at least :zone for a zero fraction. + assert_warning(/fraction of hour/) do + assert_equal(Rational(2214, 5), Date._parse('2001-02-03T00:00:00+00.123')[:offset]) + end + assert_warning(/fraction of hour/) do + assert_equal(1800, Date._parse('2001-02-03T00:00:00+00,5')[:offset]) + end + assert_warning(/fraction of hour/) do + h = Date._parse('2001-02-03T00:00:00+01.0') + assert_equal(['+01.0', 3600], h.values_at(:zone, :offset)) + end + [ + ['19990523235521.123[-9.50]', [1999,5,23,23,55,21,'-9.50',-(9*3600+30*60)]], + ['19990523235521.123[+9.50]', [1999,5,23,23,55,21,'+9.50',+(9*3600+30*60)]], + ['2000-01-31 13:20:00-5.5', [2000,1,31,13,20,0,'-5.5',-5*3600-30*60]], + ['2000-01-31 13:20:00-5,5', [2000,1,31,13,20,0,'-5,5',-5*3600-30*60]], + ['2000-01-31 13:20:00+3.5', [2000,1,31,13,20,0,'+3.5',3*3600+30*60]], + ['2000-01-31 13:20:00+3,5', [2000,1,31,13,20,0,'+3,5',3*3600+30*60]], + ].each do |str, expected| + assert_warning(/fraction of hour/) do + h = Date._parse(str) + assert_equal(expected, h.values_at(:year,:mon,:mday,:hour,:min,:sec,:zone,:offset), str) + end + end + # The colon forms have no fractional hour at all. + assert_warning('') do + assert_equal(1800, Date._parse('2001-02-03T00:00:00+00:30')[:offset]) + assert_equal(86399, Date._parse('2001-02-03T00:00:00+23:59:59')[:offset]) + end end - # The colon forms have no fractional hour at all. - assert_warning('') do - assert_equal(1800, Date._parse('2001-02-03T00:00:00+00:30')[:offset]) - assert_equal(86399, Date._parse('2001-02-03T00:00:00+23:59:59')[:offset]) + end + + def test__strptime_fractional_zone_offset_deprecated + with_deprecation_warnings do + assert_warning(/fraction of hour/) do + assert_equal(1800, Date._strptime('+00.5', '%z')[:offset]) + end + assert_warning(/fraction of hour/) do + assert_equal(Rational(2214, 5), Date._strptime('+00,123', '%z')[:offset]) + end + assert_warning(/fraction of hour/) do + assert_equal(['+01.0', 3600], Date._strptime('+01.0', '%z').values_at(:zone, :offset)) + end + assert_warning(/fraction of hour/) do + h = Date._strptime('Thu Jul 29 16:39:41 GMT-3.5 1999', '%a %b %d %H:%M:%S %Z %Y') + assert_equal(['GMT-3.5', -3*3600-30*60], h.values_at(:zone, :offset)) + end + assert_warning(/fraction of hour/) do + h = Date._strptime('Thu Jul 29 16:39:41 GMT-3,5 1999', '%a %b %d %H:%M:%S %Z %Y') + assert_equal(['GMT-3,5', -3*3600-30*60], h.values_at(:zone, :offset)) + end + assert_warning(/fraction of hour/) do + assert_equal(3600, Date._strptime('+0.9999999999999999999999', '%Z')[:offset]) + end + assert_warning('') do + assert_equal(1800, Date._strptime('+00:30', '%z')[:offset]) + assert_equal(-3600, Date._strptime('-0100', '%z')[:offset]) + end end end + def with_deprecation_warnings + deprecated = Warning[:deprecated] if Warning.respond_to?(:[]) + Warning[:deprecated] = true if Warning.respond_to?(:[]=) + yield + ensure + Warning[:deprecated] = deprecated if Warning.respond_to?(:[]=) + end + def test__parse [ # ctime(3), asctime(3) @@ -154,8 +206,6 @@ def test__parse [['19990523235521.123[+9]',false],[1999,5,23,23,55,21,'+9',+(9*3600),nil], __LINE__], [['19990523235521.123[9]',false],[1999,5,23,23,55,21,'9',+(9*3600),nil], __LINE__], [['19990523235521.123[9 ]',false],[1999,5,23,23,55,21,'9 ',+(9*3600),nil], __LINE__], - [['19990523235521.123[-9.50]',false],[1999,5,23,23,55,21,'-9.50',-(9*3600+30*60),nil], __LINE__], - [['19990523235521.123[+9.50]',false],[1999,5,23,23,55,21,'+9.50',+(9*3600+30*60),nil], __LINE__], [['19990523235521.123[-5:EST]',false],[1999,5,23,23,55,21,'EST',-5*3600,nil], __LINE__], [['19990523235521.123[+9:JST]',false],[1999,5,23,23,55,21,'JST',9*3600,nil], __LINE__], [['19990523235521.123[+12:XXX YYY ZZZ]',false],[1999,5,23,23,55,21,'XXX YYY ZZZ',12*3600,nil], __LINE__], @@ -186,10 +236,6 @@ def test__parse [['2000-01-31 13:20:00-5',false],[2000,1,31,13,20,0,'-5',-5*3600,nil], __LINE__], # [-+]\d+.\d+ - [['2000-01-31 13:20:00-5.5',false],[2000,1,31,13,20,0,'-5.5',-5*3600-30*60,nil], __LINE__], - [['2000-01-31 13:20:00-5,5',false],[2000,1,31,13,20,0,'-5,5',-5*3600-30*60,nil], __LINE__], - [['2000-01-31 13:20:00+3.5',false],[2000,1,31,13,20,0,'+3.5',3*3600+30*60,nil], __LINE__], - [['2000-01-31 13:20:00+3,5',false],[2000,1,31,13,20,0,'+3,5',3*3600+30*60,nil], __LINE__], # mil [['2000-01-31 13:20:00 Z',false],[2000,1,31,13,20,0,'Z',0*3600,nil], __LINE__], diff --git a/test/date/test_date_strptime.rb b/test/date/test_date_strptime.rb index f68f749..688a91e 100644 --- a/test/date/test_date_strptime.rb +++ b/test/date/test_date_strptime.rb @@ -142,8 +142,6 @@ def test__strptime__3 [['Thu Jul 29 16:39:41 GMT-09 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'GMT-09',-9*3600,4], __LINE__], [['Thu Jul 29 16:39:41 GMT-09:08 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'GMT-09:08',-9*3600-8*60,4], __LINE__], [['Thu Jul 29 16:39:41 GMT-09:08:07 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'GMT-09:08:07',-9*3600-8*60-7,4], __LINE__], - [['Thu Jul 29 16:39:41 GMT-3.5 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'GMT-3.5',-3*3600-30*60,4], __LINE__], - [['Thu Jul 29 16:39:41 GMT-3,5 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'GMT-3,5',-3*3600-30*60,4], __LINE__], [['Thu Jul 29 16:39:41 Mountain Daylight Time 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'Mountain Daylight Time',-6*3600,4], __LINE__], [['Thu Jul 29 16:39:41 E. Australia Standard Time 1999', '%a %b %d %H:%M:%S %Z %Y'], [1999,7,29,16,39,41,'E. Australia Standard Time',10*3600,4], __LINE__], @@ -182,7 +180,6 @@ def test__strptime__3 [['E. Australia Standard Time', '%Z'], [nil,nil,nil,nil,nil,nil,'E. Australia Standard Time',10*3600,nil], __LINE__], # out of range - [['+0.9999999999999999999999', '%Z'], [nil,nil,nil,nil,nil,nil,'+0.9999999999999999999999',+1*3600,nil], __LINE__], [['+9999999999999999999999.0', '%Z'], [nil,nil,nil,nil,nil,nil,'+9999999999999999999999.0',nil,nil], __LINE__], ].each do |x, y| h = Date._strptime(*x)