Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions novem/cli/group.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import datetime
import email.utils as eut
import json
import re
from typing import Any, Dict, List

from novem.exceptions import Novem404

from ..api_ref import NovemAPI
from ..utils import cl, pretty_format
from ..utils import cl, format_datetime_local, parse_api_datetime, pretty_format


def list_orgs(args: Dict[str, Any], novem: NovemAPI, path: str) -> None:
Expand Down Expand Up @@ -106,8 +104,9 @@ def list_orgs(args: Dict[str, Any], novem: NovemAPI, path: str) -> None:
]

for p in flist:
nd = datetime.datetime(*eut.parsedate(p["created"])[:6])
p["created"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["created"])
if dt:
p["created"] = format_datetime_local(dt)

ppl = pretty_format(flist, ppo)

Expand Down Expand Up @@ -241,8 +240,9 @@ def list_groups(args: Dict[str, Any], novem: NovemAPI, path: str) -> None:
]

for p in flist:
nd = datetime.datetime(*eut.parsedate(p["created"])[:6])
p["created"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["created"])
if dt:
p["created"] = format_datetime_local(dt)

ppl = pretty_format(flist, ppo)

Expand Down
9 changes: 4 additions & 5 deletions novem/cli/invite.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import datetime
import email.utils as eut
import json
from typing import Any, Dict, List

from novem.exceptions import Novem404

from ..api_ref import NovemAPI
from ..utils import cl, pretty_format
from ..utils import cl, format_datetime_local, parse_api_datetime, pretty_format


def list_invites(args: Dict[str, Any], novem: NovemAPI) -> None:
Expand Down Expand Up @@ -114,8 +112,9 @@ def list_invites(args: Dict[str, Any], novem: NovemAPI) -> None:
]

for p in flist:
nd = datetime.datetime(*eut.parsedate(p["created"])[:6])
p["created"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["created"])
if dt:
p["created"] = format_datetime_local(dt)

ppl = pretty_format(flist, ppo)

Expand Down
62 changes: 26 additions & 36 deletions novem/cli/vis.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import datetime
import email.utils as eut
import json
import re
from datetime import timezone
from typing import Any, Dict, List, Optional, Tuple

from novem.exceptions import Novem404

from ..api_ref import NovemAPI
from ..utils import cl, colors, get_current_config, pretty_format
from ..utils import cl, colors, format_datetime_local, get_current_config, parse_api_datetime, pretty_format
from .filter import apply_filters
from .gql import (
NovemGQL,
Expand Down Expand Up @@ -81,10 +81,8 @@ def list_vis(args: Dict[str, Any], type: str) -> None:
# Sort by: 1) favs first, 2) likes second, 3) rest last - each group sorted by updated (newest first)
# Parse date string for proper sorting (format: "Thu, 17 Mar 2022 12:19:02 UTC")
def parse_date(date_str: str) -> datetime.datetime:
parsed = eut.parsedate(date_str)
if parsed:
return datetime.datetime(*parsed[:6])
return datetime.datetime.min
dt = parse_api_datetime(date_str)
return dt if dt else datetime.datetime.min.replace(tzinfo=timezone.utc)

def sort_tier(markers: str) -> int:
"""Return sort tier: 0=fav, 1=like only, 2=rest."""
Expand Down Expand Up @@ -181,8 +179,9 @@ def fav_fmt(markers: str, cl: cl) -> str:
]

for p in plist:
nd = datetime.datetime(*eut.parsedate(p["updated"])[:6])
p["updated"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["updated"])
if dt:
p["updated"] = format_datetime_local(dt)

striped: bool = config.get("cli_striped", False)
ppl = pretty_format(plist, ppo, striped=striped)
Expand Down Expand Up @@ -251,10 +250,9 @@ def summary_fmt(summary: str, cl: cl) -> str:
]

for p in plist:
pds = eut.parsedate(p["created_on"])
if pds:
nd = datetime.datetime(*pds[:6])
p["created_on"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["created_on"])
if dt:
p["created_on"] = format_datetime_local(dt)

ppl = pretty_format(plist, ppo, striped=striped)
print(ppl)
Expand Down Expand Up @@ -376,10 +374,9 @@ def summary_fmt(summary: str, cl: cl) -> str:
]

for p in plist:
pds = eut.parsedate(p.get("created_on", ""))
if pds:
nd = datetime.datetime(*pds[:6])
p["created_on"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p.get("created_on", ""))
if dt:
p["created_on"] = format_datetime_local(dt)

ppl = pretty_format(plist, ppo, striped=striped)
print(ppl)
Expand Down Expand Up @@ -677,10 +674,8 @@ def list_jobs(args: Dict[str, Any]) -> None:

# Sort by: 1) favs first, 2) likes second, 3) rest last - each group sorted by updated (newest first)
def parse_date(date_str: str) -> datetime.datetime:
parsed = eut.parsedate(date_str)
if parsed:
return datetime.datetime(*parsed[:6])
return datetime.datetime.min
dt = parse_api_datetime(date_str)
return dt if dt else datetime.datetime.min.replace(tzinfo=timezone.utc)

def sort_tier(markers: str) -> int:
"""Return sort tier: 0=fav, 1=like only, 2=rest."""
Expand Down Expand Up @@ -876,11 +871,10 @@ def _format_relative_time(date_str: str) -> str:
if not date_str:
return ""
try:
parsed = eut.parsedate(date_str)
if not parsed:
dt = parse_api_datetime(date_str)
if not dt:
return date_str
dt = datetime.datetime(*parsed[:6])
now = datetime.datetime.now()
now = datetime.datetime.now(timezone.utc)
delta = now - dt

if delta.days < 0:
Expand Down Expand Up @@ -925,11 +919,10 @@ def _format_time_ago(date_str: str) -> str:
if not date_str:
return ""
try:
parsed = eut.parsedate(date_str)
if not parsed:
dt = parse_api_datetime(date_str)
if not dt:
return date_str
dt = datetime.datetime(*parsed[:6])
now = datetime.datetime.now()
now = datetime.datetime.now(timezone.utc)
delta = now - dt

if delta.days < 0:
Expand Down Expand Up @@ -1506,10 +1499,8 @@ def list_org_group_vis(args: Dict[str, Any], vis_type: str) -> None:

# Sort by: 1) favs first, 2) likes second, 3) rest last - each group sorted by updated (newest first)
def parse_date(date_str: str) -> datetime.datetime:
parsed = eut.parsedate(date_str)
if parsed:
return datetime.datetime(*parsed[:6])
return datetime.datetime.min
dt = parse_api_datetime(date_str)
return dt if dt else datetime.datetime.min.replace(tzinfo=timezone.utc)

def sort_tier(markers: str) -> int:
"""Return sort tier: 0=fav, 1=like only, 2=rest."""
Expand Down Expand Up @@ -1630,10 +1621,9 @@ def fav_fmt(markers: str, _cl: Any) -> str:
p["_last_run"] = _format_time_ago(p.get("last_run_time", ""))

if p.get("updated"):
parsed = eut.parsedate(p["updated"])
if parsed:
nd = datetime.datetime(*parsed[:6])
p["updated"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["updated"])
if dt:
p["updated"] = format_datetime_local(dt)

striped: bool = config.get("cli_striped", False)
ppl = pretty_format(plist, ppo, striped=striped)
Expand Down
39 changes: 39 additions & 0 deletions novem/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import configparser
import datetime
import email.utils as eut
import io
import os
import platform
Expand All @@ -7,6 +9,7 @@
import sys
import unicodedata
from dataclasses import dataclass
from datetime import timezone
from typing import Any, Dict, List, Optional, Tuple, Union, cast

from packaging.version import InvalidVersion, Version
Expand Down Expand Up @@ -493,3 +496,39 @@ def ensure_cli_defaults(path: str, config: configparser.ConfigParser) -> bool:
config.write(configfile)

return modified


def parse_api_datetime(date_str: str) -> Optional[datetime.datetime]:
"""
Parse an API date string into a timezone-aware datetime.

The API returns dates in RFC 2822 format with "UTC" suffix, e.g.:
"Mon, 05 Jan 2026 23:40:13 UTC"

email.utils.parsedate doesn't recognize "UTC" as a timezone, only numeric
offsets like "+0000". We normalize the string before parsing.

Returns a timezone-aware datetime in UTC, or None if parsing fails.
"""
if not date_str:
return None
try:
# Normalize "UTC" to "+0000" for email.utils parsing
normalized = date_str.replace(" UTC", " +0000").replace(" GMT", " +0000")
return eut.parsedate_to_datetime(normalized)
except Exception:
# Fallback: try parsing without timezone, assume UTC
try:
parsed = eut.parsedate(date_str)
if parsed:
dt = datetime.datetime(*parsed[:6])
return dt.replace(tzinfo=timezone.utc)
except Exception:
pass
return None


def format_datetime_local(dt: datetime.datetime) -> str:
"""Format a datetime as local time in YYYY-MM-DD HH:MM format."""
local_dt = dt.astimezone() # Convert to system local timezone
return local_dt.strftime("%Y-%m-%d %H:%M")
9 changes: 4 additions & 5 deletions tests/test_cli_grids.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import datetime
import email.utils as eut
from functools import partial

from novem.cli.gql import _get_gql_endpoint
from novem.utils import API_ROOT, pretty_format
from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format

from .utils import write_config

Expand Down Expand Up @@ -346,8 +344,9 @@ def fav_fmt(markers, cl):
]
plist = user_grid_list
for p in plist:
nd = datetime.datetime(*eut.parsedate(p["updated"])[:6]) # type: ignore
p["updated"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["updated"])
if dt:
p["updated"] = format_datetime_local(dt)

expected = pretty_format(plist, ppo) + "\n"

Expand Down
9 changes: 4 additions & 5 deletions tests/test_cli_plots.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import datetime
import email.utils as eut
import json
from functools import partial

import pytest

from novem.cli.gql import _get_gql_endpoint
from novem.utils import API_ROOT, pretty_format
from novem.utils import API_ROOT, format_datetime_local, parse_api_datetime, pretty_format
from tests.conftest import CliExit

from .utils import write_config
Expand Down Expand Up @@ -348,8 +346,9 @@ def fav_fmt(markers, cl):
]
plist = user_plot_list
for p in plist:
nd = datetime.datetime(*eut.parsedate(p["updated"])[:6]) # type: ignore
p["updated"] = nd.strftime("%Y-%m-%d %H:%M")
dt = parse_api_datetime(p["updated"])
if dt:
p["updated"] = format_datetime_local(dt)

ppl = pretty_format(plist, ppo)

Expand Down
68 changes: 67 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from typing import Any

from novem.utils import ansi_escape, colors, pretty_format_inner
from novem.utils import ansi_escape, colors, parse_api_datetime, pretty_format_inner


def test_pretty_format_basic() -> None:
Expand Down Expand Up @@ -155,3 +156,68 @@ def tracking_fmt(value: Any, _cl: Any) -> str:
for val in received_values:
assert isinstance(val, list)
assert val == ["a", "b", "c", "d", "e"]


def test_parse_api_datetime_utc_suffix() -> None:
"""Test parsing dates with UTC suffix (as returned by the API)."""
result = parse_api_datetime("Mon, 05 Jan 2026 23:40:13 UTC")
assert result is not None
assert result.year == 2026
assert result.month == 1
assert result.day == 5
assert result.hour == 23
assert result.minute == 40
assert result.second == 13
assert result.tzinfo == timezone.utc


def test_parse_api_datetime_gmt_suffix() -> None:
"""Test parsing dates with GMT suffix."""
result = parse_api_datetime("Fri, 12 Dec 2025 12:55:17 GMT")
assert result is not None
assert result.year == 2025
assert result.month == 12
assert result.day == 12
assert result.hour == 12
assert result.minute == 55
assert result.second == 17
assert result.tzinfo == timezone.utc


def test_parse_api_datetime_numeric_offset() -> None:
"""Test parsing dates with numeric timezone offset."""
result = parse_api_datetime("Sun, 14 Dec 2025 15:05:53 +0000")
assert result is not None
assert result.year == 2025
assert result.month == 12
assert result.day == 14
assert result.hour == 15
assert result.minute == 5
assert result.second == 53
assert result.tzinfo == timezone.utc


def test_parse_api_datetime_empty_string() -> None:
"""Test that empty string returns None."""
assert parse_api_datetime("") is None


def test_parse_api_datetime_none_like_empty() -> None:
"""Test that None-like empty input returns None."""
assert parse_api_datetime("") is None


def test_parse_api_datetime_invalid_format() -> None:
"""Test that invalid date format returns None."""
assert parse_api_datetime("not a date") is None
assert parse_api_datetime("2025-01-05") is None # ISO format not supported


def test_parse_api_datetime_returns_timezone_aware() -> None:
"""Test that returned datetime is always timezone-aware."""
result = parse_api_datetime("Mon, 05 Jan 2026 23:40:13 UTC")
assert result is not None
assert result.tzinfo is not None
# Should be able to compare with other tz-aware datetimes without error
now = datetime.now(timezone.utc)
_ = now - result # This would raise if result is naive