clockify-unofficial-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. clockify_unofficial_cli/__init__.py +3 -0
  2. clockify_unofficial_cli/__main__.py +3 -0
  3. clockify_unofficial_cli/_version.py +3 -0
  4. clockify_unofficial_cli/auth/__init__.py +0 -0
  5. clockify_unofficial_cli/auth/credentials.py +70 -0
  6. clockify_unofficial_cli/auth/env_store.py +26 -0
  7. clockify_unofficial_cli/auth/file_store.py +49 -0
  8. clockify_unofficial_cli/auth/keyring_store.py +62 -0
  9. clockify_unofficial_cli/auth/resolver.py +34 -0
  10. clockify_unofficial_cli/commands/__init__.py +0 -0
  11. clockify_unofficial_cli/commands/auth.py +153 -0
  12. clockify_unofficial_cli/commands/client.py +116 -0
  13. clockify_unofficial_cli/commands/config.py +50 -0
  14. clockify_unofficial_cli/commands/custom_field.py +130 -0
  15. clockify_unofficial_cli/commands/entry.py +275 -0
  16. clockify_unofficial_cli/commands/group.py +89 -0
  17. clockify_unofficial_cli/commands/project.py +174 -0
  18. clockify_unofficial_cli/commands/tag.py +107 -0
  19. clockify_unofficial_cli/commands/task.py +194 -0
  20. clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
  21. clockify_unofficial_cli/commands/user.py +45 -0
  22. clockify_unofficial_cli/commands/workspace.py +68 -0
  23. clockify_unofficial_cli/config/__init__.py +0 -0
  24. clockify_unofficial_cli/config/paths.py +25 -0
  25. clockify_unofficial_cli/config/settings.py +75 -0
  26. clockify_unofficial_cli/config/store.py +56 -0
  27. clockify_unofficial_cli/main.py +131 -0
  28. clockify_unofficial_cli/output/__init__.py +0 -0
  29. clockify_unofficial_cli/output/columns.py +88 -0
  30. clockify_unofficial_cli/output/formats.py +82 -0
  31. clockify_unofficial_cli/output/registry.py +29 -0
  32. clockify_unofficial_cli/output/renderer.py +68 -0
  33. clockify_unofficial_cli/runtime/__init__.py +0 -0
  34. clockify_unofficial_cli/runtime/client_factory.py +42 -0
  35. clockify_unofficial_cli/runtime/context.py +99 -0
  36. clockify_unofficial_cli/runtime/errors.py +89 -0
  37. clockify_unofficial_cli/runtime/exit_codes.py +15 -0
  38. clockify_unofficial_cli/runtime/params.py +39 -0
  39. clockify_unofficial_cli/runtime/prompts.py +24 -0
  40. clockify_unofficial_cli/services/__init__.py +0 -0
  41. clockify_unofficial_cli/services/listing.py +58 -0
  42. clockify_unofficial_cli/services/parsing.py +118 -0
  43. clockify_unofficial_cli/services/resolve.py +128 -0
  44. clockify_unofficial_cli/services/timer.py +126 -0
  45. clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
  46. clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
  47. clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
  48. clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. clockify_unofficial_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,15 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ # 1 and 2 are reserved by Click for generic failures and usage errors.
5
+ class ExitCode(IntEnum):
6
+ OK = 0
7
+ FAILURE = 1
8
+ USAGE = 2
9
+ CONFIGURATION = 3
10
+ AUTHENTICATION = 4
11
+ FORBIDDEN = 5
12
+ NOT_FOUND = 6
13
+ VALIDATION = 7
14
+ RATE_LIMITED = 8
15
+ UNAVAILABLE = 9
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import functools
5
+ import inspect
6
+ from typing import TYPE_CHECKING
7
+
8
+ import typer
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+
13
+
14
+ def _parameter(field: dataclasses.Field[object], annotation: object) -> inspect.Parameter:
15
+ default = inspect.Parameter.empty if field.default is dataclasses.MISSING else field.default
16
+ return inspect.Parameter(field.name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation)
17
+
18
+
19
+ # Typer builds a command from the callback's signature, so every option must be a parameter. This
20
+ # fabricates that signature from a dataclass and hands the callback one options object instead,
21
+ # which keeps commands within the argument limit without dropping any flag.
22
+ def options_from[T, R](options: type[T]) -> Callable[[Callable[[typer.Context, T], R]], Callable[..., R]]:
23
+ annotations = inspect.get_annotations(options, eval_str=True)
24
+ context = inspect.Parameter("ctx", inspect.Parameter.KEYWORD_ONLY, annotation=typer.Context)
25
+ fields = dataclasses.fields(options) # type: ignore[arg-type]
26
+ parameters = [context, *(_parameter(field, annotations[field.name]) for field in fields)]
27
+ signature = inspect.Signature(parameters)
28
+
29
+ def decorate(func: Callable[[typer.Context, T], R]) -> Callable[..., R]:
30
+ @functools.wraps(func)
31
+ def wrapper(*, ctx: typer.Context, **values: object) -> R:
32
+ return func(ctx, options(**values))
33
+
34
+ wrapper.__signature__ = signature # type: ignore[attr-defined]
35
+ wrapper.__annotations__ = {parameter.name: parameter.annotation for parameter in parameters}
36
+ del wrapper.__wrapped__
37
+ return wrapper
38
+
39
+ return decorate
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ import typer
6
+
7
+ from clockify_unofficial_cli.runtime.errors import CliError
8
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
9
+
10
+
11
+ # Single "are you sure?" prompt shared by every destructive command.
12
+ # TTYs get an interactive confirm; non-TTY callers must pass --yes. A "no" reply
13
+ # exits OK (the user explicitly cancelled, not a failure).
14
+ def confirm(action: str, *, assume_yes: bool) -> None:
15
+ if assume_yes:
16
+ return
17
+ if not sys.stdin.isatty():
18
+ message = f"{action} requires --yes when run non-interactively."
19
+ raise CliError(message, exit_code=ExitCode.USAGE)
20
+ if not typer.confirm(action, err=True):
21
+ raise typer.Exit(ExitCode.OK)
22
+
23
+
24
+ __all__ = ["confirm"]
File without changes
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from clockify_unofficial_cli.runtime.errors import CliError
8
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable
12
+ from collections.abc import Iterable
13
+ from collections.abc import Iterator
14
+
15
+ from clockify._pagination import Page
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ListOptions:
20
+ limit: int | None = None
21
+ page: int | None = None
22
+ page_size: int | None = None
23
+
24
+
25
+ def collect[T](
26
+ fetch_all: Callable[[], Iterator[T]],
27
+ fetch_page: Callable[..., Page[T]],
28
+ options: ListOptions,
29
+ ) -> Iterable[T]:
30
+ if options.page is not None or options.page_size is not None:
31
+ if options.limit is not None:
32
+ message = "--limit cannot be combined with --page/--page-size."
33
+ raise CliError(message, exit_code=ExitCode.USAGE)
34
+ return _paginated(fetch_page, options)
35
+ if options.limit is None:
36
+ return fetch_all()
37
+ return itertools.islice(fetch_all(), options.limit)
38
+
39
+
40
+ def _paginated[T](
41
+ fetch_page: Callable[..., Page[T]],
42
+ options: ListOptions,
43
+ ) -> Iterable[T]:
44
+ page = options.page if options.page is not None else 1
45
+ return fetch_page(page=page, page_size=options.page_size).items
46
+
47
+
48
+ def list_from_options[T](
49
+ fetch_all: Callable[[], Iterator[T]],
50
+ fetch_page: Callable[..., Page[T]],
51
+ limit: int | None,
52
+ page: int | None,
53
+ page_size: int | None,
54
+ ) -> Iterable[T]:
55
+ return collect(fetch_all, fetch_page, ListOptions(limit=limit, page=page, page_size=page_size))
56
+
57
+
58
+ __all__ = ["ListOptions", "collect", "list_from_options"]
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import re
5
+ from typing import Final
6
+
7
+ from clockify_unofficial_cli.runtime.errors import CliError
8
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
9
+
10
+ _DURATION_PATTERN: Final[str] = r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$"
11
+ _DURATION_RE: Final[re.Pattern[str]] = re.compile(_DURATION_PATTERN)
12
+
13
+ _CLOCK_PATTERN: Final[str] = r"^(\d{1,3}):(\d{2})(?::(\d{2}))?$"
14
+ _CLOCK_RE: Final[re.Pattern[str]] = re.compile(_CLOCK_PATTERN)
15
+
16
+ _RELATIVE_PATTERN: Final[str] = r"^([+-])(\d+)([hm])$"
17
+ _RELATIVE_RE: Final[re.Pattern[str]] = re.compile(_RELATIVE_PATTERN)
18
+
19
+ _DAY_KEYWORDS: Final[frozenset[str]] = frozenset({"today", "tomorrow", "yesterday"})
20
+
21
+
22
+ def parse_duration(value: str) -> datetime.timedelta:
23
+ raw = value.strip()
24
+ if not raw:
25
+ message = f"Invalid duration '{value}'."
26
+ raise CliError(message, exit_code=ExitCode.USAGE)
27
+ if ":" in raw:
28
+ return _parse_clock_duration(raw)
29
+ if _DURATION_RE.match(raw) and any(raw.endswith(unit) for unit in ("h", "m", "s")):
30
+ return _parse_compact_duration(raw)
31
+ message = f"Invalid duration '{value}'."
32
+ raise CliError(message, exit_code=ExitCode.USAGE)
33
+
34
+
35
+ def _parse_compact_duration(value: str) -> datetime.timedelta:
36
+ match = _DURATION_RE.match(value)
37
+ if match is None:
38
+ message = f"Invalid duration '{value}'."
39
+ raise CliError(message, exit_code=ExitCode.USAGE)
40
+ return datetime.timedelta(
41
+ hours=int(match.group(1) or 0),
42
+ minutes=int(match.group(2) or 0),
43
+ seconds=int(match.group(3) or 0),
44
+ )
45
+
46
+
47
+ def _parse_clock_duration(value: str) -> datetime.timedelta:
48
+ match = _CLOCK_RE.match(value)
49
+ if match is None:
50
+ message = f"Invalid duration '{value}'."
51
+ raise CliError(message, exit_code=ExitCode.USAGE)
52
+ hours, minutes, seconds = match.groups()
53
+ return datetime.timedelta(hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0))
54
+
55
+
56
+ def parse_instant(value: str) -> datetime.datetime:
57
+ raw = value.strip()
58
+ if not raw:
59
+ message = f"Invalid instant '{value}'."
60
+ raise CliError(message, exit_code=ExitCode.USAGE)
61
+ if _CLOCK_RE.match(raw):
62
+ return _today_at(raw)
63
+ head, _, tail = raw.partition(" ")
64
+ lowered_head = head.casefold()
65
+ if lowered_head in _DAY_KEYWORDS:
66
+ return _day_keyword_at(lowered_head, tail or "00:00")
67
+ if _RELATIVE_RE.match(raw):
68
+ return _resolve_relative(raw)
69
+ return _resolve_absolute(raw)
70
+
71
+
72
+ def _today_at(time_str: str) -> datetime.datetime:
73
+ today = datetime.datetime.now(datetime.UTC).date()
74
+ hour, minute = _parse_clock(time_str)
75
+ return datetime.datetime(today.year, today.month, today.day, hour, minute, tzinfo=datetime.UTC)
76
+
77
+
78
+ def _day_keyword_at(keyword: str, time_str: str) -> datetime.datetime:
79
+ today = datetime.datetime.now(datetime.UTC).date()
80
+ if keyword == "today":
81
+ target = today
82
+ elif keyword == "tomorrow":
83
+ target = today + datetime.timedelta(days=1)
84
+ else:
85
+ target = today - datetime.timedelta(days=1)
86
+ hour, minute = _parse_clock(time_str)
87
+ return datetime.datetime(target.year, target.month, target.day, hour, minute, tzinfo=datetime.UTC)
88
+
89
+
90
+ def _parse_clock(value: str) -> tuple[int, int]:
91
+ match = _CLOCK_RE.match(value.strip())
92
+ if match is None:
93
+ message = f"Invalid clock time '{value}'."
94
+ raise CliError(message, exit_code=ExitCode.USAGE)
95
+ hours, minutes, _ = match.groups()
96
+ return int(hours), int(minutes)
97
+
98
+
99
+ def _resolve_relative(value: str) -> datetime.datetime:
100
+ sign, amount, unit = _RELATIVE_RE.match(value).groups() # type: ignore[union-attr]
101
+ delta = datetime.timedelta(hours=int(amount)) if unit == "h" else datetime.timedelta(minutes=int(amount))
102
+ if sign == "-":
103
+ delta = -delta
104
+ return datetime.datetime.now(datetime.UTC) + delta
105
+
106
+
107
+ def _resolve_absolute(value: str) -> datetime.datetime:
108
+ try:
109
+ parsed = datetime.datetime.fromisoformat(value)
110
+ except ValueError as exc:
111
+ message = f"Invalid instant '{value}'."
112
+ raise CliError(message, exit_code=ExitCode.USAGE) from exc
113
+ if parsed.tzinfo is None:
114
+ return parsed.replace(tzinfo=datetime.UTC)
115
+ return parsed.astimezone(datetime.UTC)
116
+
117
+
118
+ __all__ = ["parse_duration", "parse_instant"]
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+ from typing import Any
6
+ from typing import Final
7
+
8
+ from clockify_unofficial_cli.runtime.errors import CliError
9
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+ from collections.abc import Iterable
14
+
15
+ from clockify import ProjectId
16
+
17
+ from clockify_unofficial_cli.runtime.context import AppContext
18
+
19
+
20
+ _HEX_CHARS: Final[frozenset[str]] = frozenset("0123456789abcdef")
21
+ _ID_LENGTH: Final = 24
22
+
23
+
24
+ def _looks_like_id(term: str) -> bool:
25
+ return len(term) == _ID_LENGTH and all(character in _HEX_CHARS for character in term.lower())
26
+
27
+
28
+ def _render_candidates(candidates: Iterable[str]) -> str:
29
+ rendered = ", ".join(candidates)
30
+ return f"candidates: {rendered}"
31
+
32
+
33
+ # Pattern shared by every resolve_* helper: pass a term that is already an ID,
34
+ # an exact case-insensitive name match, or surface a clear error.
35
+ @dataclass(frozen=True, slots=True)
36
+ class Candidate:
37
+ id: str
38
+ name: str
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class _Resolver:
43
+ candidates: list[Candidate]
44
+ noun: str
45
+
46
+ def resolve(self, term: str) -> str:
47
+ if _looks_like_id(term):
48
+ return term
49
+ lowered = term.casefold()
50
+ matches = [candidate for candidate in self.candidates if candidate.name.casefold() == lowered]
51
+ if len(matches) == 1:
52
+ return matches[0].id
53
+ if not matches:
54
+ message = f"Unknown {self.noun} '{term}'."
55
+ raise CliError(message, exit_code=ExitCode.NOT_FOUND)
56
+ ids = [candidate.id for candidate in matches]
57
+ message = f"Ambiguous {self.noun} '{term}'; {_render_candidates(ids)}."
58
+ raise CliError(message, exit_code=ExitCode.USAGE)
59
+
60
+
61
+ def _candidate_ids_from(items: Iterable[Any], *, name: Callable[[Any], str]) -> list[Candidate]:
62
+ return [Candidate(id=str(item.id), name=name(item)) for item in items]
63
+
64
+
65
+ def resolve(term: str, candidates: list[Candidate], *, noun: str) -> str:
66
+ return _Resolver(candidates=candidates, noun=noun).resolve(term)
67
+
68
+
69
+ def resolve_client(app_context: AppContext, term: str) -> str:
70
+ workspace = app_context.workspace()
71
+ candidates = _candidate_ids_from(workspace.clients.list(), name=lambda item: item.name)
72
+ return resolve(term, candidates, noun="client")
73
+
74
+
75
+ def resolve_project(app_context: AppContext, term: str) -> str:
76
+ workspace = app_context.workspace()
77
+ candidates = _candidate_ids_from(workspace.projects.list(), name=lambda item: item.name)
78
+ return resolve(term, candidates, noun="project")
79
+
80
+
81
+ def resolve_tag(app_context: AppContext, term: str) -> str:
82
+ workspace = app_context.workspace()
83
+ candidates = _candidate_ids_from(workspace.tags.list(), name=lambda item: item.name)
84
+ return resolve(term, candidates, noun="tag")
85
+
86
+
87
+ def resolve_custom_field(app_context: AppContext, term: str) -> str:
88
+ workspace = app_context.workspace()
89
+ candidates = _candidate_ids_from(workspace.custom_fields.list(), name=lambda item: item.name)
90
+ return resolve(term, candidates, noun="custom field")
91
+
92
+
93
+ def resolve_group(app_context: AppContext, term: str) -> str:
94
+ workspace = app_context.workspace()
95
+ candidates = _candidate_ids_from(workspace.user_groups.list(), name=lambda item: item.name)
96
+ return resolve(term, candidates, noun="group")
97
+
98
+
99
+ def resolve_task(app_context: AppContext, project_id: ProjectId, term: str) -> str:
100
+ workspace = app_context.workspace()
101
+ candidates = _candidate_ids_from(workspace.tasks.list(project_id), name=lambda item: item.name)
102
+ return resolve(term, candidates, noun="task")
103
+
104
+
105
+ def resolve_user(app_context: AppContext, term: str) -> str:
106
+ workspace = app_context.workspace()
107
+ candidates = _candidate_ids_from(
108
+ workspace.users.list(),
109
+ name=lambda item: item.email or item.name,
110
+ )
111
+ by_name = [
112
+ Candidate(id=str(item.id), name=item.name) for item in workspace.users.list() if item.email != item.name
113
+ ]
114
+ candidates.extend(by_name)
115
+ return resolve(term, candidates, noun="user")
116
+
117
+
118
+ __all__ = [
119
+ "Candidate",
120
+ "resolve",
121
+ "resolve_client",
122
+ "resolve_custom_field",
123
+ "resolve_group",
124
+ "resolve_project",
125
+ "resolve_tag",
126
+ "resolve_task",
127
+ "resolve_user",
128
+ ]
@@ -0,0 +1,126 @@
1
+ import datetime
2
+ from dataclasses import dataclass
3
+ from typing import TYPE_CHECKING
4
+
5
+ from clockify import TimeEntryCreate
6
+ from clockify import TimeEntryFilter
7
+
8
+ from clockify_unofficial_cli.runtime.errors import CliError
9
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
10
+ from clockify_unofficial_cli.services.parsing import parse_duration
11
+ from clockify_unofficial_cli.services.parsing import parse_instant
12
+
13
+ if TYPE_CHECKING:
14
+ from clockify import ProjectId
15
+ from clockify import TagId
16
+ from clockify import TaskId
17
+
18
+ from clockify_unofficial_cli.runtime.context import AppContext
19
+
20
+
21
+ # Per-invocation bundle so the timer service can build a Clockify payload without
22
+ # growing a long positional argument list every time a new flag appears.
23
+ @dataclass(frozen=True, slots=True)
24
+ class TimerRequest:
25
+ description: str | None
26
+ project_id: ProjectId | None
27
+ task_id: TaskId | None
28
+ tag_ids: tuple[TagId, ...]
29
+ billable: bool
30
+ start: datetime.datetime | None
31
+ end: datetime.datetime | None
32
+ duration: datetime.timedelta | None
33
+
34
+
35
+ def _now() -> datetime.datetime:
36
+ return datetime.datetime.now(datetime.UTC)
37
+
38
+
39
+ def start_timer(app_context: AppContext, request: TimerRequest) -> object:
40
+ resolved_start = request.start if request.start is not None else _now()
41
+ payload = TimeEntryCreate(
42
+ start=resolved_start,
43
+ end=request.end,
44
+ billable=request.billable or None,
45
+ description=request.description,
46
+ project_id=request.project_id,
47
+ task_id=request.task_id,
48
+ tag_ids=list(request.tag_ids) if request.tag_ids else None,
49
+ )
50
+ return app_context.workspace().time_entries.start(app_context.user_id(), payload)
51
+
52
+
53
+ def stop_timer(app_context: AppContext, *, end: datetime.datetime | None) -> object:
54
+ return app_context.workspace().time_entries.stop(app_context.user_id(), end=end)
55
+
56
+
57
+ def running_entry(app_context: AppContext) -> object | None:
58
+ entries = list(
59
+ app_context.workspace().time_entries.list(
60
+ app_context.user_id(),
61
+ entry_filter=TimeEntryFilter(in_progress=True),
62
+ )
63
+ )
64
+ return entries[0] if entries else None
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class LogRequest:
69
+ description: str | None
70
+ project_id: ProjectId | None
71
+ task_id: TaskId | None
72
+ tag_ids: tuple[TagId, ...]
73
+ billable: bool
74
+ start: datetime.datetime | None
75
+ end: datetime.datetime | None
76
+ duration: datetime.timedelta | None
77
+
78
+
79
+ def log_entry(
80
+ app_context: AppContext,
81
+ request: LogRequest,
82
+ ) -> object:
83
+ if request.start is not None and request.end is not None and request.duration is None:
84
+ resolved_start, resolved_end = request.start, request.end
85
+ elif request.start is not None and request.duration is not None and request.end is None:
86
+ resolved_start, resolved_end = request.start, request.start + request.duration
87
+ elif request.end is not None and request.duration is not None and request.start is None:
88
+ resolved_start, resolved_end = request.end - request.duration, request.end
89
+ elif request.duration is not None and request.start is None and request.end is None:
90
+ resolved_start, resolved_end = _now() - request.duration, _now()
91
+ else:
92
+ message = "`log` needs --from/--to or --duration (exactly one pairing)."
93
+ raise CliError(message, exit_code=ExitCode.USAGE)
94
+ payload = TimeEntryCreate(
95
+ start=resolved_start,
96
+ end=resolved_end,
97
+ billable=request.billable or None,
98
+ description=request.description,
99
+ project_id=request.project_id,
100
+ task_id=request.task_id,
101
+ tag_ids=list(request.tag_ids) if request.tag_ids else None,
102
+ )
103
+ return app_context.workspace().time_entries.create(payload)
104
+
105
+
106
+ def parse_optional_duration(value: str | None) -> datetime.timedelta | None:
107
+ if value is None:
108
+ return None
109
+ return parse_duration(value)
110
+
111
+
112
+ def parse_optional_instant(value: str | None) -> datetime.datetime | None:
113
+ if value is None:
114
+ return None
115
+ return parse_instant(value)
116
+
117
+
118
+ __all__ = [
119
+ "TimerRequest",
120
+ "log_entry",
121
+ "parse_optional_duration",
122
+ "parse_optional_instant",
123
+ "running_entry",
124
+ "start_timer",
125
+ "stop_timer",
126
+ ]