Skip to content

Commit 026381f

Browse files
authored
Merge pull request #872 from microsasa/fix/870-until-explicit-midnight-fc18960c8f7caa51
fix(cli): preserve explicit `T00:00:00` in `--until` (#870)
2 parents da727ad + a64bc2d commit 026381f

2 files changed

Lines changed: 310 additions & 34 deletions

File tree

src/copilot_usage/cli.py

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import sys
99
import threading
1010
import time
11+
from dataclasses import dataclass
1112
from datetime import datetime, time as dt_time
1213
from pathlib import Path
1314
from typing import Final, Literal, Protocol, cast
@@ -39,36 +40,94 @@
3940

4041
type _View = Literal["home", "detail", "cost"]
4142

42-
_DATE_FORMATS: Final[list[str]] = ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]
43+
# (format_string, has_explicit_time) pairs — single source of truth.
44+
_FORMAT_SPECS: Final[list[tuple[str, bool]]] = [
45+
("%Y-%m-%d", False),
46+
("%Y-%m-%dT%H:%M:%S", True),
47+
]
48+
49+
_DATE_FORMATS: Final[list[str]] = [fmt for fmt, _ in _FORMAT_SPECS]
4350

4451
_WATCHDOG_DEBOUNCE_SECS: Final[float] = (
4552
2.0 # Prevents rapid redraws during tool-use bursts
4653
)
4754

4855

56+
@dataclass(frozen=True, slots=True)
57+
class _ParsedDateArg:
58+
"""Carries a parsed datetime together with whether the user supplied a time."""
59+
60+
value: datetime
61+
has_explicit_time: bool
62+
63+
64+
class _DateTimeOrDate(click.ParamType):
65+
"""Click parameter type that distinguishes date-only from datetime inputs.
66+
67+
Parses ``%Y-%m-%d`` as date-only (``has_explicit_time=False``) and
68+
``%Y-%m-%dT%H:%M:%S`` as datetime (``has_explicit_time=True``).
69+
Returns a :class:`_ParsedDateArg`.
70+
"""
71+
72+
name: str = "datetime-or-date"
73+
74+
def convert( # noqa: RET503
75+
self,
76+
value: str | datetime,
77+
param: click.Parameter | None,
78+
ctx: click.Context | None,
79+
) -> _ParsedDateArg:
80+
"""Parse *value* into a ``_ParsedDateArg``."""
81+
if isinstance(value, datetime):
82+
# Already parsed (e.g. default value) — treat as explicit time.
83+
return _ParsedDateArg(value=value, has_explicit_time=True)
84+
85+
result = self._try_parse(value)
86+
if result is not None:
87+
return result
88+
89+
msg = (
90+
f"invalid datetime format: {value!r}. "
91+
"Expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS."
92+
)
93+
self.fail(msg, param, ctx)
94+
95+
@staticmethod
96+
def _try_parse(value: str) -> _ParsedDateArg | None:
97+
"""Attempt date-only then datetime parsing; return ``None`` on failure."""
98+
for fmt, explicit in _FORMAT_SPECS:
99+
try:
100+
return _ParsedDateArg(
101+
value=datetime.strptime(value, fmt),
102+
has_explicit_time=explicit,
103+
)
104+
except ValueError:
105+
continue
106+
return None
107+
108+
49109
console: Final[Console] = Console()
50110

51111

52-
def _normalize_until(dt: datetime | None) -> datetime | None:
53-
"""Extend an ``--until`` timestamp at midnight to end-of-day (23:59:59.999999).
112+
def _normalize_until(arg: _ParsedDateArg | None) -> datetime | None:
113+
"""Extend a date-only ``--until`` value to end-of-day (23:59:59.999999).
54114
55-
Because :class:`click.DateTime` discards the original string format, we
56-
cannot distinguish ``--until 2026-03-07`` from an explicit
57-
``--until 2026-03-07T00:00:00``. Both are treated as "include the
58-
entire day" and expanded to 23:59:59.999999 in the **same timezone**
59-
as the input (naive inputs are made UTC-aware via :func:`ensure_aware`).
115+
Only expands to end-of-day when the user supplied a date without a time
116+
component (``has_explicit_time is False``). An explicit
117+
``--until 2026-03-07T00:00:00`` is left as-is, giving strict
118+
before-midnight semantics.
60119
"""
61-
if dt is None:
120+
if arg is None:
62121
return None
63-
aware = ensure_aware(dt)
64-
if aware.time() == dt_time(0, 0, 0):
122+
aware = ensure_aware(arg.value)
123+
if not arg.has_explicit_time and aware.time() == dt_time(0, 0, 0):
65124
return aware.replace(hour=23, minute=59, second=59, microsecond=999999)
66125
return aware
67126

68127

69128
def _validate_since_until(
70129
since: datetime | None,
71-
until: datetime | None,
130+
until: _ParsedDateArg | None,
72131
) -> tuple[datetime | None, datetime | None]:
73132
"""Normalize and validate --since/--until, raising on reversed range."""
74133
aware_since = ensure_aware_opt(since)
@@ -437,9 +496,9 @@ def main(ctx: click.Context, path: Path | None) -> None:
437496
)
438497
@click.option(
439498
"--until",
440-
type=click.DateTime(formats=_DATE_FORMATS),
499+
type=_DateTimeOrDate(),
441500
default=None,
442-
help="Show sessions starting on or before this date (midnight values are expanded to end-of-day).",
501+
help="Show sessions starting before or at this timestamp cutoff (date-only values are expanded to end-of-day).",
443502
)
444503
@click.option(
445504
"--path",
@@ -451,7 +510,7 @@ def main(ctx: click.Context, path: Path | None) -> None:
451510
def summary(
452511
ctx: click.Context,
453512
since: datetime | None,
454-
until: datetime | None,
513+
until: _ParsedDateArg | None,
455514
path: Path | None,
456515
) -> None:
457516
"""Show usage summary across all sessions."""
@@ -533,9 +592,9 @@ def session(ctx: click.Context, session_id: str, path: Path | None) -> None:
533592
)
534593
@click.option(
535594
"--until",
536-
type=click.DateTime(formats=_DATE_FORMATS),
595+
type=_DateTimeOrDate(),
537596
default=None,
538-
help="Show sessions starting on or before this date (midnight values are expanded to end-of-day).",
597+
help="Show sessions starting before or at this timestamp cutoff (date-only values are expanded to end-of-day).",
539598
)
540599
@click.option(
541600
"--path",
@@ -547,7 +606,7 @@ def session(ctx: click.Context, session_id: str, path: Path | None) -> None:
547606
def cost(
548607
ctx: click.Context,
549608
since: datetime | None,
550-
until: datetime | None,
609+
until: _ParsedDateArg | None,
551610
path: Path | None,
552611
) -> None:
553612
"""Show premium request costs from shutdown data."""

0 commit comments

Comments
 (0)