From 56a4f3cee2bca338522438176ed46d32b260ce76 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 1 Oct 2026 11:13:13 -0400 Subject: [PATCH 1/4] Return NA for malformed period strings instead of a silent value The period parser accepted several malformed inputs and returned a value nobody asked for: ".h" gave 0, "PTM" gave 1 minute, "P1D1D" gave 2 days, "T1H" gave 1 hour, "P1DT" gave 1 day, and in "1h (2h (3h) 4h) 5h" the first ")" ended the skipped group so the result was 10 hours. These now return NA, as "1X" already does: - a "." with no digit on either side is not a number; - after an ISO "P", a single-letter designator needs a number and may occur only once; - "T" needs a "P" or a component before it, a component after it, and occurs once; - "(" cannot open a second group inside a skipped "(...)". The skip also stops at the end of the string. Unit names without a number ("day day"), repeated shorthand units, "T" after a component without "P" ("10DT10M", which the docs use), and mixed ISO and shorthand strings parse as before. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 7 +++++ src/period.c | 54 +++++++++++++++++++++++++++++++---- src/utils.h | 2 ++ tests/testthat/test-periods.R | 51 +++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index bded76bd..0d1bf747 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,13 @@ Version 1.9.5.9999 (dev) * Fix `month<-` assignment by month name. "April" and "May" were missing from the lookup table, which produced `NA` for those names and incorrect month numbers for June through December. +* Behaviour change: the period and duration string parser now returns `NA` + for malformed input that used to give a silent value: a lone `.` before a + unit (`".h"` was 0), an ISO 8601 designator without a number (`"PTM"` was 1 + minute) or used twice (`"P1D1D"` was 2 days), a `T` with nothing before it + (`"T1H"`) or after it (`"P1DT"`), and nested parentheses + (`"1h (2h (3h) 4h) 5h"` was 10 hours). Lubridate shorthand such as + `"day day"` and `"10DT10M"` is unaffected. Version 1.9.5 diff --git a/src/period.c b/src/period.c index 9700797f..e3c71911 100644 --- a/src/period.c +++ b/src/period.c @@ -45,6 +45,13 @@ static const char *PERIOD_UNITS[] = {"seconds", "minutes", "hours", "days", "weeks", "months", "years"}; #define N_PERIOD_UNITS 7 +// Single-letter ISO 8601 designators, as indices into EN_UNITS: S, M, H, D, W, +// M, Y and the ambiguous M (16). +static inline int is_designator(int i) { + return i == 0 || i == 3 || i == 6 || i == 8 || i == 10 || i == 12 || + i == 14 || i == 16; +} + fractionUnit parse_period_unit(const char **c) { // assumes we are at the beg of a alpha-numeric input // units: invalid=-1, S=0, M=1, H=2, d=3, w=4, m=5, y=6 @@ -52,14 +59,22 @@ fractionUnit parse_period_unit(const char **c) { fractionUnit out; out.unit = -1; + out.has_num = 0; + out.designator = 0; if (**c) { out.val = parse_int(c, 100, FALSE); + out.has_num = out.val != -1; if (**c == '.') { (*c)++; // allow fractions without leading 0 if (out.val == -1) out.val = 0; + const char *frac = *c; out.fraction = parse_fractional(c); + // "1." and ".5" are numbers, a lone "." (as in ".h") is not + if (*c == frac && !out.has_num) + return out; + out.has_num = 1; } else { out.fraction = 0.0; } @@ -70,6 +85,7 @@ fractionUnit parse_period_unit(const char **c) { if (out.unit < 0 || out.unit > 16) { return out; } else { + out.designator = is_designator(out.unit); // if only unit name supplied, default to 1 units if (out.val == -1) out.val = 1; @@ -87,7 +103,10 @@ fractionUnit parse_period_unit(const char **c) { } void parse_period_1 (const char **c, double ret[N_PERIOD_UNITS]){ - int P = 0; // ISO period flag + int P = 0; // in the ISO date part, where M is months + int iso = 0; // an ISO 'P' has been seen + int T = 0; // an ISO 'T' still awaits its first component + int seen = 0; // bit per unit: ISO designators used so far int parsed1 = 0; while (**c) { fractionUnit fu = parse_period_unit(c); @@ -95,13 +114,31 @@ void parse_period_1 (const char **c, double ret[N_PERIOD_UNITS]){ if (fu.unit >= 0) { if (fu.unit == 17) { // ISO P P = 1; + iso = 1; } else if (fu.unit == 18) { // ISO T + // T follows a P or a component ("PT1H", "10DT10M"), never another T, + // and must be followed by a component + if (T || (!iso && !parsed1)) { + ret[0] = NA_REAL; + return; + } P = 0; + T = 1; } else { if (fu.unit == 16) { // month or minute fu.unit = P ? 5 : 1; } + if (iso && fu.designator) { + // an ISO designator needs a number and may occur only once + int bit = 1 << fu.unit; + if (!fu.has_num || (seen & bit)) { + ret[0] = NA_REAL; + return; + } + seen |= bit; + } parsed1 = 1; + T = 0; ret[fu.unit] += fu.val; if (fu.fraction > 0) { if (fu.unit == 0) ret[fu.unit] += fu.fraction; @@ -116,17 +153,24 @@ void parse_period_1 (const char **c, double ret[N_PERIOD_UNITS]){ while (**c && !(ALPHA(**c) || DIGIT(**c) || **c == '.')) { /* Rprintf("c=%c\n", **c); */ if (**c == '(') { - // skip till closing ')' to allow for as.duration round-trip #1005 - while (**c && **c != ')') - (*c)++; + // skip till closing ')' to allow for as.duration round-trip #1005; + // nothing nests there, so a second '(' is malformed (*c)++; + while (**c && **c != ')') { + if (**c == '(') { + ret[0] = NA_REAL; + return; + } + (*c)++; + } + if (**c) (*c)++; // step over ')' but never past the terminator } else { (*c)++; } } } - if (!parsed1) { + if (!parsed1 || T) { ret[0] = NA_REAL; } } diff --git a/src/utils.h b/src/utils.h index fcdbf044..b584127e 100644 --- a/src/utils.h +++ b/src/utils.h @@ -32,6 +32,8 @@ typedef struct { int val; double fraction; int unit; + int has_num; // a number was written before the unit + int designator; // the unit was a single-letter ISO 8601 designator } fractionUnit; // leap year every 400 years; no leap every 100 years diff --git a/tests/testthat/test-periods.R b/tests/testthat/test-periods.R index 209c3e3d..b14e3423 100644 --- a/tests/testthat/test-periods.R +++ b/tests/testthat/test-periods.R @@ -107,6 +107,57 @@ test_that("ISO ISO 8601 period parsing works", { ) }) +test_that("malformed period strings are NA instead of a silent value", { + na <- function(x) is.na(period(x)@.Data) && is.na(as.numeric(as.duration(x))) + # a "." with no digits on either side is not a number (was 0) + expect_true(na(".h")) + expect_true(na("1d .h")) + # an ISO designator needs a number (was 1 minute) + expect_true(na("PTM")) + expect_true(na("PD")) + expect_true(na("P1DTH")) + # an ISO designator may occur only once (was 2 days) + expect_true(na("P1D1D")) + expect_true(na("PT1H2H")) + expect_true(na("P1DT1M1M")) + # "T" needs a "P" or a component before it (was 1 hour) ... + expect_true(na("T1H")) + # ... at least one component after it (was 1 day), and occurs once + expect_true(na("P1DT")) + expect_true(na("10DT")) + expect_true(na("P1DTT1H")) + # "(...)" does not nest (first ")" used to end the skip: 10 hours) + expect_true(na("1h (2h (3h) 4h) 5h")) + expect_true(na("1h ((2h)) 3h")) +}) + +test_that("well-formed and documented lenient period strings still parse", { + expect_equal(period("1.h"), period(hours = 1)) + expect_equal(period(".5h"), period(seconds = 1800)) + expect_equal(period("0.5h"), period(seconds = 1800)) + # unit names without a number default to 1 outside ISO designators + expect_equal(period("h"), period(hours = 1)) + expect_equal(period("day day"), period(days = 2)) + expect_equal(period("P1D day"), period(days = 2)) + # repeated units add up in lubridate shorthand + expect_equal(period("1d 1d"), period(days = 2)) + # "T" without "P" after a component, as documented + expect_equal(period("10DT10M"), period(days = 10, minutes = 10)) + # ISO forms with M as both month and minute, and mixed ISO and shorthand + expect_equal( + period("P3Y6M4DT12H30M5S"), + period(years = 3, months = 6, days = 4, hours = 12, minutes = 30, seconds = 5) + ) + expect_equal( + period("P23DT60H20minutes 100 sec"), + period(days = 23, hours = 60, minutes = 20, seconds = 100) + ) + # format() output, with its "(...)" estimate, still round-trips + expect_equal(as.duration("1000s (~16.67 minutes)"), dseconds(1000)) + expect_equal(period("1d (2h) 3M"), period(days = 1, minutes = 3)) + expect_equal(period(" 1d ("), period(days = 1)) +}) + test_that("fractional parsing works as expected", { expect_equal( period("1.1min 2.3sec 2.3secs 1.0H 2.2M 1.5d"), From 9d9458647b92cd734c8486c351318ba32d14eb08 Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 1 Oct 2026 08:38:11 -0400 Subject: [PATCH 2/4] docs: regenerate documentation with roxygen2 8.1.0 Regenerated with devtools::document() on an unmodified tree. roxygen2 8.1.0 replaces RoxygenNote with Config/roxygen2/version, regroups the NAMESPACE imports and rewrites \linkS4class links. No function or documentation source changed, so this commit can be skipped in review. Co-Authored-By: Claude Opus 5.5 --- DESCRIPTION | 2 +- NAMESPACE | 66 +++++++++++++++++++++++----------------- man/Duration-class.Rd | 4 +-- man/Interval-class.Rd | 4 +-- man/Period-class.Rd | 6 ++-- man/Timespan-class.Rd | 2 +- man/as.duration.Rd | 4 +-- man/as.period.Rd | 6 ++-- man/date_utils.Rd | 5 --- man/duration.Rd | 2 +- man/hidden_aliases.Rd | 1 - man/interval.Rd | 8 ++--- man/lubridate-package.Rd | 13 ++++---- man/make_datetime.Rd | 2 +- man/minute.Rd | 2 +- man/origin.Rd | 4 --- man/parse_date_time.Rd | 4 +-- man/period.Rd | 4 +-- man/posix_utils.Rd | 5 --- man/quarter.Rd | 4 +-- man/reexports.Rd | 2 +- man/round_date.Rd | 4 +-- man/second.Rd | 2 +- man/time_length.Rd | 6 ++-- man/tz.Rd | 2 +- 25 files changed, 80 insertions(+), 84 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 38d5d9c4..77fabc92 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -51,7 +51,6 @@ Config/testthat/edition: 3 Encoding: UTF-8 LazyData: true Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 SystemRequirements: A system with zoneinfo data (e.g. /usr/share/zoneinfo). On Windows the zoneinfo included with R is used. Collate: @@ -105,3 +104,4 @@ Collate: 'update.r' 'vctrs.R' 'zzz.R' +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index 0eb16cc7..b1a7225f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -268,32 +268,42 @@ exportMethods(format_ISO8601) exportMethods(reclass_timespan) exportMethods(rep) exportMethods(show) -importFrom(generics,as.difftime) -importFrom(generics,intersect) -importFrom(generics,setdiff) -importFrom(generics,union) -importFrom(methods,"coerce<-") -importFrom(methods,"slot<-") -importFrom(methods,Arith) -importFrom(methods,Compare) -importFrom(methods,allNames) -importFrom(methods,callGeneric) -importFrom(methods,initialize) -importFrom(methods,is) -importFrom(methods,new) -importFrom(methods,setClass) -importFrom(methods,setGeneric) -importFrom(methods,show) -importFrom(methods,slot) -importFrom(methods,slotNames) -importFrom(methods,validObject) -importFrom(stats,na.omit) -importFrom(stats,setNames) -importFrom(stats,update) -importFrom(timechange,time_add) -importFrom(timechange,time_force_tz) -importFrom(timechange,time_get) -importFrom(timechange,time_update) -importFrom(utils,packageVersion) -importFrom(utils,read.delim) +importFrom(generics, + as.difftime, + intersect, + setdiff, + union +) +importFrom(methods, + "coerce<-", + "slot<-", + Arith, + Compare, + allNames, + callGeneric, + initialize, + is, + new, + setClass, + setGeneric, + show, + slot, + slotNames, + validObject +) +importFrom(stats, + na.omit, + setNames, + update +) +importFrom(timechange, + time_add, + time_force_tz, + time_get, + time_update +) +importFrom(utils, + packageVersion, + read.delim +) useDynLib(lubridate, .registration=TRUE) diff --git a/man/Duration-class.Rd b/man/Duration-class.Rd index 488d8a2b..cbe5ed53 100644 --- a/man/Duration-class.Rd +++ b/man/Duration-class.Rd @@ -6,7 +6,7 @@ \alias{durations} \title{Duration class} \description{ -Duration is an S4 class that extends the \linkS4class{Timespan} class. +Duration is an S4 class that extends the \link[=Timespan-class]{Timespan} class. Durations record the exact number of seconds in a time span. They measure the exact passage of time but do not always align with measurements made in larger units of time such as hours, months and years. @@ -18,7 +18,7 @@ and Daylight Savings Time. Durations provide a method for measuring generalized timespans when we wish to treat time as a mathematical quantity that increases in a uniform, monotone manner along a continuous number line. They allow exact comparisons with other durations. -See \linkS4class{Period} for an alternative way to measure timespans that better +See \link[=Period-class]{Period} for an alternative way to measure timespans that better preserves clock times. Durations class objects have one slot: .Data, a numeric object equal to the number diff --git a/man/Interval-class.Rd b/man/Interval-class.Rd index 4f11ec51..b14b30e2 100644 --- a/man/Interval-class.Rd +++ b/man/Interval-class.Rd @@ -6,11 +6,11 @@ \alias{intervals} \title{Interval class} \description{ -Interval is an S4 class that extends the \linkS4class{Timespan} class. An +Interval is an S4 class that extends the \link[=Timespan-class]{Timespan} class. An Interval object records one or more spans of time. Intervals record these timespans as a sequence of seconds that begin at a specified date. Since intervals are anchored to a precise moment of time, they can accurately be -converted to \linkS4class{Period} or \linkS4class{Duration} class objects. This +converted to \link[=Period-class]{Period} or \link[=Duration-class]{Duration} class objects. This is because we can observe the length in seconds of each period that begins on a specific date. Contrast this to a generalized period, which may not have a consistent length in seconds (e.g. the number of seconds in a year will change diff --git a/man/Period-class.Rd b/man/Period-class.Rd index 0a6b54f9..7f290084 100644 --- a/man/Period-class.Rd +++ b/man/Period-class.Rd @@ -5,7 +5,7 @@ \alias{Period-class} \title{Period class} \description{ -Period is an S4 class that extends the \linkS4class{Timespan} class. +Period is an S4 class that extends the \link[=Timespan-class]{Timespan} class. Periods track the change in the "clock time" between two date-times. They are measured in common time related units: years, months, days, hours, minutes, and seconds. Each unit except for seconds must be expressed in @@ -17,7 +17,7 @@ specific moment of time. This is because the precise length of one year, month, day, etc. can change depending on when it occurs due to daylight savings, leap years, and other conventions. A period can be associated with a specific moment in time by coercing it to an -\linkS4class{Interval} object with \code{\link[=as.interval]{as.interval()}} or by adding +\link[=Interval-class]{Interval} object with \code{\link[=as.interval]{as.interval()}} or by adding it to a date-time with "+". Periods provide a method for measuring generalized timespans when we wish to @@ -27,7 +27,7 @@ other events happen during the period. Because Period represents imprecise amount of time it cannot be compared to precise timestamps as Durations and Intervals are. You need to explicitly -convert to durations. See \linkS4class{Duration}. +convert to durations. See \link[=Duration-class]{Duration}. The logic that guides arithmetic with periods can be unintuitive. Starting with version 1.3.0, \pkg{lubridate} enforces the reversible property of arithmetic diff --git a/man/Timespan-class.Rd b/man/Timespan-class.Rd index ccb843fa..807fc561 100644 --- a/man/Timespan-class.Rd +++ b/man/Timespan-class.Rd @@ -15,7 +15,7 @@ \title{Timespan class} \description{ Timespan is an S4 class with no slots. It is extended by the -\linkS4class{Interval}, \linkS4class{Period}, and \linkS4class{Duration} +\link[=Interval-class]{Interval}, \link[=Period-class]{Period}, and \link[=Duration-class]{Duration} classes. } \keyword{internal} diff --git a/man/as.duration.Rd b/man/as.duration.Rd index 2d9f88c1..95bb5507 100644 --- a/man/as.duration.Rd +++ b/man/as.duration.Rd @@ -28,7 +28,7 @@ with the seconds unit equal to the numeric value. } \details{ Durations are exact time measurements, whereas periods are relative time -measurements. See \linkS4class{Period}. The length of a period depends +measurements. See \link[=Period-class]{Period}. The length of a period depends on when it occurs. Hence, a one to one mapping does not exist between durations and periods. When used with a period object, as.duration provides an inexact estimate of the length of the period; each time unit is assigned @@ -46,7 +46,7 @@ as.numeric(dur, "hours") as.numeric(dur, "minutes") } \seealso{ -\linkS4class{Duration}, \code{\link[=duration]{duration()}} +\link[=Duration-class]{Duration}, \code{\link[=duration]{duration()}} } \keyword{chron} \keyword{classes} diff --git a/man/as.period.Rd b/man/as.period.Rd index 8ac6a01c..a42c29c0 100644 --- a/man/as.period.Rd +++ b/man/as.period.Rd @@ -33,7 +33,7 @@ to Period class objects with the specified units. \details{ Users must specify which time units to measure the period in. The exact length of each time unit in a period will depend on when it occurs. See -\linkS4class{Period} and \code{\link[=period]{period()}}. +\link[=Period-class]{Period} and \code{\link[=period]{period()}}. The choice of units is not trivial; units that are normally equal may differ in length depending on when the time period occurs. For example, when a leap second occurs one minute is longer than 60 @@ -41,7 +41,7 @@ seconds. Because periods do not have a fixed length, they can not be accurately converted to and from Duration objects. Duration objects measure time spans -in exact numbers of seconds, see \linkS4class{Duration}. Hence, a one to one +in exact numbers of seconds, see \link[=Duration-class]{Duration}. Hence, a one to one mapping does not exist between durations and periods. When used with a Duration object, as.period provides an inexact estimate; the duration is broken into time units based on the most common lengths of time units, in @@ -80,7 +80,7 @@ as.numeric(per, "hours") as.numeric(per, "minutes") } \seealso{ -\linkS4class{Period}, \code{\link[=period]{period()}} +\link[=Period-class]{Period}, \code{\link[=period]{period()}} } \keyword{chron} \keyword{classes} diff --git a/man/date_utils.Rd b/man/date_utils.Rd index 03727d41..ccfe1a5e 100644 --- a/man/date_utils.Rd +++ b/man/date_utils.Rd @@ -1,14 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/Dates.r -\docType{data} \name{is.Date} \alias{is.Date} \alias{Date} \alias{NA_Date_} \title{Various date utilities} -\format{ -An object of class \code{Date} of length 1. -} \usage{ is.Date(x) @@ -34,5 +30,4 @@ is.Date(difftime(now() + 5, now())) # FALSE \code{\link[=is.instant]{is.instant()}}, \code{\link[=is.timespan]{is.timespan()}}, \code{\link[=is.POSIXt]{is.POSIXt()}}, \code{\link[=POSIXct]{POSIXct()}} } \keyword{chron} -\keyword{datasets} \keyword{logic} diff --git a/man/duration.Rd b/man/duration.Rd index 12e9d066..2378a2d7 100644 --- a/man/duration.Rd +++ b/man/duration.Rd @@ -150,7 +150,7 @@ is.duration(as.Date("2009-08-03")) # FALSE is.duration(duration(days = 12.4)) # TRUE } \seealso{ -\code{\link[=as.duration]{as.duration()}} \linkS4class{Duration} +\code{\link[=as.duration]{as.duration()}} \link[=Duration-class]{Duration} } \keyword{chron} \keyword{classes} diff --git a/man/hidden_aliases.Rd b/man/hidden_aliases.Rd index 2afc10a8..78ccf51a 100644 --- a/man/hidden_aliases.Rd +++ b/man/hidden_aliases.Rd @@ -1,7 +1,6 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/Dates.r, R/POSIXt.r, R/intervals.r, % R/durations.r, R/periods.r, R/hidden.r -\docType{data} \name{hidden_aliases} \alias{hidden_aliases} \alias{day<-,Date-method} diff --git a/man/interval.Rd b/man/interval.Rd index d3015d85..06dff2cd 100644 --- a/man/interval.Rd +++ b/man/interval.Rd @@ -69,7 +69,7 @@ with much more permissive lubridate style parsing both for dates and periods \code{int_diff()})} } \value{ -\code{interval()} -- \linkS4class{Interval} object. +\code{interval()} -- \link[=Interval-class]{Interval} object. \code{int_start()} and \code{int_end()} return a POSIXct date object when used as an accessor. Nothing when used as a setter. @@ -91,7 +91,7 @@ same moment. FALSE otherwise between the n date-time in times } \description{ -\code{interval()} creates an \linkS4class{Interval} object with the specified start and +\code{interval()} creates an \link[=Interval-class]{Interval} object with the specified start and end dates. If the start date occurs before the end date, the interval will be positive. Otherwise, it will be negative. Character vectors in ISO 8601 format are supported from v1.7.2. @@ -121,7 +121,7 @@ moments of each interval occur at the same time. \code{int_diff()} returns the intervals that occur between the elements of a vector of date-times. \code{int_diff()} is similar to the POSIXt and Date -methods of \code{\link[=diff]{diff()}}, but returns an \linkS4class{Interval} object instead +methods of \code{\link[=diff]{diff()}}, but returns an \link[=Interval-class]{Interval} object instead of a difftime object. } \details{ @@ -188,5 +188,5 @@ dates <- now() + days(1:10) int_diff(dates) } \seealso{ -\linkS4class{Interval}, \code{\link[=as.interval]{as.interval()}}, \code{\link{\%within\%}} +\link[=Interval-class]{Interval}, \code{\link[=as.interval]{as.interval()}}, \code{\link{\%within\%}} } diff --git a/man/lubridate-package.Rd b/man/lubridate-package.Rd index 66811d53..af119476 100644 --- a/man/lubridate-package.Rd +++ b/man/lubridate-package.Rd @@ -24,7 +24,7 @@ models the order in which the year ('y'), month ('m') and day is provided by \code{\link[=parse_date_time]{parse_date_time()}}. Lubridate can also parse partial dates from strings into -\linkS4class{Period} objects with the functions +\link[=Period-class]{Period} objects with the functions \code{\link[=hm]{hm()}}, \code{\link[=hms]{hms()}} and \code{\link[=ms]{ms()}}. Lubridate has an inbuilt very fast POSIX parser. Most of the \code{\link[=strptime]{strptime()}} @@ -37,9 +37,9 @@ formats and various extensions are supported for English locales. See Lubridate distinguishes between moments in time (known as \code{\link[=instants]{instants()}}) and spans of time (known as time spans, see -\linkS4class{Timespan}). Time spans are further separated into -\linkS4class{Duration}, \linkS4class{Period} and -\linkS4class{Interval} objects. +\link[=Timespan-class]{Timespan}). Time spans are further separated into +\link[=Duration-class]{Duration}, \link[=Period-class]{Period} and +\link[=Interval-class]{Interval} objects. } \section{Instants}{ @@ -108,8 +108,8 @@ follow complex conventions and rules so that the clock times we see reflect what we expect to observe in terms of daylight, season, and congruence with the atomic clock. To better navigate the nuances of time, \pkg{lubridate} creates three additional timespan classes, each with its own specific and consistent behavior: -\linkS4class{Interval}, \linkS4class{Period} and -\linkS4class{Duration}. +\link[=Interval-class]{Interval}, \link[=Period-class]{Period} and +\link[=Duration-class]{Duration}. \code{\link[=is.difftime]{is.difftime()}} tests whether an object inherits from the difftime class. \code{\link[=is.timespan]{is.timespan()}} @@ -196,6 +196,7 @@ Useful links: Authors: \itemize{ + \item Vitalie Spinu \email{spinuvit@gmail.com} \item Garrett Grolemund \item Hadley Wickham } diff --git a/man/make_datetime.Rd b/man/make_datetime.Rd index c547990d..41f7dca8 100644 --- a/man/make_datetime.Rd +++ b/man/make_datetime.Rd @@ -34,7 +34,7 @@ make_date(year = 1970L, month = 1L, day = 1L) } \description{ \code{make_datetime()} is a very fast drop-in replacement for -\code{\link[base:ISOdatetime]{base::ISOdate()}} and \code{\link[base:ISOdatetime]{base::ISOdatetime()}}. \code{make_date()} produces +\code{\link[base:ISOdate]{base::ISOdate()}} and \code{\link[base:ISOdatetime]{base::ISOdatetime()}}. \code{make_date()} produces objects of class \code{Date}. } \details{ diff --git a/man/minute.Rd b/man/minute.Rd index 494e58ef..80d3ce8c 100644 --- a/man/minute.Rd +++ b/man/minute.Rd @@ -18,7 +18,7 @@ minute(x) <- value the minutes element of x as a decimal number } \description{ -Date-time must be a POSIXct, POSIXlt, Date, Period, chron, yearmon, yearqtr, zoo, +Date-time must be a POSIXct, POSIXlt, Date, Period, chron, yearmon, yearqtr, zoo, zooreg, timeDate, xts, its, ti, jul, timeSeries, and fts objects. } \examples{ diff --git a/man/origin.Rd b/man/origin.Rd index 6a0ad6e7..487c0a98 100644 --- a/man/origin.Rd +++ b/man/origin.Rd @@ -1,12 +1,8 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/instants.r -\docType{data} \name{origin} \alias{origin} \title{1970-01-01 UTC} -\format{ -An object of class \code{POSIXct} (inherits from \code{POSIXt}) of length 1. -} \usage{ origin } diff --git a/man/parse_date_time.Rd b/man/parse_date_time.Rd index a9ac9e1a..4549c6bb 100644 --- a/man/parse_date_time.Rd +++ b/man/parse_date_time.Rd @@ -57,7 +57,7 @@ fails to parse \verb{\%Y-\%m} formats.} \item{quiet}{logical. If \code{TRUE}, progress messages are not printed, and \verb{No formats found} error is suppressed and the function simply returns a vector of NAs. This mirrors the behavior of base R functions -\code{\link[base:strptime]{base::strptime()}} and \code{\link[base:as.POSIXlt]{base::as.POSIXct()}}.} +\code{\link[base:strptime]{base::strptime()}} and \code{\link[base:as.POSIXct]{base::as.POSIXct()}}.} \item{locale}{locale to be used, see \link{locales}. On Linux systems you can use \code{system("locale -a")} to list all the installed locales.} @@ -215,7 +215,7 @@ formats as any other but it is rarely necessary. \code{parse_date_time2()} and \item{\code{r} (*)}{Matches \code{Ip} and \code{H} orders.} -\item{\code{R} (*)}{Matches \code{HM} and\code{IMp} orders.} +\item{\code{R} (*)}{Matches \code{HM} and \code{IMp} orders.} \item{\code{T} (*)}{Matches \code{IMSp}, \code{HMS}, and \code{HMOS} orders.} } diff --git a/man/period.Rd b/man/period.Rd index fd24df0f..cbbd2a49 100644 --- a/man/period.Rd +++ b/man/period.Rd @@ -95,7 +95,7 @@ oriented programming. Note: Arithmetic with periods can result in undefined behavior when non-existent dates are involved (such as February 29th in non-leap years). -Please see \linkS4class{Period} for more details and \code{\link{\%m+\%}} and +Please see \link[=Period-class]{Period} for more details and \code{\link{\%m+\%}} and \code{\link[=add_with_rollback]{add_with_rollback()}} for alternative operations. } \examples{ @@ -166,7 +166,7 @@ is.period(as.Date("2009-08-03")) # FALSE is.period(period(months = 1, days = 15)) # TRUE } \seealso{ -\linkS4class{Period}, \code{\link[=period]{period()}}, \code{\link{\%m+\%}}, +\link[=Period-class]{Period}, \code{\link[=period]{period()}}, \code{\link{\%m+\%}}, \code{\link[=add_with_rollback]{add_with_rollback()}} } \keyword{chron} diff --git a/man/posix_utils.Rd b/man/posix_utils.Rd index df90c9b9..2e5e7028 100644 --- a/man/posix_utils.Rd +++ b/man/posix_utils.Rd @@ -1,6 +1,5 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/POSIXt.r -\docType{data} \name{is.POSIXt} \alias{is.POSIXt} \alias{is.POSIXlt} @@ -8,9 +7,6 @@ \alias{POSIXct} \alias{NA_POSIXct_} \title{Various POSIX utilities} -\format{ -An object of class \code{POSIXct} (inherits from \code{POSIXt}) of length 1. -} \usage{ is.POSIXt(x) @@ -45,5 +41,4 @@ is.POSIXt(as.POSIXct("2009-08-03")) \code{\link[=is.instant]{is.instant()}}, \code{\link[=is.timespan]{is.timespan()}}, \code{\link[=is.Date]{is.Date()}} } \keyword{chron} -\keyword{datasets} \keyword{logic} diff --git a/man/quarter.Rd b/man/quarter.Rd index 98e854ee..d01c5972 100644 --- a/man/quarter.Rd +++ b/man/quarter.Rd @@ -19,7 +19,7 @@ semester(x, with_year = FALSE) zoo, zooreg, timeDate, xts, its, ti, jul, timeSeries, fts or anything else that can be converted with as.POSIXlt} -\item{type}{the format to be returned for the quarter. Can be one one of "quarter" - +\item{type}{the format to be returned for the quarter. Can be one of "quarter" - return numeric quarter (default), "year.quarter" return the ending year and quarter as a number of the form year.quarter, "date_first" or "date_last" - return the date at the quarter's start or end, "year_start/end" - return a full description of the @@ -37,7 +37,7 @@ numeric or a vector of class POSIXct if \code{type} argument is \code{date_first financial year. } \description{ -Quarters divide the year into fourths. Semesters divide the year into halfs. +Quarters divide the year into fourths. Semesters divide the year into halves. } \examples{ x <- ymd(c("2012-03-26", "2012-05-04", "2012-09-23", "2012-12-31")) diff --git a/man/reexports.Rd b/man/reexports.Rd index a7cae224..bd2bccaa 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -14,6 +14,6 @@ These objects are imported from other packages. Follow the links below to see their documentation. \describe{ - \item{generics}{\code{\link[generics:coercion-time-difference]{as.difftime}}, \code{\link[generics:setops]{intersect}}, \code{\link[generics:setops]{setdiff}}, \code{\link[generics:setops]{union}}} + \item{generics}{\code{\link[generics:as.difftime]{as.difftime()}}, \code{\link[generics:intersect]{intersect()}}, \code{\link[generics:setdiff]{setdiff()}}, \code{\link[generics:union]{union()}}} }} diff --git a/man/round_date.Rd b/man/round_date.Rd index ebda311a..e3cd8dd6 100644 --- a/man/round_date.Rd +++ b/man/round_date.Rd @@ -67,7 +67,7 @@ and same time zone as \code{unit}. of the specified time unit. For rounding date-times which are exactly halfway between two consecutive units, the convention is to round up. Note that this is in line with the behavior of R's \code{\link[base:round.POSIXt]{base::round.POSIXt()}} function -but does not follow the convention of the base \code{\link[base:Round]{base::round()}} function +but does not follow the convention of the base \code{\link[base:round]{base::round()}} function which "rounds to the even digit", as per IEC 60559. Rounding to the nearest unit or multiple of a unit is supported. All @@ -196,7 +196,7 @@ ceiling_date(x, "month") ceiling_date(x, "month", change_on_boundary = TRUE) } \seealso{ -\code{\link[base:Round]{base::round()}} +\code{\link[base:round]{base::round()}} } \keyword{chron} \keyword{manip} diff --git a/man/second.Rd b/man/second.Rd index 6210928d..2c4006d6 100644 --- a/man/second.Rd +++ b/man/second.Rd @@ -18,7 +18,7 @@ second(x) <- value the seconds element of x as a decimal number } \description{ -Date-time must be a POSIXct, POSIXlt, Date, Period, chron, yearmon, yearqtr, zoo, +Date-time must be a POSIXct, POSIXlt, Date, Period, chron, yearmon, yearqtr, zoo, zooreg, timeDate, xts, its, ti, jul, timeSeries, and fts objects. } \examples{ diff --git a/man/time_length.Rd b/man/time_length.Rd index e8fe8296..e8b8de1b 100644 --- a/man/time_length.Rd +++ b/man/time_length.Rd @@ -12,7 +12,7 @@ time_length(x, unit = "second") \arguments{ \item{x}{a duration, period, difftime or interval} -\item{unit}{a character string that specifies with time units to use} +\item{unit}{a character string that specifies which time units to use} } \value{ the length of the interval in the specified unit. A negative number @@ -22,11 +22,11 @@ connotes a negative interval or duration Compute the exact length of a time span } \details{ -When \code{x} is an \linkS4class{Interval} object and +When \code{x} is an \link[=Interval-class]{Interval} object and \code{unit} are years or months, \code{time_length()} takes into account the fact that all months and years don't have the same number of days. -When \code{x} is a \linkS4class{Duration}, \linkS4class{Period} +When \code{x} is a \link[=Duration-class]{Duration}, \link[=Period-class]{Period} or \code{\link[=difftime]{difftime()}} object, length in months or years is based on their most common lengths in seconds (see \code{\link[=timespan]{timespan()}}). } diff --git a/man/tz.Rd b/man/tz.Rd index 41c82e9a..5d68708c 100644 --- a/man/tz.Rd +++ b/man/tz.Rd @@ -55,7 +55,7 @@ with_tz(x, "Pacific/Auckland") } \seealso{ See \link{DateTimeClasses} for a description of the underlying -\code{tzone} attribute.. +\code{tzone} attribute. } \keyword{chron} \keyword{manip} From 92626c343d38436f5d0eb0790ee03bcc74b4953d Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 1 Oct 2026 11:14:47 -0400 Subject: [PATCH 3/4] docs: make two interval() examples span the 2.5 hours they intend "2008-05-11/P2H30M" and "08 05 11/P 2h 30m" both ended on 2010-11-11, because "M" after an ISO "P" and lowercase "m" in shorthand mean months. The neighbouring example "P2hours 30minutes" shows the intent. Use "PT2H30M" and "30min", and pin all three in a test. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 3 +++ R/intervals.r | 4 ++-- man/interval.Rd | 4 ++-- tests/testthat/test-intervals.R | 7 +++++++ 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 0d1bf747..76b0b127 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,9 @@ Version 1.9.5.9999 (dev) (`"T1H"`) or after it (`"P1DT"`), and nested parentheses (`"1h (2h (3h) 4h) 5h"` was 10 hours). Lubridate shorthand such as `"day day"` and `"10DT10M"` is unaffected. +* Fix two `interval()` examples that spanned 2.5 years instead of 2.5 hours: + `"2008-05-11/P2H30M"` and `"08 05 11/P 2h 30m"` read `M` and `m` as months. + They now use `"PT2H30M"` and `"30min"`. Version 1.9.5 diff --git a/R/intervals.r b/R/intervals.r index fdd282d0..5c932e11 100644 --- a/R/intervals.r +++ b/R/intervals.r @@ -201,11 +201,11 @@ unique.Interval <- function(x, ...) { #' interval("2007-03-01T13:00:00Z/2008-05-11T15:30:00Z") #' interval("2007-03-01T13:00:00Z/P1Y2M10DT2H30M") #' interval("P1Y2M10DT2H30M/2008-05-11T15:30:00Z") -#' interval("2008-05-11/P2H30M") +#' interval("2008-05-11/PT2H30M") #' #' ### More permissive parsing (as long as there are no intermittent / characters) #' interval("2008 05 11/P2hours 30minutes") -#' interval("08 05 11/P 2h 30m") +#' interval("08 05 11/P 2h 30min") #' #' is.interval(period(months = 1, days = 15)) # FALSE #' is.interval(interval(ymd(20090801), ymd(20090809))) # TRUE diff --git a/man/interval.Rd b/man/interval.Rd index 06dff2cd..93337b39 100644 --- a/man/interval.Rd +++ b/man/interval.Rd @@ -146,11 +146,11 @@ span <- interval(ymd(20090101), ymd(20090201)) interval("2007-03-01T13:00:00Z/2008-05-11T15:30:00Z") interval("2007-03-01T13:00:00Z/P1Y2M10DT2H30M") interval("P1Y2M10DT2H30M/2008-05-11T15:30:00Z") -interval("2008-05-11/P2H30M") +interval("2008-05-11/PT2H30M") ### More permissive parsing (as long as there are no intermittent / characters) interval("2008 05 11/P2hours 30minutes") -interval("08 05 11/P 2h 30m") +interval("08 05 11/P 2h 30min") is.interval(period(months = 1, days = 15)) # FALSE is.interval(interval(ymd(20090801), ymd(20090809))) # TRUE diff --git a/tests/testthat/test-intervals.R b/tests/testthat/test-intervals.R index 7d82b92d..f8b2f2b5 100644 --- a/tests/testthat/test-intervals.R +++ b/tests/testthat/test-intervals.R @@ -87,6 +87,13 @@ test_that("Parsing of iso 8601 intervals works", { ) }) +test_that("documented ISO interval examples span 2.5 hours", { + start <- ymd("2008-05-11", tz = "UTC") + for (x in c("2008-05-11/PT2H30M", "2008 05 11/P2hours 30minutes", "08 05 11/P 2h 30min")) { + expect_equal(int_end(interval(x)), start + hours(2) + minutes(30), info = x) + } +}) + test_that("interval works as expected", { time1 <- as.POSIXct("2008-08-03 13:01:59", tz = "UTC") time2 <- as.POSIXct("2009-08-03 13:01:59", tz = "UTC") From 1393b0905d107d9b9f741011287468936e895dbf Mon Sep 17 00:00:00 2001 From: Bill Denney Date: Thu, 1 Oct 2026 11:16:04 -0400 Subject: [PATCH 4/4] Start the ISO time part at an H or S designator written before T "P2H30M" is used in lubridate's own interval tests and, until the previous commit, its documentation, plainly meaning 2 hours 30 minutes. Because "M" after "P" always meant months, it parsed as 2 hours and 30 months. An hours or seconds designator before "T" now starts the time part, so a following "M" is minutes. "M" before any time designator is still months. This changes the value of an input that used to parse, so it is kept as a separate commit. Co-Authored-By: Claude Opus 5.5 --- NEWS.md | 4 ++++ src/period.c | 4 ++++ tests/testthat/test-periods.R | 15 +++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/NEWS.md b/NEWS.md index 76b0b127..7ea00693 100644 --- a/NEWS.md +++ b/NEWS.md @@ -15,6 +15,10 @@ Version 1.9.5.9999 (dev) * Fix two `interval()` examples that spanned 2.5 years instead of 2.5 hours: `"2008-05-11/P2H30M"` and `"08 05 11/P 2h 30m"` read `M` and `m` as months. They now use `"PT2H30M"` and `"30min"`. +* Behaviour change: in an ISO 8601 period written without `T`, an `H` or `S` + designator now starts the time part, so a following `M` means minutes. + `period("P2H30M")` was 2 hours and 30 months and is now 2 hours and 30 + minutes. Version 1.9.5 diff --git a/src/period.c b/src/period.c index e3c71911..47a40f21 100644 --- a/src/period.c +++ b/src/period.c @@ -136,6 +136,10 @@ void parse_period_1 (const char **c, double ret[N_PERIOD_UNITS]){ return; } seen |= bit; + // an hours or seconds designator before 'T' ("P2H30M") starts the + // time part, so a later M is minutes, not months + if (P && (fu.unit == 2 || fu.unit == 0)) + P = 0; } parsed1 = 1; T = 0; diff --git a/tests/testthat/test-periods.R b/tests/testthat/test-periods.R index b14e3423..31e18667 100644 --- a/tests/testthat/test-periods.R +++ b/tests/testthat/test-periods.R @@ -158,6 +158,21 @@ test_that("well-formed and documented lenient period strings still parse", { expect_equal(period(" 1d ("), period(days = 1)) }) +test_that("an H or S designator before T starts the ISO time part", { + # used to be 30 months: "M" after "P" was always months + expect_equal(period("P2H30M"), period(hours = 2, minutes = 30)) + expect_equal(period("P1S2M"), period(seconds = 1, minutes = 2)) + expect_equal(period("P1D2H30M"), period(days = 1, hours = 2, minutes = 30)) + # M before any time designator is still months + expect_equal(period("P3M2H"), period(months = 3, hours = 2)) + expect_equal(period("P1Y2M10DT2H30M"), + period(years = 1, months = 2, days = 10, hours = 2, minutes = 30)) + expect_equal( + interval("2008-05-11/P2H30M"), + interval(ymd("2008-05-11", tz = "UTC"), ymd_hm("2008-05-11 02:30", tz = "UTC")) + ) +}) + test_that("fractional parsing works as expected", { expect_equal( period("1.1min 2.3sec 2.3secs 1.0H 2.2M 1.5d"),