diff --git a/README.md b/README.md index 74a6d62..c64d7c5 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 | ... diff --git a/src/keito/resources/time_entries.py b/src/keito/resources/time_entries.py index 840d081..9f04d0b 100644 --- a/src/keito/resources/time_entries.py +++ b/src/keito/resources/time_entries.py @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/src/keito/types/common.py b/src/keito/types/common.py index 1f6fc83..36e7400 100644 --- a/src/keito/types/common.py +++ b/src/keito/types/common.py @@ -11,6 +11,8 @@ class Source(str, Enum): CLI = "cli" API = "api" AGENT = "agent" + CALENDAR = "calendar" + DESKTOP = "desktop" class UserType(str, Enum): diff --git a/src/keito/types/time_entry.py b/src/keito/types/time_entry.py index 8172476..bd333c3 100644 --- a/src/keito/types/time_entry.py +++ b/src/keito/types/time_entry.py @@ -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} @@ -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 @@ -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) diff --git a/tests/test_async_resources.py b/tests/test_async_resources.py index 6985d13..50c08dc 100644 --- a/tests/test_async_resources.py +++ b/tests/test_async_resources.py @@ -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" @@ -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() diff --git a/tests/test_time_entries.py b/tests/test_time_entries.py index 5abc8fb..9e54d1f 100644 --- a/tests/test_time_entries.py +++ b/tests/test_time_entries.py @@ -1,7 +1,10 @@ +import json + +import pytest from pytest_httpx import HTTPXMock from keito import Keito -from keito.types import Source, TimeEntry +from keito.types import Source, TimeEntry, TimeEntryCreate _BASE = "https://app.keito.io/api/v2/time_entries" @@ -43,10 +46,16 @@ def test_create_time_entry(httpx_mock: HTTPXMock, client: Keito): spent_date="2026-03-05", hours=1.5, notes="Test entry", + replace_running=True, source=Source.AGENT, metadata={"agent_id": "test-agent"}, ) + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["replace_running"] is True + assert isinstance(entry, TimeEntry) assert entry.id == "entry_123" assert entry.source == Source.AGENT @@ -64,6 +73,57 @@ def test_update_time_entry(httpx_mock: HTTPXMock, client: Keito): assert entry.hours == 2.0 +def test_start_timer_uses_running_create_without_hours(httpx_mock: HTTPXMock, client: Keito): + running = {**_ENTRY_JSON, "id": "entry_running", "hours": 0, "is_running": True} + httpx_mock.add_response(method="POST", url=_BASE, json=running) + + entry = client.time_entries.start_timer( + project_id="proj_789", + task_id="task_012", + spent_date="2026-03-05", + source=Source.AGENT, + metadata={"agent_id": "test-agent"}, + replace_running=True, + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["is_running"] is True + assert body["source"] == "agent" + assert body["replace_running"] is True + assert "hours" not in body + assert entry.id == "entry_running" + assert entry.is_running is True + + +def test_stop_timer_uses_stop_endpoint(httpx_mock: HTTPXMock, client: Keito): + stopped = {**_ENTRY_JSON, "is_running": False, "hours": 1.25} + httpx_mock.add_response(method="PATCH", url=f"{_BASE}/entry_123/stop", json=stopped) + + entry = client.time_entries.stop_timer("entry_123", notes="Completed review") + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body == {"notes": "Completed review"} + assert entry.is_running is False + assert entry.hours == 1.25 + + +def test_restart_timer_uses_restart_endpoint(httpx_mock: HTTPXMock, client: Keito): + running = {**_ENTRY_JSON, "is_running": True, "hours": 0} + httpx_mock.add_response(method="PATCH", url=f"{_BASE}/entry_123/restart", json=running) + + entry = client.time_entries.restart_timer("entry_123", replace_running=True) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body == {"replace_running": True} + assert entry.is_running is True + + def test_delete_time_entry(httpx_mock: HTTPXMock, client: Keito): httpx_mock.add_response(method="DELETE", url=f"{_BASE}/entry_123", status_code=204) @@ -82,7 +142,10 @@ def test_list_time_entries(httpx_mock: HTTPXMock, client: Keito): }, ) - entries = list(client.time_entries.list(source=Source.AGENT)) + entries = list(client.time_entries.list(source=Source.DESKTOP)) + request = httpx_mock.get_request() + assert request is not None + assert "source=desktop" in str(request.url) assert len(entries) == 1 assert entries[0].id == "entry_123" @@ -131,3 +194,15 @@ def test_create_sends_correct_headers(httpx_mock: HTTPXMock, client: Keito): assert request.headers["Authorization"] == "Bearer kto_test_key" assert request.headers["Keito-Account-Id"] == "acc_test_123" assert "keito-python/" in request.headers["User-Agent"] + + +def test_time_entry_create_validates_time_of_day(): + TimeEntryCreate(project_id="proj_789", task_id="task_012", spent_date="2026-03-05", started_time="09:30") + + with pytest.raises(ValueError): + TimeEntryCreate( + project_id="proj_789", + task_id="task_012", + spent_date="2026-03-05", + started_time="2026-03-05T09:30:00Z", + )