Skip to content

Document how to format a timedelta in human-readable form #132642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
aum7 opened this issue Apr 17, 2025 · 11 comments
Open

Document how to format a timedelta in human-readable form #132642

aum7 opened this issue Apr 17, 2025 · 11 comments
Labels
docs Documentation in the Doc dir easy type-feature A feature request or enhancement

Comments

@aum7
Copy link

aum7 commented Apr 17, 2025

Bug description:

dt_utc = datetime.now(timezone.utc).replace(microsecond=1)
dt_utc_str = dt_utc.strftime("%Y-%m-%d %H:%M:%S")
print(f"timenow : dt_utc_str : {dt_utc_str}")
if self.timezone:
    print(f"timenow : timezone : {self.timezone}")
    # dt_event = dt_utc.replace(tzinfo=ZoneInfo(self.timezone))
    dt_event = dt_utc.astimezone(ZoneInfo(self.timezone))
    dt_event_str = dt_event.strftime("%Y-%m-%d %H:%M:%S")
    print(f"timenow : dt_event_str : {dt_event_str}")
    tz_offset_ = dt_event.utcoffset()
    tz_offset_str = str(tz_offset_)
    print(f"timenow : tzoffstr : {tz_offset_str}")
    print(
        f"timenow : tz_offset_ : {tz_offset_} | type : {type(tz_offset_)}"
    )
    # error : invalid literal ... -1 day, 20:00:00 [workaround]
    # parse timezone string to decimal hours
    parts = [p for p in tz_offset_str.split(",") if p]
    days = int(parts[0].split()[0]) if "day" in parts[0] else 0
    h, m, s = map(int, parts[-1].strip().split(":"))
    # convert to decimal
    self.tz_offset = days * 24 + h + m / 60 + s / 3600
    print(f"timenow : tz_offset : {self.tz_offset}")
  • terminal : expected / correct
    timenow : dt_utc_str : 2025-04-17 15:46:10
    timenow : timezone : Europe/Vienna
    timenow : dt_event_str : 2025-04-17 17:46:10
    timenow : tzoffstr : 2:00:00
    timenow : tz_offset_ : 2:00:00 | type : <class 'datetime.timedelta'>
    timenow : tz_offset : 2.0

  • terminal : unexpected / wrong
    timenow : dt_utc_str : 2025-04-17 15:46:36
    timenow : timezone : America/Lima
    timenow : dt_event_str : 2025-04-17 10:46:36
    timenow : tzoffstr : -1 day, 19:00:00
    timenow : tz_offset_ : -1 day, 19:00:00 | type : <class 'datetime.timedelta'>
    timenow : tz_offset : -5.0

seems western longitudes return '-1 day, hh:mm:ss' offset

tested with usa, peru, chile, venezuela : all west longitude (-ve) return '-1 day...'
with slovenia, austria, china, australia : works ok, as expected, no 'day' in return

the workaround i am using (bottom of above py code) is working properly > tz_offset is correct
basically it is -24 (hours) + hh:mm:ss (as decimal number)

os : linux mint22 (latest, greatest)
python : 3.11.12 in virtual env (Python 3.11.12 (main, Apr 9 2025, 08:55:55) [GCC 13.3.0] on linux)
guest os (with dev venv) is running in virual machine on identical host (mint22)

have fun

CPython versions tested on:

3.11

Operating systems tested on:

Linux

@aum7 aum7 added the type-bug An unexpected behavior, bug, or error label Apr 17, 2025
@picnixz picnixz added the extension-modules C modules in the Modules dir label Apr 17, 2025
@picnixz
Copy link
Member

picnixz commented Apr 17, 2025

The reason is because of normalization of timedelta objects. It's the expected (and documented) behavior, albeit surprsing. See https://docs.python.org/3/library/datetime.html#datetime.timedelta. The relevant paragraph is:

Only days, seconds and microseconds are stored internally. Arguments are converted to those units:

    A millisecond is converted to 1000 microseconds.
    A minute is converted to 60 seconds.
    An hour is converted to 3600 seconds.
    A week is converted to 7 days.

and days, seconds and microseconds are then normalized so that the representation is unique, with

    0 <= microseconds < 1000000
    0 <= seconds < 3600*24 (the number of seconds in one day)
    -999999999 <= days <= 999999999

The representation is meant to be unique under the above constraints.

@picnixz picnixz added pending The issue will be closed if no feedback is provided type-feature A feature request or enhancement stdlib Python modules in the Lib dir and removed type-bug An unexpected behavior, bug, or error labels Apr 17, 2025
@picnixz
Copy link
Member

picnixz commented Apr 17, 2025

However, I'm willing to consider the str() (or repr()), to be changed so that it's in a more readable format. Internally, we wouldn't change anything, but we could consider changing the visible output. However, I don't know if it's necessary, so I'm leaving the decision to @pganssle.

Note that it's possible to just recover the time delta in terms of (signed) hours by taking the corresponding components (namely, you don't need to parse the output, just use the attributes directly namely days, seconds and microseconds and convert them into hours and then print the result accordingly)

@zware
Copy link
Member

zware commented Apr 17, 2025

Note that it's possible to just recover the time delta in terms of (signed) hours by taking the corresponding components (namely, you don't need to parse the output, just use the attributes directly namely days, seconds and microseconds and convert them into hours and then print the result accordingly)

Even better, just do division on the result of int(delta.total_seconds()) (if you don't need to worry about microseconds).

I don't think __str__ or __repr__ of timedelta is going to change at this point. It might be interesting to add a better-named timedelta.human_friendly_str() method, though, something like:

def human_friendly_td_str(td):
    if td.days >= 0:
        return str(td)
    return f'-({-td!s})'

@picnixz
Copy link
Member

picnixz commented Apr 17, 2025

We could add this kind of recipe in the docs. I'm converting this into a docs issue first (adding more methods would require another discussion but I don't think we should only consider adding it to timedelta; other classes may need this but we'll need to decide first which ones).

@picnixz picnixz added docs Documentation in the Doc dir and removed stdlib Python modules in the Lib dir extension-modules C modules in the Modules dir pending The issue will be closed if no feedback is provided labels Apr 17, 2025
@picnixz picnixz changed the title datetime.utcoffset returns -1 day, 20:00:00 Document how to format a timedelta in human-readable form Apr 17, 2025
@picnixz picnixz added the easy label Apr 17, 2025
@aum7
Copy link
Author

aum7 commented Apr 17, 2025

thx for reply

quote
just use the attributes directly namely days, seconds
quote end

i did tried
tz_offset_secs = tz_offset_.seconds
but was confused since my code editor (nvim - lunarvim) was giving me error : seconds (also days & total_seconds) is not a known attribute (probably editor issue)
tested again, works now, but
if tz_offset_:
tz_offset_secs = tz_offset_.seconds
print(f"mantimechange : tzoffsecs : {tz_offset_secs}")

terminal
mantimechange : tzoffsecs : 72000
/ 3600 -> 20.0 = not expected since expected / useful / actual offset is
manualdt tz : ... tzoffset : -4.0 (hours)

honestly, totally unexpected, although i did read py datetime docs and did catch that 'weird' conversion explanation

consider this solved, my workaround does what i need

best regards
have fun

@picnixz
Copy link
Member

picnixz commented Apr 17, 2025

I still think we can make a docs improvement for that so I'll keep this one open. At least mention a recipe where we show how to make it more readable (we do have a small example of "surprsing" normalization so it's fine to have a recipe that follows IMO)

@aum7
Copy link
Author

aum7 commented Apr 17, 2025

in a current state, it would need be converted in any case, via string (my solution), or via something else
note that offset for west of 0 longitude is -ve, minus 4 hours in our example
....seconds - did not preserve -ve sign

what a user expects when it sees 'tzoffset', is -ve hours for western, & +ve hours for eastern longitudes
(this is european normative (as a standard), usa has it reversed, i believe : +ve for west, -ve for east longitudes)

while we are at it

happy easter holidays

godspeed

@StanFromIreland
Copy link
Contributor

StanFromIreland commented Apr 17, 2025

However, I'm willing to consider the str() (or repr()), to be changed so that it's in a more readable format. Internally, we wouldn't change anything, but we could consider changing the visible output. However, I don't know if it's necessary, so I'm leaving the decision to @pganssle.

@picnixz There are 1 (2) duplicates of this.

#85426 #86260

@picnixz
Copy link
Member

picnixz commented Apr 17, 2025

Oh... right... I forgot about them. Anyway, we can still use this one for the docs.

@StanFromIreland
Copy link
Contributor

timedelta has both __str__ and __repr__ since 2010 and they seem fine to me.

>>> s = timedelta(11.2143)
>>> print(f'{s!r}')
datetime.timedelta(days=11, seconds=18515, microseconds=520000)
>>> print(f'{s!s}')
11 days, 5:08:35.520000

@picnixz
Copy link
Member

picnixz commented Apr 19, 2025

The issue isn't about whether they are fine or not, it's about the possible negative output which for a human is not necessarily useful (especially, if one needs to do -1 day + X hours)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Documentation in the Doc dir easy type-feature A feature request or enhancement
Projects
Status: Todo
Development

No branches or pull requests

4 participants