Skip to content

Commit ee00c03

Browse files
committed
feat: align timer API contract
1 parent ab76191 commit ee00c03

6 files changed

Lines changed: 283 additions & 5 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ entry = client.time_entries.create(
128128
)
129129
```
130130

131+
Start and stop running timers through the timer helpers. Omit `started_time` when
132+
you want the API to start at the server's current time; `stop_timer()` calculates
133+
elapsed duration server-side.
134+
135+
```python
136+
from datetime import date
137+
138+
timer = client.time_entries.start_timer(
139+
project_id="proj_123",
140+
task_id="task_456",
141+
spent_date=str(date.today()),
142+
source=Source.AGENT,
143+
replace_running=True,
144+
)
145+
146+
client.time_entries.stop_timer(timer.id, notes="Finished implementation")
147+
```
148+
131149
### Structured Agent Metadata
132150

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

567-
Source.WEB | Source.CLI | Source.API | Source.AGENT
585+
Source.WEB | Source.CLI | Source.API | Source.AGENT | Source.CALENDAR | Source.DESKTOP
568586
UserType.HUMAN | UserType.AGENT
569587
InvoiceState.DRAFT | InvoiceState.OPEN | InvoiceState.PAID | InvoiceState.CLOSED
570588
PaymentTerm.UPON_RECEIPT | PaymentTerm.NET_15 | PaymentTerm.NET_30 | ...

src/keito/resources/time_entries.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def create(
7878
notes: Optional[str] = None,
7979
billable: Optional[bool] = None,
8080
is_running: Optional[bool] = None,
81+
replace_running: Optional[bool] = None,
8182
started_time: Optional[str] = None,
8283
ended_time: Optional[str] = None,
8384
source: Optional[Source] = None,
@@ -93,6 +94,7 @@ def create(
9394
notes=notes,
9495
billable=billable,
9596
is_running=is_running,
97+
replace_running=replace_running,
9698
started_time=started_time,
9799
ended_time=ended_time,
98100
source=source,
@@ -106,6 +108,42 @@ def create(
106108
)
107109
return TimeEntry.model_validate(response.json())
108110

111+
def start_timer(
112+
self,
113+
*,
114+
project_id: str,
115+
task_id: str,
116+
spent_date: str,
117+
user_id: Optional[str] = None,
118+
notes: Optional[str] = None,
119+
billable: Optional[bool] = None,
120+
started_time: Optional[str] = None,
121+
source: Optional[Source] = None,
122+
metadata: Optional[dict[str, Any]] = None,
123+
replace_running: Optional[bool] = None,
124+
request_options: Optional[RequestOptions] = None,
125+
) -> TimeEntry:
126+
body = TimeEntryCreate(
127+
project_id=project_id,
128+
task_id=task_id,
129+
spent_date=spent_date,
130+
user_id=user_id,
131+
notes=notes,
132+
billable=billable,
133+
is_running=True,
134+
replace_running=replace_running,
135+
started_time=started_time,
136+
source=source,
137+
metadata=metadata,
138+
)
139+
response = self._http.request(
140+
"POST",
141+
_PATH,
142+
json=body.model_dump(exclude_none=True),
143+
request_options=request_options,
144+
)
145+
return TimeEntry.model_validate(response.json())
146+
109147
def update(
110148
self,
111149
id: str,
@@ -140,6 +178,32 @@ def update(
140178
)
141179
return TimeEntry.model_validate(response.json())
142180

181+
def stop_timer(
182+
self,
183+
id: str,
184+
*,
185+
notes: Optional[str] = None,
186+
request_options: Optional[RequestOptions] = None,
187+
) -> TimeEntry:
188+
body: dict[str, Any] = {}
189+
if notes is not None:
190+
body["notes"] = notes
191+
response = self._http.request("PATCH", f"{_PATH}/{id}/stop", json=body, request_options=request_options)
192+
return TimeEntry.model_validate(response.json())
193+
194+
def restart_timer(
195+
self,
196+
id: str,
197+
*,
198+
replace_running: Optional[bool] = None,
199+
request_options: Optional[RequestOptions] = None,
200+
) -> TimeEntry:
201+
body: dict[str, Any] = {}
202+
if replace_running is not None:
203+
body["replace_running"] = replace_running
204+
response = self._http.request("PATCH", f"{_PATH}/{id}/restart", json=body, request_options=request_options)
205+
return TimeEntry.model_validate(response.json())
206+
143207
def delete(
144208
self,
145209
id: str,
@@ -215,6 +279,7 @@ async def create(
215279
notes: Optional[str] = None,
216280
billable: Optional[bool] = None,
217281
is_running: Optional[bool] = None,
282+
replace_running: Optional[bool] = None,
218283
started_time: Optional[str] = None,
219284
ended_time: Optional[str] = None,
220285
source: Optional[Source] = None,
@@ -230,6 +295,7 @@ async def create(
230295
notes=notes,
231296
billable=billable,
232297
is_running=is_running,
298+
replace_running=replace_running,
233299
started_time=started_time,
234300
ended_time=ended_time,
235301
source=source,
@@ -243,6 +309,42 @@ async def create(
243309
)
244310
return TimeEntry.model_validate(response.json())
245311

312+
async def start_timer(
313+
self,
314+
*,
315+
project_id: str,
316+
task_id: str,
317+
spent_date: str,
318+
user_id: Optional[str] = None,
319+
notes: Optional[str] = None,
320+
billable: Optional[bool] = None,
321+
started_time: Optional[str] = None,
322+
source: Optional[Source] = None,
323+
metadata: Optional[dict[str, Any]] = None,
324+
replace_running: Optional[bool] = None,
325+
request_options: Optional[RequestOptions] = None,
326+
) -> TimeEntry:
327+
body = TimeEntryCreate(
328+
project_id=project_id,
329+
task_id=task_id,
330+
spent_date=spent_date,
331+
user_id=user_id,
332+
notes=notes,
333+
billable=billable,
334+
is_running=True,
335+
replace_running=replace_running,
336+
started_time=started_time,
337+
source=source,
338+
metadata=metadata,
339+
)
340+
response = await self._http.request(
341+
"POST",
342+
_PATH,
343+
json=body.model_dump(exclude_none=True),
344+
request_options=request_options,
345+
)
346+
return TimeEntry.model_validate(response.json())
347+
246348
async def update(
247349
self,
248350
id: str,
@@ -277,6 +379,37 @@ async def update(
277379
)
278380
return TimeEntry.model_validate(response.json())
279381

382+
async def stop_timer(
383+
self,
384+
id: str,
385+
*,
386+
notes: Optional[str] = None,
387+
request_options: Optional[RequestOptions] = None,
388+
) -> TimeEntry:
389+
body: dict[str, Any] = {}
390+
if notes is not None:
391+
body["notes"] = notes
392+
response = await self._http.request("PATCH", f"{_PATH}/{id}/stop", json=body, request_options=request_options)
393+
return TimeEntry.model_validate(response.json())
394+
395+
async def restart_timer(
396+
self,
397+
id: str,
398+
*,
399+
replace_running: Optional[bool] = None,
400+
request_options: Optional[RequestOptions] = None,
401+
) -> TimeEntry:
402+
body: dict[str, Any] = {}
403+
if replace_running is not None:
404+
body["replace_running"] = replace_running
405+
response = await self._http.request(
406+
"PATCH",
407+
f"{_PATH}/{id}/restart",
408+
json=body,
409+
request_options=request_options,
410+
)
411+
return TimeEntry.model_validate(response.json())
412+
280413
async def delete(
281414
self,
282415
id: str,

src/keito/types/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class Source(str, Enum):
1111
CLI = "cli"
1212
API = "api"
1313
AGENT = "agent"
14+
CALENDAR = "calendar"
15+
DESKTOP = "desktop"
1416

1517

1618
class UserType(str, Enum):

src/keito/types/time_entry.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
from __future__ import annotations
22

3+
import re
34
from datetime import date, datetime
45
from typing import Any, Optional
56

6-
from pydantic import BaseModel
7+
from pydantic import BaseModel, field_validator
78

89
from keito.types.common import IdName, Source
910

11+
_TIME_OF_DAY_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
12+
13+
14+
def _validate_time_of_day(value: Optional[str]) -> Optional[str]:
15+
if value is None:
16+
return value
17+
if not _TIME_OF_DAY_RE.fullmatch(value):
18+
raise ValueError("time-of-day fields must use HH:mm in the workspace timezone")
19+
return value
20+
1021

1122
class TimeEntry(BaseModel):
1223
model_config = {"frozen": True}
@@ -47,11 +58,14 @@ class TimeEntryCreate(BaseModel):
4758
notes: Optional[str] = None
4859
billable: Optional[bool] = None
4960
is_running: Optional[bool] = None
61+
replace_running: Optional[bool] = None
5062
started_time: Optional[str] = None
5163
ended_time: Optional[str] = None
5264
source: Optional[Source] = None
5365
metadata: Optional[dict[str, Any]] = None
5466

67+
_time_of_day = field_validator("started_time", "ended_time")(_validate_time_of_day)
68+
5569

5670
class TimeEntryUpdate(BaseModel):
5771
project_id: Optional[str] = None
@@ -63,3 +77,5 @@ class TimeEntryUpdate(BaseModel):
6377
started_time: Optional[str] = None
6478
ended_time: Optional[str] = None
6579
metadata: Optional[dict[str, Any]] = None
80+
81+
_time_of_day = field_validator("started_time", "ended_time")(_validate_time_of_day)

tests/test_async_resources.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Async resource tests for coverage of async code paths."""
22

3+
import json
4+
35
import pytest
46
from pytest_httpx import HTTPXMock
57

68
from keito import AsyncKeito, OutcomeTypes
7-
from keito.types import ClientModel, Contact, Invoice, InvoiceMessage, Project, Task, TeamTimeResult
9+
from keito.types import ClientModel, Contact, Invoice, InvoiceMessage, Project, Source, Task, TeamTimeResult
810

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

@@ -279,3 +281,35 @@ async def test_async_outcomes(httpx_mock: HTTPXMock):
279281
assert result.hours == 0
280282
assert result.source.value == "agent"
281283
await client.close()
284+
285+
286+
@pytest.mark.asyncio
287+
async def test_async_time_entry_timer_helpers(httpx_mock: HTTPXMock):
288+
client = AsyncKeito(api_key="kto_test", account_id="acc_test")
289+
running = {**_ENTRY_JSON, "id": "entry_running", "is_running": True, "hours": 0}
290+
stopped = {**_ENTRY_JSON, "id": "entry_running", "is_running": False, "hours": 1.25}
291+
292+
httpx_mock.add_response(method="POST", url=f"{_BASE}/time_entries", json=running)
293+
timer = await client.time_entries.start_timer(
294+
project_id="proj_789",
295+
task_id="task_012",
296+
spent_date="2026-03-05",
297+
source=Source.AGENT,
298+
replace_running=True,
299+
)
300+
start_request = httpx_mock.get_request()
301+
assert start_request is not None
302+
start_body = json.loads(start_request.content)
303+
assert start_body["is_running"] is True
304+
assert start_body["replace_running"] is True
305+
assert "hours" not in start_body
306+
assert timer.is_running is True
307+
308+
httpx_mock.add_response(method="PATCH", url=f"{_BASE}/time_entries/entry_running/stop", json=stopped)
309+
stopped_timer = await client.time_entries.stop_timer("entry_running", notes="Done")
310+
stop_request = httpx_mock.get_requests()[-1]
311+
assert stop_request is not None
312+
assert json.loads(stop_request.content) == {"notes": "Done"}
313+
assert stopped_timer.is_running is False
314+
315+
await client.close()

0 commit comments

Comments
 (0)