tars-sdk 0.1.1__tar.gz

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.
@@ -0,0 +1,56 @@
1
+ # Node
2
+ node_modules/
3
+ *.tsbuildinfo
4
+ *.log
5
+
6
+ # Environment / secrets — commit only the .example files
7
+ .env
8
+ .env.*
9
+ !.env.example
10
+
11
+ # Terraform — providers, state, and real tfvars (contain subscription IDs)
12
+ **/.terraform/
13
+ *.tfstate
14
+ *.tfstate.*
15
+ terraform.tfvars
16
+ !terraform.tfvars.example
17
+
18
+ # Restate local data (journals, ledger)
19
+ restate-data/
20
+
21
+ # OS
22
+ .DS_Store
23
+
24
+ # Terraform saved plans
25
+ tfplan
26
+
27
+ # built SDK artifacts
28
+ sdk/dist/
29
+
30
+ # Next.js
31
+ .next/
32
+ backend/dist/
33
+ frontend/.next/
34
+ frontend/next-env.d.ts
35
+ website/next-env.d.ts
36
+ website/package-lock.json
37
+
38
+ # Claude Code — machine-local permission state and per-task worktrees
39
+ # (EnterWorktree). settings.json IS tracked; settings.local.json is not.
40
+ .claude/settings.local.json
41
+ .claude/worktrees/
42
+
43
+ # Verification screenshots never belong at the repo root
44
+ /*.png
45
+ /*.jpg
46
+
47
+ # .NET build output (sdk-dotnet, examples/chat-dotnet)
48
+ bin/
49
+ obj/
50
+
51
+ # Python (sdk-python, examples/chat-python)
52
+ .venv/
53
+ __pycache__/
54
+ *.egg-info/
55
+ sdk-python/dist/
56
+ .pytest_cache/
@@ -0,0 +1,45 @@
1
+ # tars-sdk
2
+
3
+ Tenant-facing record of the Python SDK, newest first. Each entry names the
4
+ minimum TARS backend it needs. The version lives in `pyproject.toml`; bump
5
+ it in the same PR as the entry, and label that PR `release-approved` once
6
+ prd runs that backend — merging it publishes.
7
+
8
+ ## 0.1.1
9
+
10
+ First published release. Needs TARS backend ≥ `prd-20261108-2059-sha-918f00a7`.
11
+
12
+ - `TarsClient(api_key, base_url=, provider_keys=, act_as_tenant=, http_client=)`:
13
+ BYOK provider keys as one `x-tars-key-<provider>` header per key, never
14
+ stored; `act_as_tenant` for admin keys (read-only); an injectable
15
+ `httpx.AsyncClient`. Async context manager; `aclose()`.
16
+ - `agents`: create, list, get, update, archive, unarchive, versions. On
17
+ `update`, omitted keywords are unchanged and `None` clears a clearable
18
+ field (description, tools, metadata, mcp_servers, skills, environment,
19
+ reasoning).
20
+ - `sessions`: create (with client-executed tools), open, list, get, events,
21
+ rename, set_mcp_servers, set_custom_tools, interrupt, compact, usage,
22
+ destroy.
23
+ - `TarsSession`: `send` (streams the turn and runs your tool handlers, sync
24
+ or async), `send_raw`, `stream` (replay + live tail), `resume` (current
25
+ turn only), `mount_file`, `attach_file`, `interrupt`, `compact`, `destroy`.
26
+ - `files`: upload, upload_text, list, get, download (stream), download_bytes.
27
+ - `skills`: create, list, get, versions, push_version, download_version.
28
+ - `mcp_credentials`: set (`BearerCredential` / `OAuthCredential`), list,
29
+ delete; `mcp.list_tools`.
30
+ - `usage`: get (from/to/session/end-user filters), daily, sessions. Dates
31
+ are `datetime` (naive = UTC) or ISO strings, sent as UTC ISO 8601.
32
+ - `api_keys`: create (key returned once), list, revoke.
33
+ - `completions.create`; `admin` (admin keys): tenants, their keys, stats.
34
+ - `me`, `providers`, `provider_status`, `models`.
35
+ - Typed events: one dataclass per journal event type, `UnknownEvent` for
36
+ types newer than the SDK, `raw` on every event. `SESSION_EVENT_TYPES` and
37
+ `AGENT_ERROR_CODES` are pinned to the backend taxonomy by a test.
38
+ - `TarsApiError` with `status`, the stable `code` from the error envelope,
39
+ and the raw `body`.
40
+
41
+ ## 0.1.0
42
+
43
+ Never published: PyPI refused the `tars_sdk-0.1.0` filenames because an
44
+ unrelated, since-deleted upload had used them once, and PyPI never lets a
45
+ filename be reused. 0.1.1 is the same code.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: tars-sdk
3
+ Version: 0.1.1
4
+ Summary: Client SDK for TARS — the self-hosted managed-agents platform (agents, durable sessions, streaming turns, client-executed tools, files).
5
+ Project-URL: Homepage, https://github.com/LegendariTech/tars
6
+ Project-URL: Repository, https://github.com/LegendariTech/tars
7
+ Project-URL: Changelog, https://github.com/LegendariTech/tars/blob/dev/sdk-python/CHANGELOG.md
8
+ Author: Legendari
9
+ License-Expression: MIT
10
+ Keywords: agents,ai,anthropic,mcp,sdk,tars
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx<1,>=0.27
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
24
+ Requires-Dist: pytest>=8; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # tars-sdk
28
+
29
+ Python client for [TARS](https://github.com/LegendariTech/tars) — a
30
+ self-hosted managed-agents platform: server-side agent loops in sandboxes,
31
+ an append-only session event log with SSE streaming + replay, client-executed
32
+ tools, files, and usage metering.
33
+
34
+ ```bash
35
+ pip install tars-sdk
36
+ ```
37
+
38
+ Python 3.10+, async (`asyncio`), one dependency (`httpx`).
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ import asyncio, os
44
+ from tars_sdk import TarsClient, TextDeltaEvent
45
+
46
+ async def main():
47
+ async with TarsClient(
48
+ api_key=os.environ["TARS_API_KEY"], # tars_sk_…
49
+ base_url="https://your-tars-host",
50
+ provider_keys={"openrouter": os.environ["OPENROUTER_API_KEY"]},
51
+ ) as tars:
52
+ agent = await tars.agents.create(
53
+ name="assistant",
54
+ system="You are a helpful assistant.",
55
+ model="openrouter/anthropic/claude-sonnet-5",
56
+ )
57
+ session = await tars.sessions.create(agent_id=agent.id)
58
+ async for ev in session.send("Summarize the attached file."):
59
+ if isinstance(ev, TextDeltaEvent):
60
+ print(ev.text, end="", flush=True)
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ ## Streaming surfaces
66
+
67
+ - `session.send(text)` — yields the turn's events; client-executed tools
68
+ you registered (`tools=` on `sessions.create` / `session.on_tool_call`)
69
+ are run and their results returned to the turn automatically. Handlers
70
+ may be sync or async.
71
+ - `session.send_raw(text)` — same events, **no auto-dispatch**: hosts
72
+ that own their tool dispatcher consume this.
73
+ - `session.stream(after)` — replay the journal after a seq, then
74
+ live-tail; survives restarts.
75
+ - `session.resume(after)` — reconnect to the current turn only.
76
+
77
+ Every event is a `SessionEvent` dataclass (`TextDeltaEvent`,
78
+ `ToolCallEvent`, `TurnEndEvent`, …); a type newer than this SDK arrives as
79
+ `UnknownEvent` with the payload in `raw`. Non-2xx answers raise
80
+ `TarsApiError` with `status` and the stable `code`.
81
+
82
+ ## Everything else
83
+
84
+ `tars.skills`, `tars.mcp_credentials`, `tars.mcp.list_tools`, `tars.usage`,
85
+ `tars.api_keys`, `tars.completions` and `tars.admin` (admin keys) cover the
86
+ rest of the API, one typed method per `/v1/*` route. On
87
+ `agents.update`, a keyword you do not pass is unchanged; passing `None` for
88
+ a clearable field (description, tools, metadata, mcp_servers, skills,
89
+ environment, reasoning) clears it.
90
+
91
+ Full docs: the TARS docs site, *SDK for Python* page.
@@ -0,0 +1,65 @@
1
+ # tars-sdk
2
+
3
+ Python client for [TARS](https://github.com/LegendariTech/tars) — a
4
+ self-hosted managed-agents platform: server-side agent loops in sandboxes,
5
+ an append-only session event log with SSE streaming + replay, client-executed
6
+ tools, files, and usage metering.
7
+
8
+ ```bash
9
+ pip install tars-sdk
10
+ ```
11
+
12
+ Python 3.10+, async (`asyncio`), one dependency (`httpx`).
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ import asyncio, os
18
+ from tars_sdk import TarsClient, TextDeltaEvent
19
+
20
+ async def main():
21
+ async with TarsClient(
22
+ api_key=os.environ["TARS_API_KEY"], # tars_sk_…
23
+ base_url="https://your-tars-host",
24
+ provider_keys={"openrouter": os.environ["OPENROUTER_API_KEY"]},
25
+ ) as tars:
26
+ agent = await tars.agents.create(
27
+ name="assistant",
28
+ system="You are a helpful assistant.",
29
+ model="openrouter/anthropic/claude-sonnet-5",
30
+ )
31
+ session = await tars.sessions.create(agent_id=agent.id)
32
+ async for ev in session.send("Summarize the attached file."):
33
+ if isinstance(ev, TextDeltaEvent):
34
+ print(ev.text, end="", flush=True)
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ ## Streaming surfaces
40
+
41
+ - `session.send(text)` — yields the turn's events; client-executed tools
42
+ you registered (`tools=` on `sessions.create` / `session.on_tool_call`)
43
+ are run and their results returned to the turn automatically. Handlers
44
+ may be sync or async.
45
+ - `session.send_raw(text)` — same events, **no auto-dispatch**: hosts
46
+ that own their tool dispatcher consume this.
47
+ - `session.stream(after)` — replay the journal after a seq, then
48
+ live-tail; survives restarts.
49
+ - `session.resume(after)` — reconnect to the current turn only.
50
+
51
+ Every event is a `SessionEvent` dataclass (`TextDeltaEvent`,
52
+ `ToolCallEvent`, `TurnEndEvent`, …); a type newer than this SDK arrives as
53
+ `UnknownEvent` with the payload in `raw`. Non-2xx answers raise
54
+ `TarsApiError` with `status` and the stable `code`.
55
+
56
+ ## Everything else
57
+
58
+ `tars.skills`, `tars.mcp_credentials`, `tars.mcp.list_tools`, `tars.usage`,
59
+ `tars.api_keys`, `tars.completions` and `tars.admin` (admin keys) cover the
60
+ rest of the API, one typed method per `/v1/*` route. On
61
+ `agents.update`, a keyword you do not pass is unchanged; passing `None` for
62
+ a clearable field (description, tools, metadata, mcp_servers, skills,
63
+ environment, reasoning) clears it.
64
+
65
+ Full docs: the TARS docs site, *SDK for Python* page.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tars-sdk"
7
+ # Bumped by hand in the PR that adds the CHANGELOG entry; Verify refuses the
8
+ # bump without the `release-approved` label; merging publishes to PyPI.
9
+ version = "0.1.1"
10
+ description = "Client SDK for TARS — the self-hosted managed-agents platform (agents, durable sessions, streaming turns, client-executed tools, files)."
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ requires-python = ">=3.10"
14
+ authors = [{ name = "Legendari" }]
15
+ keywords = ["agents", "ai", "mcp", "anthropic", "sdk", "tars"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+ # httpx is the one dependency: async HTTP with streaming responses, the
28
+ # transport seam tests use (MockTransport), and HTTP/2-ready.
29
+ dependencies = ["httpx>=0.27,<1"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/LegendariTech/tars"
33
+ Repository = "https://github.com/LegendariTech/tars"
34
+ Changelog = "https://github.com/LegendariTech/tars/blob/dev/sdk-python/CHANGELOG.md"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=8", "pytest-asyncio>=0.24"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/tars_sdk"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/tars_sdk", "README.md", "CHANGELOG.md", "pyproject.toml"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ asyncio_default_fixture_loop_scope = "function"
48
+ testpaths = ["tests"]
@@ -0,0 +1,219 @@
1
+ """tars-sdk — the Python client for TARS.
2
+
3
+ from tars_sdk import TarsClient, TextDeltaEvent
4
+
5
+ async with TarsClient(api_key="tars_sk_…", base_url="https://your-tars-host") as tars:
6
+ agent = await tars.agents.create(name="assistant", system="You are helpful.")
7
+ session = await tars.sessions.create(agent_id=agent.id)
8
+ async for ev in session.send("Hello!"):
9
+ if isinstance(ev, TextDeltaEvent):
10
+ print(ev.text, end="")
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _version
14
+
15
+ from ._wire import UNSET
16
+ from .client import (
17
+ DEFAULT_BASE_URL,
18
+ KEYED_PROVIDERS,
19
+ AdminClient,
20
+ AgentsClient,
21
+ ApiKeysClient,
22
+ CompletionsClient,
23
+ FilesClient,
24
+ McpClient,
25
+ McpCredentialsClient,
26
+ SessionsClient,
27
+ SkillsClient,
28
+ TarsClient,
29
+ UsageClient,
30
+ )
31
+ from .errors import TarsApiError
32
+ from .events import (
33
+ AGENT_ERROR_CODES,
34
+ SESSION_EVENT_TYPES,
35
+ CompactionEvent,
36
+ ConnectRequiredEvent,
37
+ ErrorEvent,
38
+ McpConnectEvent,
39
+ MessageAttachment,
40
+ OutputFileEvent,
41
+ SessionEvent,
42
+ StatusEvent,
43
+ TextDeltaEvent,
44
+ TextEndEvent,
45
+ TextStartEvent,
46
+ ToolCallEvent,
47
+ ToolResultEvent,
48
+ TurnEndEvent,
49
+ TurnStartEvent,
50
+ TurnUsage,
51
+ UnknownEvent,
52
+ UserMessageEvent,
53
+ parse_event,
54
+ )
55
+ from .models import (
56
+ Agent,
57
+ AgentEnvironment,
58
+ AgentPackages,
59
+ AgentStatusResult,
60
+ AgentVersion,
61
+ ApiKeyInfo,
62
+ AttachResult,
63
+ BearerCredential,
64
+ BuiltinToolInfo,
65
+ ClientIdentity,
66
+ ClientTool,
67
+ ClientToolDeclaration,
68
+ CompactResult,
69
+ CompletionResult,
70
+ CompletionUsage,
71
+ CreatedApiKey,
72
+ CreatedTenant,
73
+ DailyUsageRow,
74
+ FileMeta,
75
+ InterruptResult,
76
+ McpCredential,
77
+ McpCredentialStatus,
78
+ McpServer,
79
+ McpServerTools,
80
+ McpToolAnnotations,
81
+ McpToolInfo,
82
+ ModelInfo,
83
+ ModelPricing,
84
+ MountResult,
85
+ OAuthCredential,
86
+ OkResult,
87
+ PlatformStats,
88
+ PlatformUsageTotals,
89
+ ProviderStatus,
90
+ ProviderStatusReport,
91
+ ReasoningConfig,
92
+ SessionClientTool,
93
+ SessionEventsPage,
94
+ SessionInfo,
95
+ SessionListItem,
96
+ SessionsPage,
97
+ SessionToolSurface,
98
+ SessionUsage,
99
+ SessionUsageFigures,
100
+ SessionUsageRow,
101
+ Skill,
102
+ SkillPin,
103
+ SkillVersion,
104
+ SkillVersionPushed,
105
+ Tenant,
106
+ TenantCounts,
107
+ TenantDetail,
108
+ TenantFirstKey,
109
+ TenantStats,
110
+ ToolHandler,
111
+ UsageBreakdownRow,
112
+ UsageSummary,
113
+ UsageTotals,
114
+ )
115
+ from .session import TarsSession
116
+
117
+ try:
118
+ __version__ = _version("tars-sdk")
119
+ except PackageNotFoundError: # running from a source checkout
120
+ __version__ = "0.0.0"
121
+
122
+ __all__ = [
123
+ "AGENT_ERROR_CODES",
124
+ "DEFAULT_BASE_URL",
125
+ "KEYED_PROVIDERS",
126
+ "SESSION_EVENT_TYPES",
127
+ "UNSET",
128
+ "AdminClient",
129
+ "Agent",
130
+ "AgentEnvironment",
131
+ "AgentPackages",
132
+ "AgentStatusResult",
133
+ "AgentVersion",
134
+ "AgentsClient",
135
+ "ApiKeyInfo",
136
+ "ApiKeysClient",
137
+ "AttachResult",
138
+ "BearerCredential",
139
+ "BuiltinToolInfo",
140
+ "ClientIdentity",
141
+ "ClientTool",
142
+ "ClientToolDeclaration",
143
+ "CompactResult",
144
+ "CompactionEvent",
145
+ "CompletionResult",
146
+ "CompletionUsage",
147
+ "CompletionsClient",
148
+ "ConnectRequiredEvent",
149
+ "CreatedApiKey",
150
+ "CreatedTenant",
151
+ "DailyUsageRow",
152
+ "ErrorEvent",
153
+ "FileMeta",
154
+ "FilesClient",
155
+ "InterruptResult",
156
+ "McpClient",
157
+ "McpConnectEvent",
158
+ "McpCredential",
159
+ "McpCredentialStatus",
160
+ "McpCredentialsClient",
161
+ "McpServer",
162
+ "McpServerTools",
163
+ "McpToolAnnotations",
164
+ "McpToolInfo",
165
+ "MessageAttachment",
166
+ "ModelInfo",
167
+ "ModelPricing",
168
+ "MountResult",
169
+ "OAuthCredential",
170
+ "OkResult",
171
+ "OutputFileEvent",
172
+ "PlatformStats",
173
+ "PlatformUsageTotals",
174
+ "ProviderStatus",
175
+ "ProviderStatusReport",
176
+ "ReasoningConfig",
177
+ "SessionClientTool",
178
+ "SessionEvent",
179
+ "SessionEventsPage",
180
+ "SessionInfo",
181
+ "SessionListItem",
182
+ "SessionToolSurface",
183
+ "SessionUsage",
184
+ "SessionUsageFigures",
185
+ "SessionUsageRow",
186
+ "SessionsClient",
187
+ "SessionsPage",
188
+ "Skill",
189
+ "SkillPin",
190
+ "SkillVersion",
191
+ "SkillVersionPushed",
192
+ "SkillsClient",
193
+ "StatusEvent",
194
+ "TarsApiError",
195
+ "TarsClient",
196
+ "TarsSession",
197
+ "Tenant",
198
+ "TenantCounts",
199
+ "TenantDetail",
200
+ "TenantFirstKey",
201
+ "TenantStats",
202
+ "TextDeltaEvent",
203
+ "TextEndEvent",
204
+ "TextStartEvent",
205
+ "ToolCallEvent",
206
+ "ToolHandler",
207
+ "ToolResultEvent",
208
+ "TurnEndEvent",
209
+ "TurnStartEvent",
210
+ "TurnUsage",
211
+ "UnknownEvent",
212
+ "UsageBreakdownRow",
213
+ "UsageClient",
214
+ "UsageSummary",
215
+ "UsageTotals",
216
+ "UserMessageEvent",
217
+ "__version__",
218
+ "parse_event",
219
+ ]
@@ -0,0 +1,33 @@
1
+ """Minimal SSE reader for the session streams.
2
+
3
+ TARS writes ``id: <seq>\\ndata: <json>\\n\\n`` per event (``backend/src/http/sse.ts``);
4
+ the seq is also inside the JSON, so ``id:`` is ignored. ``data:`` lines of one
5
+ frame are joined, a blank line ends the frame, EOF flushes a frame the server
6
+ closed without a blank line.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import AsyncIterator
13
+
14
+ import httpx
15
+
16
+ from .events import SessionEvent, parse_event
17
+
18
+
19
+ async def read_events(response: httpx.Response) -> AsyncIterator[SessionEvent]:
20
+ data: list[str] = []
21
+ async for line in response.aiter_lines():
22
+ if line == "":
23
+ if data:
24
+ yield parse_event(json.loads("".join(data)))
25
+ data = []
26
+ continue
27
+ if line.startswith("data: "):
28
+ data.append(line[6:])
29
+ elif line.startswith("data:"):
30
+ data.append(line[5:])
31
+ # id:, event:, retry:, comments — not used by this stream.
32
+ if data:
33
+ yield parse_event(json.loads("".join(data)))
@@ -0,0 +1,49 @@
1
+ """Request-body helpers.
2
+
3
+ ``UNSET`` lets a method tell "the caller did not pass this" from "the caller
4
+ passed ``None``": for a PATCH that is "leave it" versus "clear it". ``body()``
5
+ drops the SDK's own unset/None fields; the caller's data inside them (tool
6
+ outputs, input schemas, metadata) is passed through verbatim.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Final
12
+
13
+
14
+ class _Unset:
15
+ __slots__ = ()
16
+
17
+ def __repr__(self) -> str:
18
+ return "UNSET"
19
+
20
+ def __bool__(self) -> bool:
21
+ return False
22
+
23
+
24
+ UNSET: Final = _Unset()
25
+ """Default for optional update fields: not sent. Pass ``None`` to clear a field."""
26
+
27
+
28
+ def body(**fields: Any) -> dict[str, Any]:
29
+ """The SDK's own top-level fields, minus the ones left ``None``/``UNSET``."""
30
+ return {k: v for k, v in fields.items() if v is not None and v is not UNSET}
31
+
32
+
33
+ def patch(**fields: Any) -> dict[str, Any]:
34
+ """Like :func:`body`, but a ``None`` is kept (sent as JSON ``null``); only ``UNSET`` is dropped."""
35
+ return {k: v for k, v in fields.items() if v is not UNSET}
36
+
37
+
38
+ def iso(at: Any) -> str:
39
+ """UTC ISO 8601 with milliseconds and ``Z``, the shape the API expects."""
40
+ from datetime import datetime, timezone
41
+
42
+ if isinstance(at, str):
43
+ return at
44
+ if not isinstance(at, datetime):
45
+ raise TypeError(f"expected a datetime or ISO string, got {type(at).__name__}")
46
+ if at.tzinfo is None:
47
+ at = at.replace(tzinfo=timezone.utc)
48
+ at = at.astimezone(timezone.utc)
49
+ return at.strftime("%Y-%m-%dT%H:%M:%S.") + f"{at.microsecond // 1000:03d}Z"