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.
- docket/__init__.py +8 -0
- docket/api/__init__.py +1 -0
- docket/api/access/__init__.py +1 -0
- docket/api/access/add_principal.py +193 -0
- docket/api/access/archive_role_assignment.py +182 -0
- docket/api/access/create_api_token.py +198 -0
- docket/api/access/create_application_principal.py +188 -0
- docket/api/access/create_role_assignment.py +194 -0
- docket/api/access/list_api_tokens.py +184 -0
- docket/api/access/list_principals.py +184 -0
- docket/api/access/list_role_assignments.py +200 -0
- docket/api/access/revoke_api_token.py +173 -0
- docket/api/identity/__init__.py +1 -0
- docket/api/identity/create_my_api_token.py +206 -0
- docket/api/identity/get_me.py +127 -0
- docket/api/identity/list_my_api_tokens.py +151 -0
- docket/api/identity/revoke_my_api_token.py +178 -0
- docket/api/recurring_acquisitions/__init__.py +1 -0
- docket/api/recurring_acquisitions/archive_recurring_acquisition.py +207 -0
- docket/api/recurring_acquisitions/create_recurring_acquisition.py +218 -0
- docket/api/recurring_acquisitions/get_recurring_acquisition.py +163 -0
- docket/api/recurring_acquisitions/list_recurring_acquisitions.py +248 -0
- docket/api/recurring_acquisitions/preview_recurring_acquisition.py +187 -0
- docket/api/recurring_acquisitions/preview_split_recurring_acquisition.py +232 -0
- docket/api/recurring_acquisitions/split_recurring_acquisition.py +258 -0
- docket/api/runs/__init__.py +1 -0
- docket/api/runs/get_run.py +160 -0
- docket/api/runs/list_runs.py +180 -0
- docket/api/runs/report_run_outcome.py +206 -0
- docket/api/scheduled_acquisitions/__init__.py +1 -0
- docket/api/scheduled_acquisitions/archive_scheduled_acquisition.py +203 -0
- docket/api/scheduled_acquisitions/create_scheduled_acquisition.py +185 -0
- docket/api/scheduled_acquisitions/get_resolved_settings.py +169 -0
- docket/api/scheduled_acquisitions/get_scheduled_acquisition.py +159 -0
- docket/api/scheduled_acquisitions/list_active_acquisitions.py +297 -0
- docket/api/scheduled_acquisitions/list_runs_for_scheduled_acquisition.py +191 -0
- docket/api/scheduled_acquisitions/list_scheduled_acquisitions.py +332 -0
- docket/api/scheduled_acquisitions/preview_scheduled_acquisition_edit.py +224 -0
- docket/api/scheduled_acquisitions/set_planning_status.py +199 -0
- docket/api/scheduled_acquisitions/start_run.py +221 -0
- docket/api/scheduled_acquisitions/update_scheduled_acquisition.py +226 -0
- docket/api/settings/__init__.py +1 -0
- docket/api/settings/list_behavior_curriculum_discriminator_values.py +188 -0
- docket/api/settings/list_behavior_curriculum_settings.py +199 -0
- docket/api/settings/list_fip_discriminator_values.py +184 -0
- docket/api/settings/list_fip_settings.py +195 -0
- docket/api/settings/list_live_behavior_curriculum_settings.py +233 -0
- docket/api/settings/list_live_fip_settings.py +225 -0
- docket/api/settings/preview_behavior_curriculum_settings.py +191 -0
- docket/api/settings/preview_fip_settings.py +177 -0
- docket/api/settings/push_behavior_curriculum_settings.py +222 -0
- docket/api/settings/push_fip_settings.py +206 -0
- docket/api/templates/__init__.py +1 -0
- docket/api/templates/archive_template.py +203 -0
- docket/api/templates/create_template.py +206 -0
- docket/api/templates/get_template.py +159 -0
- docket/api/templates/list_templates.py +174 -0
- docket/api/templates/update_template.py +234 -0
- docket/api/vocabulary/__init__.py +1 -0
- docket/api/vocabulary/list_instruments.py +165 -0
- docket/api/vocabulary/list_persons.py +132 -0
- docket/api/vocabulary/list_projects.py +132 -0
- docket/api/vocabulary/list_subjects.py +132 -0
- docket/api/vocabulary/lookup_person.py +166 -0
- docket/api/vocabulary/lookup_subject.py +166 -0
- docket/api/vocabulary/sync_vocabulary.py +148 -0
- docket/client.py +272 -0
- docket/errors.py +16 -0
- docket/models/__init__.py +221 -0
- docket/models/access_role.py +12 -0
- docket/models/access_role_nullable_type_1.py +12 -0
- docket/models/access_role_nullable_type_2_type_1.py +12 -0
- docket/models/access_role_nullable_type_3_type_1.py +12 -0
- docket/models/acquisition_variable.py +72 -0
- docket/models/acquisition_warning.py +126 -0
- docket/models/add_principal_request.py +83 -0
- docket/models/api_token_response.py +271 -0
- docket/models/api_token_status.py +10 -0
- docket/models/archive_recurring_acquisition_request.py +77 -0
- docket/models/archive_recurring_acquisition_response.py +83 -0
- docket/models/archive_scheduled_acquisition_request.py +78 -0
- docket/models/archive_template_request.py +66 -0
- docket/models/auth_mode.py +10 -0
- docket/models/behavior_curriculum_push_settings_request.py +85 -0
- docket/models/behavior_curriculum_settings.py +55 -0
- docket/models/behavior_curriculum_settings_preview_response.py +102 -0
- docket/models/behavior_curriculum_settings_preview_response_promoted.py +50 -0
- docket/models/behavior_curriculum_settings_version_response.py +160 -0
- docket/models/behavior_curriculum_settings_version_response_promoted.py +50 -0
- docket/models/caller_kind.py +11 -0
- docket/models/create_api_token_request.py +97 -0
- docket/models/create_application_principal_request.py +65 -0
- docket/models/create_personal_api_token_request.py +167 -0
- docket/models/create_recurring_acquisition_request.py +269 -0
- docket/models/create_role_assignment_request.py +99 -0
- docket/models/create_scheduled_acquisition_request.py +265 -0
- docket/models/create_template_request.py +323 -0
- docket/models/created_api_token_response.py +82 -0
- docket/models/effective_status.py +15 -0
- docket/models/fip_color.py +13 -0
- docket/models/fip_fiber.py +93 -0
- docket/models/fip_mode.py +9 -0
- docket/models/fip_push_settings_request.py +82 -0
- docket/models/fip_settings.py +91 -0
- docket/models/fip_settings_preview_response.py +97 -0
- docket/models/fip_settings_preview_response_promoted.py +50 -0
- docket/models/fip_settings_version_response.py +155 -0
- docket/models/fip_settings_version_response_promoted.py +50 -0
- docket/models/future_occurrences.py +9 -0
- docket/models/get_resolved_settings_response_200.py +65 -0
- docket/models/held_role_response.py +116 -0
- docket/models/http_validation_problem_details.py +204 -0
- docket/models/http_validation_problem_details_errors.py +59 -0
- docket/models/instrument_response.py +91 -0
- docket/models/kept_occurrence.py +85 -0
- docket/models/kept_occurrence_reason.py +10 -0
- docket/models/list_active_acquisitions_effective_status_item.py +15 -0
- docket/models/list_scheduled_acquisitions_planning_status.py +11 -0
- docket/models/me_caller.py +118 -0
- docket/models/me_permission.py +98 -0
- docket/models/me_response.py +158 -0
- docket/models/me_role.py +75 -0
- docket/models/occurrence_preview.py +121 -0
- docket/models/occurrence_summary.py +126 -0
- docket/models/occurrence_warnings.py +91 -0
- docket/models/permission_action.py +12 -0
- docket/models/person_assignment.py +74 -0
- docket/models/person_lookup_response.py +131 -0
- docket/models/person_response.py +103 -0
- docket/models/person_role.py +9 -0
- docket/models/personal_token_limits.py +92 -0
- docket/models/planning_status.py +11 -0
- docket/models/principal_kind.py +9 -0
- docket/models/principal_response.py +216 -0
- docket/models/problem_details.py +177 -0
- docket/models/project_response.py +75 -0
- docket/models/recurrence_exception_kind.py +13 -0
- docket/models/recurrence_exception_response.py +103 -0
- docket/models/recurrence_frequency.py +9 -0
- docket/models/recurrence_preview_response.py +101 -0
- docket/models/recurrence_rule.py +207 -0
- docket/models/recurring_acquisition_created_response.py +123 -0
- docket/models/recurring_acquisition_detail_response.py +105 -0
- docket/models/recurring_acquisition_response.py +326 -0
- docket/models/report_run_outcome_request.py +89 -0
- docket/models/resolution_mode.py +10 -0
- docket/models/resolved_settings.py +73 -0
- docket/models/role_assignment_response.py +157 -0
- docket/models/role_origin.py +10 -0
- docket/models/role_source_kind.py +10 -0
- docket/models/run_origin.py +9 -0
- docket/models/run_outcome.py +9 -0
- docket/models/run_response.py +181 -0
- docket/models/run_settings_snapshot.py +72 -0
- docket/models/run_start_response.py +91 -0
- docket/models/run_start_response_settings.py +65 -0
- docket/models/run_status.py +11 -0
- docket/models/scheduled_acquisition_edit_preview_response.py +150 -0
- docket/models/scheduled_acquisition_response.py +366 -0
- docket/models/set_planning_status_request.py +90 -0
- docket/models/settings_selector.py +138 -0
- docket/models/skipped_occurrence.py +85 -0
- docket/models/split_preview_response.py +129 -0
- docket/models/split_recurring_acquisition_request.py +261 -0
- docket/models/split_recurring_acquisition_response.py +159 -0
- docket/models/start_run_request.py +73 -0
- docket/models/subject_lookup_response.py +149 -0
- docket/models/subject_response.py +128 -0
- docket/models/sync_counts.py +80 -0
- docket/models/sync_response.py +81 -0
- docket/models/template_response.py +302 -0
- docket/models/update_scheduled_acquisition_request.py +235 -0
- docket/models/update_template_request.py +279 -0
- docket/models/weekday.py +14 -0
- docket/types.py +54 -0
- docket_auth/__init__.py +239 -0
- docket_client-0.3.2.dist-info/METADATA +233 -0
- docket_client-0.3.2.dist-info/RECORD +188 -0
- docket_client-0.3.2.dist-info/WHEEL +4 -0
- docket_client-0.3.2.dist-info/entry_points.txt +2 -0
- docket_mcp/__init__.py +6 -0
- docket_mcp/api.py +207 -0
- docket_mcp/config.py +57 -0
- docket_mcp/server.py +78 -0
- docket_mcp/signin.py +155 -0
- docket_mcp/slots.py +80 -0
- docket_mcp/times.py +160 -0
- 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)
|