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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,24 @@ entry = client.time_entries.create(
)
```

Start and stop running timers through the timer helpers. Omit `started_time` when
you want the API to start at the server's current time; `stop_timer()` calculates
elapsed duration server-side.

```python
from datetime import date

timer = client.time_entries.start_timer(
project_id="proj_123",
task_id="task_456",
spent_date=str(date.today()),
source=Source.AGENT,
replace_running=True,
)

client.time_entries.stop_timer(timer.id, notes="Finished implementation")
```

### Structured Agent Metadata

The `AgentMetadata.build()` helper produces a structured dict following the Keito metadata schema:
Expand Down Expand Up @@ -564,7 +582,7 @@ from keito.types import (
```python
from keito.types import Source, UserType, InvoiceState, PaymentTerm, ApprovalStatus

Source.WEB | Source.CLI | Source.API | Source.AGENT
Source.WEB | Source.CLI | Source.API | Source.AGENT | Source.CALENDAR | Source.DESKTOP
UserType.HUMAN | UserType.AGENT
InvoiceState.DRAFT | InvoiceState.OPEN | InvoiceState.PAID | InvoiceState.CLOSED
PaymentTerm.UPON_RECEIPT | PaymentTerm.NET_15 | PaymentTerm.NET_30 | ...
Expand Down
133 changes: 133 additions & 0 deletions src/keito/resources/time_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def create(
notes: Optional[str] = None,
billable: Optional[bool] = None,
is_running: Optional[bool] = None,
replace_running: Optional[bool] = None,
started_time: Optional[str] = None,
ended_time: Optional[str] = None,
source: Optional[Source] = None,
Expand All @@ -93,6 +94,7 @@ def create(
notes=notes,
billable=billable,
is_running=is_running,
replace_running=replace_running,
started_time=started_time,
ended_time=ended_time,
source=source,
Expand All @@ -106,6 +108,42 @@ def create(
)
return TimeEntry.model_validate(response.json())

def start_timer(
self,
*,
project_id: str,
task_id: str,
spent_date: str,
user_id: Optional[str] = None,
notes: Optional[str] = None,
billable: Optional[bool] = None,
started_time: Optional[str] = None,
source: Optional[Source] = None,
metadata: Optional[dict[str, Any]] = None,
replace_running: Optional[bool] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body = TimeEntryCreate(
project_id=project_id,
task_id=task_id,
spent_date=spent_date,
user_id=user_id,
notes=notes,
billable=billable,
is_running=True,
replace_running=replace_running,
started_time=started_time,
source=source,
metadata=metadata,
)
response = self._http.request(
"POST",
_PATH,
json=body.model_dump(exclude_none=True),
request_options=request_options,
)
return TimeEntry.model_validate(response.json())

def update(
self,
id: str,
Expand Down Expand Up @@ -140,6 +178,32 @@ def update(
)
return TimeEntry.model_validate(response.json())

def stop_timer(
self,
id: str,
*,
notes: Optional[str] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body: dict[str, Any] = {}
if notes is not None:
body["notes"] = notes
response = self._http.request("PATCH", f"{_PATH}/{id}/stop", json=body, request_options=request_options)
return TimeEntry.model_validate(response.json())

def restart_timer(
self,
id: str,
*,
replace_running: Optional[bool] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body: dict[str, Any] = {}
if replace_running is not None:
body["replace_running"] = replace_running
response = self._http.request("PATCH", f"{_PATH}/{id}/restart", json=body, request_options=request_options)
return TimeEntry.model_validate(response.json())

def delete(
self,
id: str,
Expand Down Expand Up @@ -215,6 +279,7 @@ async def create(
notes: Optional[str] = None,
billable: Optional[bool] = None,
is_running: Optional[bool] = None,
replace_running: Optional[bool] = None,
started_time: Optional[str] = None,
ended_time: Optional[str] = None,
source: Optional[Source] = None,
Expand All @@ -230,6 +295,7 @@ async def create(
notes=notes,
billable=billable,
is_running=is_running,
replace_running=replace_running,
started_time=started_time,
ended_time=ended_time,
source=source,
Expand All @@ -243,6 +309,42 @@ async def create(
)
return TimeEntry.model_validate(response.json())

async def start_timer(
self,
*,
project_id: str,
task_id: str,
spent_date: str,
user_id: Optional[str] = None,
notes: Optional[str] = None,
billable: Optional[bool] = None,
started_time: Optional[str] = None,
source: Optional[Source] = None,
metadata: Optional[dict[str, Any]] = None,
replace_running: Optional[bool] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body = TimeEntryCreate(
project_id=project_id,
task_id=task_id,
spent_date=spent_date,
user_id=user_id,
notes=notes,
billable=billable,
is_running=True,
replace_running=replace_running,
started_time=started_time,
source=source,
metadata=metadata,
)
response = await self._http.request(
"POST",
_PATH,
json=body.model_dump(exclude_none=True),
request_options=request_options,
)
return TimeEntry.model_validate(response.json())

async def update(
self,
id: str,
Expand Down Expand Up @@ -277,6 +379,37 @@ async def update(
)
return TimeEntry.model_validate(response.json())

async def stop_timer(
self,
id: str,
*,
notes: Optional[str] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body: dict[str, Any] = {}
if notes is not None:
body["notes"] = notes
response = await self._http.request("PATCH", f"{_PATH}/{id}/stop", json=body, request_options=request_options)
return TimeEntry.model_validate(response.json())

async def restart_timer(
self,
id: str,
*,
replace_running: Optional[bool] = None,
request_options: Optional[RequestOptions] = None,
) -> TimeEntry:
body: dict[str, Any] = {}
if replace_running is not None:
body["replace_running"] = replace_running
response = await self._http.request(
"PATCH",
f"{_PATH}/{id}/restart",
json=body,
request_options=request_options,
)
return TimeEntry.model_validate(response.json())

async def delete(
self,
id: str,
Expand Down
2 changes: 2 additions & 0 deletions src/keito/types/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Source(str, Enum):
CLI = "cli"
API = "api"
AGENT = "agent"
CALENDAR = "calendar"
DESKTOP = "desktop"


class UserType(str, Enum):
Expand Down
18 changes: 17 additions & 1 deletion src/keito/types/time_entry.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
from __future__ import annotations

import re
from datetime import date, datetime
from typing import Any, Optional

from pydantic import BaseModel
from pydantic import BaseModel, field_validator

from keito.types.common import IdName, Source

_TIME_OF_DAY_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")


def _validate_time_of_day(value: Optional[str]) -> Optional[str]:
if value is None:
return value
if not _TIME_OF_DAY_RE.fullmatch(value):
raise ValueError("time-of-day fields must use HH:mm in the workspace timezone")
return value


class TimeEntry(BaseModel):
model_config = {"frozen": True}
Expand Down Expand Up @@ -47,11 +58,14 @@ class TimeEntryCreate(BaseModel):
notes: Optional[str] = None
billable: Optional[bool] = None
is_running: Optional[bool] = None
replace_running: Optional[bool] = None
started_time: Optional[str] = None
ended_time: Optional[str] = None
source: Optional[Source] = None
metadata: Optional[dict[str, Any]] = None

_time_of_day = field_validator("started_time", "ended_time")(_validate_time_of_day)


class TimeEntryUpdate(BaseModel):
project_id: Optional[str] = None
Expand All @@ -63,3 +77,5 @@ class TimeEntryUpdate(BaseModel):
started_time: Optional[str] = None
ended_time: Optional[str] = None
metadata: Optional[dict[str, Any]] = None

_time_of_day = field_validator("started_time", "ended_time")(_validate_time_of_day)
36 changes: 35 additions & 1 deletion tests/test_async_resources.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Async resource tests for coverage of async code paths."""

import json

import pytest
from pytest_httpx import HTTPXMock

from keito import AsyncKeito, OutcomeTypes
from keito.types import ClientModel, Contact, Invoice, InvoiceMessage, Project, Task, TeamTimeResult
from keito.types import ClientModel, Contact, Invoice, InvoiceMessage, Project, Source, Task, TeamTimeResult

_BASE = "https://app.keito.io/api/v2"

Expand Down Expand Up @@ -279,3 +281,35 @@ async def test_async_outcomes(httpx_mock: HTTPXMock):
assert result.hours == 0
assert result.source.value == "agent"
await client.close()


@pytest.mark.asyncio
async def test_async_time_entry_timer_helpers(httpx_mock: HTTPXMock):
client = AsyncKeito(api_key="kto_test", account_id="acc_test")
running = {**_ENTRY_JSON, "id": "entry_running", "is_running": True, "hours": 0}
stopped = {**_ENTRY_JSON, "id": "entry_running", "is_running": False, "hours": 1.25}

httpx_mock.add_response(method="POST", url=f"{_BASE}/time_entries", json=running)
timer = await client.time_entries.start_timer(
project_id="proj_789",
task_id="task_012",
spent_date="2026-03-05",
source=Source.AGENT,
replace_running=True,
)
start_request = httpx_mock.get_request()
assert start_request is not None
start_body = json.loads(start_request.content)
assert start_body["is_running"] is True
assert start_body["replace_running"] is True
assert "hours" not in start_body
assert timer.is_running is True

httpx_mock.add_response(method="PATCH", url=f"{_BASE}/time_entries/entry_running/stop", json=stopped)
stopped_timer = await client.time_entries.stop_timer("entry_running", notes="Done")
stop_request = httpx_mock.get_requests()[-1]
assert stop_request is not None
assert json.loads(stop_request.content) == {"notes": "Done"}
assert stopped_timer.is_running is False

await client.close()
Loading
Loading