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
26 changes: 13 additions & 13 deletions src/clockify_unofficial_cli/commands/entry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import dataclasses
import datetime
import datetime as dt
from dataclasses import dataclass
from typing import Annotated
from typing import Final
Expand Down Expand Up @@ -49,8 +49,8 @@ def _optional_tag_ids(app_context: AppContext, terms: tuple[str, ...] | None) ->
@dataclass(frozen=True, slots=True)
class _ListArgs:
description: str | None
start: datetime.datetime | None
end: datetime.datetime | None
start: dt.datetime | None
end: dt.datetime | None
project_id: ProjectId | None
task_id: TaskId | None
tag_ids: list[TagId] | None
Expand All @@ -66,10 +66,10 @@ class _ListOptions:
typer.Option("--description", help="Substring filter on the entry description."),
] = None
from_: Annotated[
datetime.datetime | None,
dt.datetime | None,
typer.Option("--from", help="Lower bound on start, e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
] = None
to: Annotated[datetime.datetime | None, typer.Option("--to", help="Upper bound on start.")] = None
to: Annotated[dt.datetime | None, typer.Option("--to", help="Upper bound on start.")] = None
project: Annotated[
str | None,
typer.Option("--project", "-P", help="Filter by project ID or exact name."),
Expand Down Expand Up @@ -154,8 +154,8 @@ class _CreateArgs:
task_id: TaskId | None
tag_ids: list[TagId] | None
billable: bool
start: datetime.datetime | None
end: datetime.datetime | None
start: dt.datetime | None
end: dt.datetime | None


@dataclass(frozen=True, slots=True)
Expand All @@ -175,10 +175,10 @@ class _CreateOptions:
typer.Option("--billable/--no-billable", help="Mark the entry as billable."),
] = False
from_: Annotated[
datetime.datetime | None,
dt.datetime | None,
typer.Option("--from", help="Start instant; e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
] = None
to: Annotated[datetime.datetime | None, typer.Option("--to", help="End instant.")] = None
to: Annotated[dt.datetime | None, typer.Option("--to", help="End instant.")] = None


@APP.command(help="Create a finished time entry (use `start` for a running entry).")
Expand Down Expand Up @@ -211,8 +211,8 @@ class _UpdateArgs:
task_id: TaskId | None
tag_ids: list[TagId] | None
billable: bool | None
start: datetime.datetime
end: datetime.datetime | None
start: dt.datetime
end: dt.datetime | None


@dataclass(frozen=True, slots=True)
Expand All @@ -230,10 +230,10 @@ class _UpdateOptions:
] = None
billable: Annotated[bool | None, typer.Option("--billable/--no-billable", help="Toggle billable.")] = None
from_: Annotated[
datetime.datetime | None,
dt.datetime | None,
typer.Option("--from", help="New start instant; required because Clockify PUT replaces the entry."),
] = None
to: Annotated[datetime.datetime | None, typer.Option("--to", help="New end instant.")] = None
to: Annotated[dt.datetime | None, typer.Option("--to", help="New end instant.")] = None


@APP.command(help="Update an existing time entry (start is required by the SDK).")
Expand Down
6 changes: 2 additions & 4 deletions src/clockify_unofficial_cli/commands/time_off_request.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import datetime
import datetime as dt
import functools
from dataclasses import dataclass
from typing import Annotated
Expand Down Expand Up @@ -91,9 +91,7 @@ class _CreateOptions:
user: Annotated[str | None, typer.Option("--user", help="Request on behalf of this user.")] = None


def _period(
start: datetime.date, end: datetime.date | None, half_day: TimeOffHalfDayPeriod | None
) -> TimeOffRequestPeriodRequest:
def _period(start: dt.date, end: dt.date | None, half_day: TimeOffHalfDayPeriod | None) -> TimeOffRequestPeriodRequest:
period = TimeOffPeriodRequest(start=start, end=end or start)
if half_day is None:
return TimeOffRequestPeriodRequest(period=period)
Expand Down
4 changes: 2 additions & 2 deletions src/clockify_unofficial_cli/services/expenses.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

import datetime
import datetime as dt
import mimetypes
from dataclasses import dataclass
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -91,7 +91,7 @@ def build_update(current: Expense, changes: ExpenseChanges) -> ExpenseUpdate:
return ExpenseUpdate(
user_id=current.user_id, # type: ignore[arg-type]
category_id=changes.category_id or current.category_id, # type: ignore[arg-type]
date=parse_instant(when) if when else datetime.datetime.now(datetime.UTC),
date=parse_instant(when) if when else dt.datetime.now(dt.UTC),
amount=changes.amount if changes.amount is not None else current.total or 0.0,
change_fields=changed,
**{key: value for key, value in optional.items() if value is not None}, # type: ignore[arg-type]
Expand Down
6 changes: 3 additions & 3 deletions src/clockify_unofficial_cli/services/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from clockify_unofficial_cli.runtime.exit_codes import ExitCode

if TYPE_CHECKING:
import datetime
import datetime as dt
from collections.abc import Sequence

from clockify import InvoiceDetails
Expand All @@ -41,8 +41,8 @@ def _required[T](value: T | None, option: str) -> T:
class InvoiceChanges:
number: str | None = None
currency: str | None = None
issued_date: datetime.datetime | None = None
due_date: datetime.datetime | None = None
issued_date: dt.datetime | None = None
due_date: dt.datetime | None = None
discount: float | None = None
tax: float | None = None
tax2: float | None = None
Expand Down
46 changes: 23 additions & 23 deletions src/clockify_unofficial_cli/services/parsing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

import datetime
import datetime as dt
import re
from typing import Final

Expand All @@ -19,7 +19,7 @@
_DAY_KEYWORDS: Final[frozenset[str]] = frozenset({"today", "tomorrow", "yesterday"})


def parse_duration(value: str) -> datetime.timedelta:
def parse_duration(value: str) -> dt.timedelta:
raw = value.strip()
if not raw:
message = f"Invalid duration '{value}'."
Expand All @@ -32,28 +32,28 @@ def parse_duration(value: str) -> datetime.timedelta:
raise CliError(message, exit_code=ExitCode.USAGE)


def _parse_compact_duration(value: str) -> datetime.timedelta:
def _parse_compact_duration(value: str) -> dt.timedelta:
match = _DURATION_RE.match(value)
if match is None:
message = f"Invalid duration '{value}'."
raise CliError(message, exit_code=ExitCode.USAGE)
return datetime.timedelta(
return dt.timedelta(
hours=int(match.group(1) or 0),
minutes=int(match.group(2) or 0),
seconds=int(match.group(3) or 0),
)


def _parse_clock_duration(value: str) -> datetime.timedelta:
def _parse_clock_duration(value: str) -> dt.timedelta:
match = _CLOCK_RE.match(value)
if match is None:
message = f"Invalid duration '{value}'."
raise CliError(message, exit_code=ExitCode.USAGE)
hours, minutes, seconds = match.groups()
return datetime.timedelta(hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0))
return dt.timedelta(hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0))


def parse_instant(value: str) -> datetime.datetime:
def parse_instant(value: str) -> dt.datetime:
raw = value.strip()
if not raw:
message = f"Invalid instant '{value}'."
Expand All @@ -69,22 +69,22 @@ def parse_instant(value: str) -> datetime.datetime:
return _resolve_absolute(raw)


def _today_at(time_str: str) -> datetime.datetime:
today = datetime.datetime.now(datetime.UTC).date()
def _today_at(time_str: str) -> dt.datetime:
today = dt.datetime.now(dt.UTC).date()
hour, minute = _parse_clock(time_str)
return datetime.datetime(today.year, today.month, today.day, hour, minute, tzinfo=datetime.UTC)
return dt.datetime(today.year, today.month, today.day, hour, minute, tzinfo=dt.UTC)


def _day_keyword_at(keyword: str, time_str: str) -> datetime.datetime:
today = datetime.datetime.now(datetime.UTC).date()
def _day_keyword_at(keyword: str, time_str: str) -> dt.datetime:
today = dt.datetime.now(dt.UTC).date()
if keyword == "today":
target = today
elif keyword == "tomorrow":
target = today + datetime.timedelta(days=1)
target = today + dt.timedelta(days=1)
else:
target = today - datetime.timedelta(days=1)
target = today - dt.timedelta(days=1)
hour, minute = _parse_clock(time_str)
return datetime.datetime(target.year, target.month, target.day, hour, minute, tzinfo=datetime.UTC)
return dt.datetime(target.year, target.month, target.day, hour, minute, tzinfo=dt.UTC)


def _parse_clock(value: str) -> tuple[int, int]:
Expand All @@ -96,26 +96,26 @@ def _parse_clock(value: str) -> tuple[int, int]:
return int(hours), int(minutes)


def _resolve_relative(value: str) -> datetime.datetime:
def _resolve_relative(value: str) -> dt.datetime:
sign, amount, unit = _RELATIVE_RE.match(value).groups() # type: ignore[union-attr]
delta = datetime.timedelta(hours=int(amount)) if unit == "h" else datetime.timedelta(minutes=int(amount))
delta = dt.timedelta(hours=int(amount)) if unit == "h" else dt.timedelta(minutes=int(amount))
if sign == "-":
delta = -delta
return datetime.datetime.now(datetime.UTC) + delta
return dt.datetime.now(dt.UTC) + delta


def _resolve_absolute(value: str) -> datetime.datetime:
def _resolve_absolute(value: str) -> dt.datetime:
try:
parsed = datetime.datetime.fromisoformat(value)
parsed = dt.datetime.fromisoformat(value)
except ValueError as exc:
message = f"Invalid instant '{value}'."
raise CliError(message, exit_code=ExitCode.USAGE) from exc
if parsed.tzinfo is None:
return parsed.replace(tzinfo=datetime.UTC)
return parsed.astimezone(datetime.UTC)
return parsed.replace(tzinfo=dt.UTC)
return parsed.astimezone(dt.UTC)


def parse_date(value: str) -> datetime.date:
def parse_date(value: str) -> dt.date:
return parse_instant(value).date()


Expand Down
44 changes: 21 additions & 23 deletions src/clockify_unofficial_cli/services/reports.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

import datetime
import datetime as dt
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -42,8 +42,8 @@ class Period(StrEnum):

@dataclass(frozen=True, slots=True)
class DateRange:
start: datetime.datetime
end: datetime.datetime
start: dt.datetime
end: dt.datetime


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -83,40 +83,40 @@ def fields(self) -> dict[str, Any]:
}


_ONE_SECOND: Final = datetime.timedelta(seconds=1)
_ONE_SECOND: Final = dt.timedelta(seconds=1)


def _start_of_day(moment: datetime.datetime) -> datetime.datetime:
def _start_of_day(moment: dt.datetime) -> dt.datetime:
return moment.replace(hour=0, minute=0, second=0, microsecond=0)


def _start_of_month(moment: datetime.datetime) -> datetime.datetime:
def _start_of_month(moment: dt.datetime) -> dt.datetime:
return _start_of_day(moment).replace(day=1)


def _next_month(first_of_month: datetime.datetime) -> datetime.datetime:
return (first_of_month + datetime.timedelta(days=32)).replace(day=1)
def _next_month(first_of_month: dt.datetime) -> dt.datetime:
return (first_of_month + dt.timedelta(days=32)).replace(day=1)


# The upper bound is the last second of the period, because Clockify treats dateRangeEnd as inclusive.
def _preset(period: Period, now: datetime.datetime) -> DateRange:
def _preset(period: Period, now: dt.datetime) -> DateRange:
today = _start_of_day(now)
monday = today - datetime.timedelta(days=today.weekday())
monday = today - dt.timedelta(days=today.weekday())
month = _start_of_month(now)
bounds: dict[Period, tuple[datetime.datetime, datetime.datetime]] = {
Period.TODAY: (today, today + datetime.timedelta(days=1)),
Period.YESTERDAY: (today - datetime.timedelta(days=1), today),
Period.THIS_WEEK: (monday, monday + datetime.timedelta(days=7)),
Period.LAST_WEEK: (monday - datetime.timedelta(days=7), monday),
bounds: dict[Period, tuple[dt.datetime, dt.datetime]] = {
Period.TODAY: (today, today + dt.timedelta(days=1)),
Period.YESTERDAY: (today - dt.timedelta(days=1), today),
Period.THIS_WEEK: (monday, monday + dt.timedelta(days=7)),
Period.LAST_WEEK: (monday - dt.timedelta(days=7), monday),
Period.THIS_MONTH: (month, _next_month(month)),
Period.LAST_MONTH: (_start_of_month(month - datetime.timedelta(days=1)), month),
Period.LAST_MONTH: (_start_of_month(month - dt.timedelta(days=1)), month),
}
start, end = bounds[period]
return DateRange(start=start.astimezone(datetime.UTC), end=(end - _ONE_SECOND).astimezone(datetime.UTC))
return DateRange(start=start.astimezone(dt.UTC), end=(end - _ONE_SECOND).astimezone(dt.UTC))


def resolve_range(selection: ReportSelection, *, now: datetime.datetime | None = None) -> DateRange:
local_now = now or datetime.datetime.now().astimezone()
def resolve_range(selection: ReportSelection, *, now: dt.datetime | None = None) -> DateRange:
local_now = now or dt.datetime.now().astimezone()
explicit = selection.start is not None or selection.end is not None
if selection.period is not None and explicit:
message = "--period cannot be combined with --from/--to."
Expand All @@ -127,7 +127,7 @@ def resolve_range(selection: ReportSelection, *, now: datetime.datetime | None =
message = "--to needs --from."
raise CliError(message, exit_code=ExitCode.USAGE)
start = parse_instant(selection.start)
end = parse_instant(selection.end) if selection.end is not None else local_now.astimezone(datetime.UTC)
end = parse_instant(selection.end) if selection.end is not None else local_now.astimezone(dt.UTC)
if end <= start:
message = "--to must be after --from."
raise CliError(message, exit_code=ExitCode.USAGE)
Expand All @@ -143,9 +143,7 @@ def _entity_filter(
return ReportEntityFilter(ids=ids, contains=ReportFilterContains.CONTAINS)


def build_query(
app_context: AppContext, selection: ReportSelection, *, now: datetime.datetime | None = None
) -> ReportQuery:
def build_query(app_context: AppContext, selection: ReportSelection, *, now: dt.datetime | None = None) -> ReportQuery:
date_range = resolve_range(selection, now=now)
candidates = {
"projects": _entity_filter(app_context, selection.projects, resolve_project),
Expand Down
Loading
Loading