docket-client 0.3.2__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 (188) hide show
  1. docket/__init__.py +8 -0
  2. docket/api/__init__.py +1 -0
  3. docket/api/access/__init__.py +1 -0
  4. docket/api/access/add_principal.py +193 -0
  5. docket/api/access/archive_role_assignment.py +182 -0
  6. docket/api/access/create_api_token.py +198 -0
  7. docket/api/access/create_application_principal.py +188 -0
  8. docket/api/access/create_role_assignment.py +194 -0
  9. docket/api/access/list_api_tokens.py +184 -0
  10. docket/api/access/list_principals.py +184 -0
  11. docket/api/access/list_role_assignments.py +200 -0
  12. docket/api/access/revoke_api_token.py +173 -0
  13. docket/api/identity/__init__.py +1 -0
  14. docket/api/identity/create_my_api_token.py +206 -0
  15. docket/api/identity/get_me.py +127 -0
  16. docket/api/identity/list_my_api_tokens.py +151 -0
  17. docket/api/identity/revoke_my_api_token.py +178 -0
  18. docket/api/recurring_acquisitions/__init__.py +1 -0
  19. docket/api/recurring_acquisitions/archive_recurring_acquisition.py +207 -0
  20. docket/api/recurring_acquisitions/create_recurring_acquisition.py +218 -0
  21. docket/api/recurring_acquisitions/get_recurring_acquisition.py +163 -0
  22. docket/api/recurring_acquisitions/list_recurring_acquisitions.py +248 -0
  23. docket/api/recurring_acquisitions/preview_recurring_acquisition.py +187 -0
  24. docket/api/recurring_acquisitions/preview_split_recurring_acquisition.py +232 -0
  25. docket/api/recurring_acquisitions/split_recurring_acquisition.py +258 -0
  26. docket/api/runs/__init__.py +1 -0
  27. docket/api/runs/get_run.py +160 -0
  28. docket/api/runs/list_runs.py +180 -0
  29. docket/api/runs/report_run_outcome.py +206 -0
  30. docket/api/scheduled_acquisitions/__init__.py +1 -0
  31. docket/api/scheduled_acquisitions/archive_scheduled_acquisition.py +203 -0
  32. docket/api/scheduled_acquisitions/create_scheduled_acquisition.py +185 -0
  33. docket/api/scheduled_acquisitions/get_resolved_settings.py +169 -0
  34. docket/api/scheduled_acquisitions/get_scheduled_acquisition.py +159 -0
  35. docket/api/scheduled_acquisitions/list_active_acquisitions.py +297 -0
  36. docket/api/scheduled_acquisitions/list_runs_for_scheduled_acquisition.py +191 -0
  37. docket/api/scheduled_acquisitions/list_scheduled_acquisitions.py +332 -0
  38. docket/api/scheduled_acquisitions/preview_scheduled_acquisition_edit.py +224 -0
  39. docket/api/scheduled_acquisitions/set_planning_status.py +199 -0
  40. docket/api/scheduled_acquisitions/start_run.py +221 -0
  41. docket/api/scheduled_acquisitions/update_scheduled_acquisition.py +226 -0
  42. docket/api/settings/__init__.py +1 -0
  43. docket/api/settings/list_behavior_curriculum_discriminator_values.py +188 -0
  44. docket/api/settings/list_behavior_curriculum_settings.py +199 -0
  45. docket/api/settings/list_fip_discriminator_values.py +184 -0
  46. docket/api/settings/list_fip_settings.py +195 -0
  47. docket/api/settings/list_live_behavior_curriculum_settings.py +233 -0
  48. docket/api/settings/list_live_fip_settings.py +225 -0
  49. docket/api/settings/preview_behavior_curriculum_settings.py +191 -0
  50. docket/api/settings/preview_fip_settings.py +177 -0
  51. docket/api/settings/push_behavior_curriculum_settings.py +222 -0
  52. docket/api/settings/push_fip_settings.py +206 -0
  53. docket/api/templates/__init__.py +1 -0
  54. docket/api/templates/archive_template.py +203 -0
  55. docket/api/templates/create_template.py +206 -0
  56. docket/api/templates/get_template.py +159 -0
  57. docket/api/templates/list_templates.py +174 -0
  58. docket/api/templates/update_template.py +234 -0
  59. docket/api/vocabulary/__init__.py +1 -0
  60. docket/api/vocabulary/list_instruments.py +165 -0
  61. docket/api/vocabulary/list_persons.py +132 -0
  62. docket/api/vocabulary/list_projects.py +132 -0
  63. docket/api/vocabulary/list_subjects.py +132 -0
  64. docket/api/vocabulary/lookup_person.py +166 -0
  65. docket/api/vocabulary/lookup_subject.py +166 -0
  66. docket/api/vocabulary/sync_vocabulary.py +148 -0
  67. docket/client.py +272 -0
  68. docket/errors.py +16 -0
  69. docket/models/__init__.py +221 -0
  70. docket/models/access_role.py +12 -0
  71. docket/models/access_role_nullable_type_1.py +12 -0
  72. docket/models/access_role_nullable_type_2_type_1.py +12 -0
  73. docket/models/access_role_nullable_type_3_type_1.py +12 -0
  74. docket/models/acquisition_variable.py +72 -0
  75. docket/models/acquisition_warning.py +126 -0
  76. docket/models/add_principal_request.py +83 -0
  77. docket/models/api_token_response.py +271 -0
  78. docket/models/api_token_status.py +10 -0
  79. docket/models/archive_recurring_acquisition_request.py +77 -0
  80. docket/models/archive_recurring_acquisition_response.py +83 -0
  81. docket/models/archive_scheduled_acquisition_request.py +78 -0
  82. docket/models/archive_template_request.py +66 -0
  83. docket/models/auth_mode.py +10 -0
  84. docket/models/behavior_curriculum_push_settings_request.py +85 -0
  85. docket/models/behavior_curriculum_settings.py +55 -0
  86. docket/models/behavior_curriculum_settings_preview_response.py +102 -0
  87. docket/models/behavior_curriculum_settings_preview_response_promoted.py +50 -0
  88. docket/models/behavior_curriculum_settings_version_response.py +160 -0
  89. docket/models/behavior_curriculum_settings_version_response_promoted.py +50 -0
  90. docket/models/caller_kind.py +11 -0
  91. docket/models/create_api_token_request.py +97 -0
  92. docket/models/create_application_principal_request.py +65 -0
  93. docket/models/create_personal_api_token_request.py +167 -0
  94. docket/models/create_recurring_acquisition_request.py +269 -0
  95. docket/models/create_role_assignment_request.py +99 -0
  96. docket/models/create_scheduled_acquisition_request.py +265 -0
  97. docket/models/create_template_request.py +323 -0
  98. docket/models/created_api_token_response.py +82 -0
  99. docket/models/effective_status.py +15 -0
  100. docket/models/fip_color.py +13 -0
  101. docket/models/fip_fiber.py +93 -0
  102. docket/models/fip_mode.py +9 -0
  103. docket/models/fip_push_settings_request.py +82 -0
  104. docket/models/fip_settings.py +91 -0
  105. docket/models/fip_settings_preview_response.py +97 -0
  106. docket/models/fip_settings_preview_response_promoted.py +50 -0
  107. docket/models/fip_settings_version_response.py +155 -0
  108. docket/models/fip_settings_version_response_promoted.py +50 -0
  109. docket/models/future_occurrences.py +9 -0
  110. docket/models/get_resolved_settings_response_200.py +65 -0
  111. docket/models/held_role_response.py +116 -0
  112. docket/models/http_validation_problem_details.py +204 -0
  113. docket/models/http_validation_problem_details_errors.py +59 -0
  114. docket/models/instrument_response.py +91 -0
  115. docket/models/kept_occurrence.py +85 -0
  116. docket/models/kept_occurrence_reason.py +10 -0
  117. docket/models/list_active_acquisitions_effective_status_item.py +15 -0
  118. docket/models/list_scheduled_acquisitions_planning_status.py +11 -0
  119. docket/models/me_caller.py +118 -0
  120. docket/models/me_permission.py +98 -0
  121. docket/models/me_response.py +158 -0
  122. docket/models/me_role.py +75 -0
  123. docket/models/occurrence_preview.py +121 -0
  124. docket/models/occurrence_summary.py +126 -0
  125. docket/models/occurrence_warnings.py +91 -0
  126. docket/models/permission_action.py +12 -0
  127. docket/models/person_assignment.py +74 -0
  128. docket/models/person_lookup_response.py +131 -0
  129. docket/models/person_response.py +103 -0
  130. docket/models/person_role.py +9 -0
  131. docket/models/personal_token_limits.py +92 -0
  132. docket/models/planning_status.py +11 -0
  133. docket/models/principal_kind.py +9 -0
  134. docket/models/principal_response.py +216 -0
  135. docket/models/problem_details.py +177 -0
  136. docket/models/project_response.py +75 -0
  137. docket/models/recurrence_exception_kind.py +13 -0
  138. docket/models/recurrence_exception_response.py +103 -0
  139. docket/models/recurrence_frequency.py +9 -0
  140. docket/models/recurrence_preview_response.py +101 -0
  141. docket/models/recurrence_rule.py +207 -0
  142. docket/models/recurring_acquisition_created_response.py +123 -0
  143. docket/models/recurring_acquisition_detail_response.py +105 -0
  144. docket/models/recurring_acquisition_response.py +326 -0
  145. docket/models/report_run_outcome_request.py +89 -0
  146. docket/models/resolution_mode.py +10 -0
  147. docket/models/resolved_settings.py +73 -0
  148. docket/models/role_assignment_response.py +157 -0
  149. docket/models/role_origin.py +10 -0
  150. docket/models/role_source_kind.py +10 -0
  151. docket/models/run_origin.py +9 -0
  152. docket/models/run_outcome.py +9 -0
  153. docket/models/run_response.py +181 -0
  154. docket/models/run_settings_snapshot.py +72 -0
  155. docket/models/run_start_response.py +91 -0
  156. docket/models/run_start_response_settings.py +65 -0
  157. docket/models/run_status.py +11 -0
  158. docket/models/scheduled_acquisition_edit_preview_response.py +150 -0
  159. docket/models/scheduled_acquisition_response.py +366 -0
  160. docket/models/set_planning_status_request.py +90 -0
  161. docket/models/settings_selector.py +138 -0
  162. docket/models/skipped_occurrence.py +85 -0
  163. docket/models/split_preview_response.py +129 -0
  164. docket/models/split_recurring_acquisition_request.py +261 -0
  165. docket/models/split_recurring_acquisition_response.py +159 -0
  166. docket/models/start_run_request.py +73 -0
  167. docket/models/subject_lookup_response.py +149 -0
  168. docket/models/subject_response.py +128 -0
  169. docket/models/sync_counts.py +80 -0
  170. docket/models/sync_response.py +81 -0
  171. docket/models/template_response.py +302 -0
  172. docket/models/update_scheduled_acquisition_request.py +235 -0
  173. docket/models/update_template_request.py +279 -0
  174. docket/models/weekday.py +14 -0
  175. docket/types.py +54 -0
  176. docket_auth/__init__.py +239 -0
  177. docket_client-0.3.2.dist-info/METADATA +233 -0
  178. docket_client-0.3.2.dist-info/RECORD +188 -0
  179. docket_client-0.3.2.dist-info/WHEEL +4 -0
  180. docket_client-0.3.2.dist-info/entry_points.txt +2 -0
  181. docket_mcp/__init__.py +6 -0
  182. docket_mcp/api.py +207 -0
  183. docket_mcp/config.py +57 -0
  184. docket_mcp/server.py +78 -0
  185. docket_mcp/signin.py +155 -0
  186. docket_mcp/slots.py +80 -0
  187. docket_mcp/times.py +160 -0
  188. docket_mcp/tools.py +1202 -0
docket_mcp/config.py ADDED
@@ -0,0 +1,57 @@
1
+ """Configuration, read from environment variables (documented in client/README.md)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from .times import get_zone, system_zone_name
11
+
12
+ __all__ = ["DEFAULT_API_URL", "Settings", "normalise_api_url"]
13
+
14
+ DEFAULT_API_URL = "http://localhost:8001"
15
+
16
+
17
+ def normalise_api_url(url: str) -> str:
18
+ """The server origin: accepts the API origin or a frontend origin + ``/api``."""
19
+ url = url.strip().rstrip("/")
20
+ if url.endswith("/api"):
21
+ url = url[: -len("/api")]
22
+ return url
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Settings:
27
+ api_url: str = DEFAULT_API_URL
28
+ time_zone: str = "UTC"
29
+ # Entra sign-in (the docket_auth providers). DOCKET_API_TOKEN is read by the token provider
30
+ # itself, on every request, and is deliberately not held here.
31
+ client_id: str | None = None
32
+ client_secret: str | None = None
33
+ scope: str | None = None
34
+ tenant_id: str | None = None
35
+ authority: str | None = None
36
+ cache_path: Path | None = None
37
+
38
+ @classmethod
39
+ def from_env(cls, env: Mapping[str, str] | None = None) -> Settings:
40
+ env = os.environ if env is None else env
41
+
42
+ def get(name: str) -> str | None:
43
+ return (env.get(name) or "").strip() or None
44
+
45
+ zone = get("DOCKET_TIME_ZONE") or system_zone_name()
46
+ get_zone(zone) # fail at startup, not on the first tool call
47
+ cache = get("DOCKET_AUTH_CACHE_PATH")
48
+ return cls(
49
+ api_url=normalise_api_url(get("DOCKET_API_URL") or DEFAULT_API_URL),
50
+ time_zone=zone,
51
+ client_id=get("DOCKET_AUTH_CLIENT_ID"),
52
+ client_secret=get("DOCKET_AUTH_CLIENT_SECRET"),
53
+ scope=get("DOCKET_AUTH_SCOPE"),
54
+ tenant_id=get("DOCKET_AUTH_TENANT_ID"),
55
+ authority=get("DOCKET_AUTH_AUTHORITY"),
56
+ cache_path=Path(cache) if cache else Path.home() / ".docket" / "mcp-token-cache.json",
57
+ )
docket_mcp/server.py ADDED
@@ -0,0 +1,78 @@
1
+ """The ``docket-mcp`` entry point: a FastMCP server over stdio.
2
+
3
+ stdout carries the MCP protocol, so everything else (sign-in instructions, logs) goes to stderr.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import inspect
10
+ import sys
11
+ import typing
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from mcp.server.fastmcp import FastMCP
16
+
17
+ from .api import DocketApi, DocketToolError
18
+ from .config import Settings
19
+ from .times import TimeError
20
+ from .tools import TOOL_NAMES, DocketTools
21
+
22
+ __all__ = ["build_server", "main"]
23
+
24
+ INSTRUCTIONS = """\
25
+ Docket schedules acquisitions on AIND rigs. Use the read tools to look things up, then the
26
+ write tools to change the schedule. Every write tool previews by default (dry_run=true): show
27
+ the user what would happen, and only repeat the call with dry_run=false once they agree.
28
+ Times are local wall-clock (no offsets) plus an IANA zone; results give local time and UTC.
29
+ """
30
+
31
+
32
+ def _as_tool(method: Callable[..., Any]) -> Callable[..., Any]:
33
+ """Wrap a tool method: report expected failures as tool errors, keep the typed signature."""
34
+
35
+ @functools.wraps(method)
36
+ def tool(*args: Any, **kwargs: Any) -> Any:
37
+ try:
38
+ return method(*args, **kwargs)
39
+ except TimeError as error:
40
+ raise DocketToolError(str(error)) from None
41
+
42
+ # The annotations are strings (``from __future__``) that only resolve in tools.py's namespace.
43
+ hints = typing.get_type_hints(method, include_extras=True)
44
+ signature = inspect.signature(method)
45
+ tool.__signature__ = signature.replace( # type: ignore[attr-defined]
46
+ parameters=[
47
+ p.replace(annotation=hints.get(name, p.annotation))
48
+ for name, p in signature.parameters.items()
49
+ ],
50
+ return_annotation=hints.get("return", signature.return_annotation),
51
+ )
52
+ tool.__annotations__ = hints
53
+ return tool
54
+
55
+
56
+ def build_server(api: DocketApi) -> FastMCP:
57
+ server = FastMCP("docket", instructions=INSTRUCTIONS)
58
+ tools = DocketTools(api)
59
+ for name in TOOL_NAMES:
60
+ server.add_tool(_as_tool(getattr(tools, name)), name=name)
61
+ return server
62
+
63
+
64
+ def main() -> None:
65
+ try:
66
+ settings = Settings.from_env()
67
+ except (TimeError, ValueError) as error:
68
+ print(f"docket-mcp: {error}", file=sys.stderr)
69
+ raise SystemExit(2) from None
70
+ print(
71
+ f"docket-mcp: using {settings.api_url} (time zone {settings.time_zone})",
72
+ file=sys.stderr,
73
+ )
74
+ build_server(DocketApi(settings)).run(transport="stdio")
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
docket_mcp/signin.py ADDED
@@ -0,0 +1,155 @@
1
+ """Credential choice for write calls, and a device-code sign-in that never blocks a tool call.
2
+
3
+ Order: a Docket API token (``DOCKET_API_TOKEN``) first, then Entra: client credentials when
4
+ ``DOCKET_AUTH_CLIENT_SECRET`` is set, otherwise the device-code flow for a person.
5
+
6
+ ``msal``'s device flow blocks until the person has signed in, but an MCP tool call must return.
7
+ `BackgroundSignIn` runs it on a thread, sends the instructions to stderr and raises
8
+ `SignInRequired` carrying the same text, so a client that hides stderr still shows the
9
+ code in the tool error. Once signed in the next call finds a warm cache.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ import threading
17
+ from collections.abc import Callable
18
+
19
+ from docket_auth import (
20
+ ClientCredentialsTokenProvider,
21
+ DeviceCodeTokenProvider,
22
+ TokenProvider,
23
+ api_token_provider,
24
+ )
25
+
26
+ from .config import Settings
27
+
28
+ __all__ = ["BackgroundSignIn", "SignInRequired", "build_provider"]
29
+
30
+
31
+ class SignInRequired(Exception):
32
+ """The person must complete the device-code sign-in; the message says how."""
33
+
34
+
35
+ class _Job:
36
+ def __init__(self) -> None:
37
+ self.ready = threading.Event() # instructions available, or finished
38
+ self.done = threading.Event()
39
+ self.message: str | None = None
40
+ self.token: str | None = None
41
+ self.error: BaseException | None = None
42
+
43
+
44
+ class BackgroundSignIn:
45
+ """Wraps a blocking device-code provider; see the module docstring."""
46
+
47
+ def __init__(
48
+ self,
49
+ make_provider: Callable[[Callable[[str], None]], TokenProvider],
50
+ *,
51
+ wait_seconds: float = 8.0,
52
+ ) -> None:
53
+ self._make = make_provider
54
+ self._provider: TokenProvider | None = None
55
+ self._wait = wait_seconds
56
+ self._lock = threading.Lock()
57
+ self._job: _Job | None = None
58
+
59
+ def _on_prompt(self, message: str) -> None:
60
+ print(message, file=sys.stderr, flush=True)
61
+ job = self._job
62
+ if job is not None:
63
+ job.message = message
64
+ job.ready.set()
65
+
66
+ def _run(self, job: _Job) -> None:
67
+ try:
68
+ # Built here, not in __init__: msal fetches the authority's metadata when constructed.
69
+ if self._provider is None:
70
+ self._provider = self._make(self._on_prompt)
71
+ job.token = self._provider()
72
+ except BaseException as error: # reported to the caller, never lost on the thread
73
+ job.error = error
74
+ finally:
75
+ job.done.set()
76
+ job.ready.set()
77
+
78
+ def silent(self) -> str | None:
79
+ """A token from the warm cache, or None; never starts a sign-in."""
80
+ with self._lock:
81
+ if self._provider is None:
82
+ self._provider = self._make(self._on_prompt)
83
+ peek = getattr(self._provider, "silent_token", None)
84
+ return peek() if peek else None
85
+
86
+ def __call__(self) -> str:
87
+ with self._lock:
88
+ if self._job is None:
89
+ self._job = _Job()
90
+ threading.Thread(target=self._run, args=(self._job,), daemon=True).start()
91
+ job = self._job
92
+ job.ready.wait(self._wait)
93
+ if job.done.is_set():
94
+ with self._lock:
95
+ if self._job is job:
96
+ self._job = None
97
+ if job.error is not None:
98
+ raise job.error
99
+ assert job.token is not None
100
+ return job.token
101
+ raise SignInRequired(
102
+ f"Sign in to Docket first. {job.message or 'Starting sign-in; try again in a moment.'} "
103
+ "Then run this tool again."
104
+ )
105
+
106
+
107
+ def non_interactive_token(provider: TokenProvider) -> str | None:
108
+ """A token without any user interaction, or None (a missing token, a failed or cold sign-in)."""
109
+ try:
110
+ if isinstance(provider, BackgroundSignIn):
111
+ return provider.silent()
112
+ return provider()
113
+ except Exception: # AuthenticationError and msal/network failures: just no credential
114
+ return None
115
+
116
+
117
+ def build_provider(settings: Settings) -> TokenProvider | None:
118
+ """The provider for the configured credentials, or None when none are configured."""
119
+ if os.environ.get("DOCKET_API_TOKEN", "").strip():
120
+ return api_token_provider()
121
+ if settings.client_id and settings.scope and (settings.tenant_id or settings.authority):
122
+ if settings.client_secret:
123
+ client_id, secret, scope = settings.client_id, settings.client_secret, settings.scope
124
+ app: list[TokenProvider] = []
125
+
126
+ def client_credentials() -> str:
127
+ if not app: # msal fetches the authority's metadata when constructed: do it lazily
128
+ app.append(
129
+ ClientCredentialsTokenProvider(
130
+ client_id,
131
+ secret,
132
+ scope,
133
+ tenant_id=settings.tenant_id,
134
+ authority=settings.authority,
135
+ )
136
+ )
137
+ return app[0]()
138
+
139
+ return client_credentials
140
+ client_id, scope = settings.client_id, settings.scope
141
+
142
+ def make(prompt: Callable[[str], None]) -> TokenProvider:
143
+ if settings.cache_path:
144
+ settings.cache_path.parent.mkdir(parents=True, exist_ok=True)
145
+ return DeviceCodeTokenProvider(
146
+ client_id,
147
+ scope,
148
+ tenant_id=settings.tenant_id,
149
+ authority=settings.authority,
150
+ cache_path=settings.cache_path,
151
+ prompt=prompt,
152
+ )
153
+
154
+ return BackgroundSignIn(make)
155
+ return None
docket_mcp/slots.py ADDED
@@ -0,0 +1,80 @@
1
+ """Free-slot computation over a rig's schedule."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, date, datetime, time, timedelta
6
+ from typing import Any
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .times import local_at, local_to_midnight, parse_instant
10
+
11
+ __all__ = ["busy_intervals", "free_slots"]
12
+
13
+ Interval = tuple[datetime, datetime]
14
+
15
+
16
+ def busy_intervals(sessions: list[dict[str, Any]], zone: ZoneInfo) -> list[Interval]:
17
+ """Intervals the sessions occupy; cancelled and archived sessions do not block.
18
+
19
+ A session with no end is open-ended: it is assumed to run to the end of its local start day.
20
+ """
21
+ out: list[Interval] = []
22
+ for session in sessions:
23
+ if session.get("archived_at") or session.get("planning_status") == "cancelled":
24
+ continue
25
+ start = parse_instant(session["scheduled_start"])
26
+ if session.get("scheduled_end"):
27
+ end = parse_instant(session["scheduled_end"])
28
+ else:
29
+ end = local_to_midnight(start.astimezone(zone).date() + timedelta(days=1), zone)
30
+ if end > start:
31
+ out.append((start, end))
32
+ return sorted(out)
33
+
34
+
35
+ def _merge(intervals: list[Interval]) -> list[Interval]:
36
+ merged: list[Interval] = []
37
+ for start, end in sorted(intervals):
38
+ if merged and start <= merged[-1][1]:
39
+ merged[-1] = (merged[-1][0], max(merged[-1][1], end))
40
+ else:
41
+ merged.append((start, end))
42
+ return merged
43
+
44
+
45
+ def free_slots(
46
+ busy: list[Interval],
47
+ start_date: date,
48
+ end_date: date,
49
+ duration_minutes: int,
50
+ earliest: time,
51
+ latest: time,
52
+ zone: ZoneInfo,
53
+ ) -> list[tuple[datetime, datetime]]:
54
+ """Gaps of at least ``duration_minutes`` inside [earliest, latest] on each local day.
55
+
56
+ Adjacent sessions leave no gap; a gap exactly as long as the duration counts. Sessions
57
+ crossing a day bound are clipped to it. Returns UTC instants.
58
+ """
59
+ if latest <= earliest:
60
+ raise ValueError("latest must be after earliest.")
61
+ need = timedelta(minutes=duration_minutes)
62
+ merged = _merge(busy)
63
+ slots: list[tuple[datetime, datetime]] = []
64
+ day = start_date
65
+ while day <= end_date:
66
+ window_start = local_at(day, earliest, zone)
67
+ window_end = local_at(day, latest, zone)
68
+ cursor = window_start
69
+ for b_start, b_end in merged:
70
+ if b_end <= cursor:
71
+ continue
72
+ if b_start >= window_end:
73
+ break
74
+ if b_start - cursor >= need:
75
+ slots.append((cursor, b_start))
76
+ cursor = max(cursor, b_end)
77
+ if window_end - cursor >= need:
78
+ slots.append((cursor, window_end))
79
+ day += timedelta(days=1)
80
+ return [(s.astimezone(UTC), e.astimezone(UTC)) for s, e in slots]
docket_mcp/times.py ADDED
@@ -0,0 +1,160 @@
1
+ """Wall-clock times <-> UTC instants.
2
+
3
+ Tools take local wall-clock times plus an IANA zone and return both the local time and UTC.
4
+ Offsets are never added by hand: every conversion goes through ``zoneinfo``, so a DST day keeps
5
+ its 23 or 25 hours. Durations are elapsed minutes, added to the UTC instant, as the server does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from datetime import UTC, date, datetime, time, timedelta
12
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
13
+
14
+ __all__ = [
15
+ "ParsedLocal",
16
+ "TimeError",
17
+ "day_bounds",
18
+ "describe",
19
+ "format_utc",
20
+ "get_zone",
21
+ "local_at",
22
+ "local_to_midnight",
23
+ "local_to_utc",
24
+ "parse_date",
25
+ "parse_instant",
26
+ "parse_local_datetime",
27
+ "parse_time",
28
+ "system_zone_name",
29
+ ]
30
+
31
+ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$", re.IGNORECASE)
32
+
33
+
34
+ class TimeError(ValueError):
35
+ """A time, date or zone the caller gave that cannot be used; the message is shown to the model."""
36
+
37
+
38
+ def system_zone_name() -> str:
39
+ """The machine's IANA zone (``tzlocal`` also resolves Windows zones), falling back to UTC."""
40
+ try:
41
+ import tzlocal
42
+
43
+ name = tzlocal.get_localzone_name()
44
+ if name:
45
+ ZoneInfo(name)
46
+ return name
47
+ except Exception: # any failure to detect means "unknown": use UTC
48
+ pass
49
+ return "UTC"
50
+
51
+
52
+ def get_zone(name: str) -> ZoneInfo:
53
+ try:
54
+ return ZoneInfo(name)
55
+ except (ZoneInfoNotFoundError, ValueError, OSError):
56
+ raise TimeError(
57
+ f"Unknown time zone {name!r}: use an IANA name such as 'America/Los_Angeles'."
58
+ ) from None
59
+
60
+
61
+ def parse_date(value: str, field: str = "date") -> date:
62
+ try:
63
+ return date.fromisoformat(value.strip())
64
+ except ValueError:
65
+ raise TimeError(f"{field} must be a date like 2026-03-08, got {value!r}.") from None
66
+
67
+
68
+ def parse_time(value: str, field: str = "time") -> time:
69
+ try:
70
+ parsed = time.fromisoformat(value.strip())
71
+ except ValueError:
72
+ raise TimeError(f"{field} must be a 24-hour time like 09:30, got {value!r}.") from None
73
+ if parsed.tzinfo is not None:
74
+ raise TimeError(f"{field} is a local wall-clock time: no offset; use time_zone instead.")
75
+ return parsed.replace(microsecond=0)
76
+
77
+
78
+ class ParsedLocal:
79
+ """A local wall-clock time resolved to an instant."""
80
+
81
+ def __init__(self, utc: datetime, ambiguous: bool) -> None:
82
+ self.utc = utc
83
+ self.ambiguous = ambiguous
84
+
85
+
86
+ def local_to_utc(local: datetime, zone: ZoneInfo) -> ParsedLocal:
87
+ """Resolve naive ``local`` in ``zone``.
88
+
89
+ A time inside a spring-forward gap does not exist and is rejected. A time repeated by a
90
+ fall-back picks the first occurrence and is flagged ``ambiguous`` so the caller can say so.
91
+ """
92
+ if local.tzinfo is not None:
93
+ raise TimeError(
94
+ "Give a local wall-clock time without an offset; use time_zone for the zone."
95
+ )
96
+ first = local.replace(tzinfo=zone, fold=0)
97
+ second = local.replace(tzinfo=zone, fold=1)
98
+ utc = first.astimezone(UTC)
99
+ if utc.astimezone(zone).replace(tzinfo=None) != local:
100
+ raise TimeError(
101
+ f"{local:%Y-%m-%d %H:%M} does not exist in {zone.key} (clocks skip forward "
102
+ "that day); pick a time after the change."
103
+ )
104
+ return ParsedLocal(utc, first.utcoffset() != second.utcoffset())
105
+
106
+
107
+ def parse_local_datetime(value: str, zone: ZoneInfo, field: str = "start") -> ParsedLocal:
108
+ """Parse ``2026-03-08T09:30`` (or with a space) as local time in ``zone``."""
109
+ text = value.strip().replace(" ", "T")
110
+ if "T" in text and _OFFSET.search(text.split("T", 1)[1]):
111
+ raise TimeError(
112
+ f"{field} is a local wall-clock time like 2026-03-08T09:30: no offset or 'Z'. "
113
+ "Pass time_zone if it is not the default zone."
114
+ )
115
+ try:
116
+ local = datetime.fromisoformat(text)
117
+ except ValueError:
118
+ raise TimeError(f"{field} must look like 2026-03-08T09:30, got {value!r}.") from None
119
+ return local_to_utc(local.replace(microsecond=0), zone)
120
+
121
+
122
+ def parse_instant(value: str) -> datetime:
123
+ """An API instant (``...Z`` or with an offset) as an aware UTC datetime."""
124
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
125
+ if parsed.tzinfo is None:
126
+ parsed = parsed.replace(tzinfo=UTC)
127
+ return parsed.astimezone(UTC)
128
+
129
+
130
+ def format_utc(instant: datetime) -> str:
131
+ return instant.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
132
+
133
+
134
+ def describe(instant: str | datetime | None, zone: ZoneInfo) -> dict[str, str] | None:
135
+ """``{"local": "2026-03-08T09:30:00-07:00", "utc": "2026-03-08T16:30:00Z"}``."""
136
+ if instant is None:
137
+ return None
138
+ moment = parse_instant(instant) if isinstance(instant, str) else instant
139
+ return {
140
+ "local": moment.astimezone(zone).isoformat(timespec="seconds"),
141
+ "utc": format_utc(moment),
142
+ }
143
+
144
+
145
+ def local_to_midnight(day: date, zone: ZoneInfo) -> datetime:
146
+ """UTC instant of the start of local ``day`` (zoneinfo resolves a midnight lost to DST)."""
147
+ return datetime.combine(day, time(0), tzinfo=zone).astimezone(UTC)
148
+
149
+
150
+ def day_bounds(start: date, end: date, zone: ZoneInfo) -> tuple[datetime, datetime]:
151
+ """UTC instants of local midnight on ``start`` and of local midnight after ``end``."""
152
+ if end < start:
153
+ raise TimeError("end_date is before start_date.")
154
+ return local_to_midnight(start, zone), local_to_midnight(end + timedelta(days=1), zone)
155
+
156
+
157
+ def local_at(day: date, at: time, zone: ZoneInfo) -> datetime:
158
+ """UTC instant of ``at`` on local ``day``; a time in a DST gap resolves to the instant after it."""
159
+ # In a gap fold=0 applies the old offset, which lands after the gap: 02:30 becomes 03:30.
160
+ return datetime.combine(day, at).replace(tzinfo=zone, fold=0).astimezone(UTC)