asap-protocol 0.1.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.
@@ -0,0 +1,410 @@
1
+ """Core entity models for the ASAP protocol.
2
+
3
+ This module defines the fundamental entities used in agent-to-agent communication:
4
+ - Agent: An autonomous entity capable of sending/receiving ASAP messages
5
+ - Manifest: Self-describing metadata about an agent's capabilities
6
+ - Conversation: Context for related interactions between agents
7
+ - Task: Fundamental unit of work with lifecycle management
8
+ - Message: Single communication turn containing parts
9
+ - Artifact: Concrete output produced by task execution
10
+ - StateSnapshot: First-class state persistence mechanism
11
+ - Skill: A specific capability that an agent can perform
12
+ - Capability: Collection of an agent's features and supported operations
13
+ - Endpoint: Network endpoints for agent communication
14
+ - AuthScheme: Authentication configuration for agent access
15
+ """
16
+
17
+ import re
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ from packaging.version import InvalidVersion, Version
22
+ from pydantic import Field, field_validator
23
+
24
+ from asap.models.base import ASAPBaseModel
25
+ from asap.models.constants import AGENT_URN_PATTERN, ASAP_PROTOCOL_VERSION
26
+ from asap.models.enums import MessageRole, TaskStatus
27
+ from asap.models.types import (
28
+ AgentURN,
29
+ ArtifactID,
30
+ ConversationID,
31
+ MessageID,
32
+ PartID,
33
+ SemanticVersion,
34
+ SnapshotID,
35
+ TaskID,
36
+ )
37
+
38
+
39
+ class Skill(ASAPBaseModel):
40
+ """A specific capability that an agent can perform.
41
+
42
+ Skills define what an agent can do, along with the expected input
43
+ and output schemas for validation.
44
+
45
+ Attributes:
46
+ id: Unique identifier for the skill (e.g., "web_research")
47
+ description: Human-readable description of what the skill does
48
+ input_schema: Optional JSON Schema for validating skill inputs
49
+ output_schema: Optional JSON Schema for validating skill outputs
50
+
51
+ Example:
52
+ >>> skill = Skill(
53
+ ... id="web_research",
54
+ ... description="Search and synthesize information from the web",
55
+ ... input_schema={"type": "object", "properties": {"query": {"type": "string"}}},
56
+ ... output_schema={"type": "object", "properties": {"summary": {"type": "string"}}}
57
+ ... )
58
+ """
59
+
60
+ id: str = Field(..., description="Unique skill identifier")
61
+ description: str = Field(..., description="Human-readable skill description")
62
+ input_schema: dict[str, Any] | None = Field(
63
+ default=None, description="JSON Schema for skill input validation"
64
+ )
65
+ output_schema: dict[str, Any] | None = Field(
66
+ default=None, description="JSON Schema for skill output validation"
67
+ )
68
+
69
+
70
+ class Capability(ASAPBaseModel):
71
+ """Collection of an agent's features and supported operations.
72
+
73
+ Capabilities describe what an agent can do, including skills,
74
+ state persistence, streaming support, and MCP tool integration.
75
+
76
+ Attributes:
77
+ asap_version: ASAP protocol version supported (e.g., "0.1")
78
+ skills: List of skills the agent can perform
79
+ state_persistence: Whether the agent supports state snapshots
80
+ streaming: Whether the agent supports streaming responses
81
+ mcp_tools: List of MCP tool names the agent can execute
82
+
83
+ Example:
84
+ >>> capability = Capability(
85
+ ... asap_version="0.1",
86
+ ... skills=[Skill(id="research", description="Research skill")],
87
+ ... state_persistence=True,
88
+ ... streaming=True,
89
+ ... mcp_tools=["web_search"]
90
+ ... )
91
+ """
92
+
93
+ asap_version: str = Field(default=ASAP_PROTOCOL_VERSION, description="ASAP protocol version")
94
+ skills: list[Skill] = Field(default_factory=list, description="Available skills")
95
+ state_persistence: bool = Field(default=False, description="Supports state snapshots")
96
+ streaming: bool = Field(default=False, description="Supports streaming responses")
97
+ mcp_tools: list[str] = Field(default_factory=list, description="Available MCP tools")
98
+
99
+
100
+ class Endpoint(ASAPBaseModel):
101
+ """Network endpoints for agent communication.
102
+
103
+ Defines the URLs where an agent can be reached for different
104
+ types of communication.
105
+
106
+ Attributes:
107
+ asap: HTTP endpoint for ASAP protocol messages (required)
108
+ events: Optional WebSocket endpoint for streaming events
109
+
110
+ Example:
111
+ >>> endpoint = Endpoint(
112
+ ... asap="https://api.example.com/asap",
113
+ ... events="wss://api.example.com/asap/events"
114
+ ... )
115
+ """
116
+
117
+ asap: str = Field(..., description="HTTP endpoint for ASAP messages")
118
+ events: str | None = Field(default=None, description="WebSocket endpoint for streaming events")
119
+
120
+
121
+ class AuthScheme(ASAPBaseModel):
122
+ """Authentication configuration for agent access.
123
+
124
+ Defines the authentication methods supported by an agent and
125
+ optional OAuth2 configuration.
126
+
127
+ Attributes:
128
+ schemes: List of supported auth schemes (e.g., ["bearer", "oauth2"])
129
+ oauth2: Optional OAuth2 configuration with URLs and scopes
130
+
131
+ Example:
132
+ >>> auth = AuthScheme(
133
+ ... schemes=["bearer", "oauth2"],
134
+ ... oauth2={
135
+ ... "authorization_url": "https://auth.example.com/authorize",
136
+ ... "token_url": "https://auth.example.com/token",
137
+ ... "scopes": ["asap:execute", "asap:read"]
138
+ ... }
139
+ ... )
140
+ """
141
+
142
+ schemes: list[str] = Field(..., description="Supported authentication schemes")
143
+ oauth2: dict[str, Any] | None = Field(
144
+ default=None, description="OAuth2 configuration if oauth2 is in schemes"
145
+ )
146
+
147
+
148
+ class Agent(ASAPBaseModel):
149
+ """An autonomous entity capable of sending and receiving ASAP messages.
150
+
151
+ Agents are symmetric peers with no inherent client/server distinction.
152
+ Each agent has a unique identifier and publishes a manifest describing
153
+ its capabilities.
154
+
155
+ Attributes:
156
+ id: Unique agent identifier (URN format, e.g., "urn:asap:agent:research-v1")
157
+ manifest_uri: URL where the agent's manifest can be retrieved
158
+ capabilities: List of capability strings (e.g., ["task.execute", "mcp.tools"])
159
+
160
+ Example:
161
+ >>> agent = Agent(
162
+ ... id="urn:asap:agent:research-v1",
163
+ ... manifest_uri="https://agents.example.com/.well-known/asap/research-v1.json",
164
+ ... capabilities=["task.execute", "state.persist", "mcp.tools"]
165
+ ... )
166
+ """
167
+
168
+ id: AgentURN = Field(..., description="Unique agent identifier (URN format)")
169
+ manifest_uri: str = Field(..., description="URL to agent's manifest")
170
+ capabilities: list[str] = Field(..., min_length=1, description="Agent capability strings")
171
+
172
+
173
+ class Manifest(ASAPBaseModel):
174
+ """Self-describing metadata about an agent's capabilities.
175
+
176
+ The manifest is analogous to A2A's Agent Card but extended with
177
+ additional ASAP-specific features. It provides all information
178
+ needed to interact with an agent.
179
+
180
+ Attributes:
181
+ id: Unique agent identifier (matches Agent.id)
182
+ name: Human-readable agent name
183
+ version: Semantic version of the agent
184
+ description: Description of what the agent does
185
+ capabilities: Detailed capability information
186
+ endpoints: Network endpoints for communication
187
+ auth: Optional authentication configuration
188
+ signature: Optional cryptographic signature for manifest verification
189
+
190
+ Example:
191
+ >>> manifest = Manifest(
192
+ ... id="urn:asap:agent:research-v1",
193
+ ... name="Research Agent",
194
+ ... version="1.0.0",
195
+ ... description="Performs web research and summarization",
196
+ ... capabilities=Capability(
197
+ ... asap_version="0.1",
198
+ ... skills=[Skill(id="web_research", description="Research skill")],
199
+ ... state_persistence=True
200
+ ... ),
201
+ ... endpoints=Endpoint(asap="https://api.example.com/asap")
202
+ ... )
203
+ """
204
+
205
+ id: AgentURN = Field(..., description="Unique agent identifier (URN format)")
206
+ name: str = Field(..., description="Human-readable agent name")
207
+ version: SemanticVersion = Field(..., description="Semantic version (e.g., '1.0.0')")
208
+ description: str = Field(..., description="What the agent does")
209
+ capabilities: Capability = Field(..., description="Agent capabilities")
210
+ endpoints: Endpoint = Field(..., description="Communication endpoints")
211
+ auth: AuthScheme | None = Field(default=None, description="Authentication configuration")
212
+ signature: str | None = Field(
213
+ default=None, description="Cryptographic signature for verification"
214
+ )
215
+
216
+ @field_validator("id")
217
+ @classmethod
218
+ def validate_urn_format(cls, v: str) -> str:
219
+ """Validate that agent ID follows URN format."""
220
+ if not re.match(AGENT_URN_PATTERN, v):
221
+ raise ValueError(f"Agent ID must follow URN format 'urn:asap:agent:{{name}}', got: {v}")
222
+ return v
223
+
224
+ @field_validator("version")
225
+ @classmethod
226
+ def validate_semver(cls, v: str) -> str:
227
+ """Validate semantic versioning format."""
228
+ try:
229
+ Version(v)
230
+ except InvalidVersion as e:
231
+ raise ValueError(f"Invalid semantic version '{v}': {e}") from e
232
+ return v
233
+
234
+
235
+ class Conversation(ASAPBaseModel):
236
+ """A context for related interactions between agents.
237
+
238
+ Conversations enable shared context accumulation, task grouping,
239
+ and state isolation between unrelated work.
240
+
241
+ Attributes:
242
+ id: Unique conversation identifier (ULID format)
243
+ participants: List of agent URNs participating in the conversation
244
+ created_at: Timestamp when the conversation was created (UTC)
245
+ metadata: Optional metadata (e.g., purpose, TTL, tags)
246
+
247
+ Example:
248
+ >>> from datetime import datetime, timezone
249
+ >>> conversation = Conversation(
250
+ ... id="conv_01HX5K3MQVN8...",
251
+ ... participants=["urn:asap:agent:coordinator", "urn:asap:agent:research-v1"],
252
+ ... created_at=datetime.now(timezone.utc),
253
+ ... metadata={"purpose": "quarterly_report_research", "ttl_hours": 72}
254
+ ... )
255
+ """
256
+
257
+ id: ConversationID = Field(..., description="Unique conversation identifier (ULID)")
258
+ participants: list[AgentURN] = Field(
259
+ ..., min_length=1, description="Agent URNs in conversation"
260
+ )
261
+ created_at: datetime = Field(..., description="Creation timestamp (UTC)")
262
+ metadata: dict[str, Any] | None = Field(
263
+ default=None, description="Optional metadata (purpose, TTL, etc.)"
264
+ )
265
+
266
+
267
+ class Task(ASAPBaseModel):
268
+ """The fundamental unit of work in ASAP.
269
+
270
+ Tasks are uniquely identified, stateful with a defined lifecycle,
271
+ cancellable, resumable, and capable of producing artifacts.
272
+
273
+ Attributes:
274
+ id: Unique task identifier (ULID format)
275
+ conversation_id: ID of the conversation this task belongs to
276
+ parent_task_id: Optional ID of parent task (for subtasks)
277
+ status: Current task status (submitted, working, completed, etc.)
278
+ progress: Optional progress information (percent, message, ETA)
279
+ created_at: Timestamp when the task was created (UTC)
280
+ updated_at: Timestamp of last status update (UTC)
281
+
282
+ Example:
283
+ >>> from datetime import datetime, timezone
284
+ >>> task = Task(
285
+ ... id="task_01HX5K4N...",
286
+ ... conversation_id="conv_01HX5K3MQVN8...",
287
+ ... status="working",
288
+ ... progress={"percent": 45, "message": "Analyzing search results..."},
289
+ ... created_at=datetime.now(timezone.utc),
290
+ ... updated_at=datetime.now(timezone.utc)
291
+ ... )
292
+ """
293
+
294
+ id: TaskID = Field(..., description="Unique task identifier (ULID)")
295
+ conversation_id: ConversationID = Field(..., description="Parent conversation ID")
296
+ parent_task_id: TaskID | None = Field(default=None, description="Parent task ID for subtasks")
297
+ status: TaskStatus = Field(..., description="Task status (submitted, working, etc.)")
298
+ progress: dict[str, Any] | None = Field(
299
+ default=None, description="Progress info (percent, message, ETA)"
300
+ )
301
+ created_at: datetime = Field(..., description="Creation timestamp (UTC)")
302
+ updated_at: datetime = Field(..., description="Last update timestamp (UTC)")
303
+
304
+ def is_terminal(self) -> bool:
305
+ """Check if task is in a terminal state (completed, failed, or cancelled)."""
306
+ return self.status.is_terminal()
307
+
308
+ def can_be_cancelled(self) -> bool:
309
+ """Check if task can be cancelled (only submitted or working tasks)."""
310
+ return self.status in {TaskStatus.SUBMITTED, TaskStatus.WORKING}
311
+
312
+
313
+ class Message(ASAPBaseModel):
314
+ """A single communication turn containing one or more parts.
315
+
316
+ Messages are the atomic units of communication within tasks,
317
+ containing content parts and metadata about the sender and role.
318
+
319
+ Attributes:
320
+ id: Unique message identifier (ULID format)
321
+ task_id: ID of the task this message belongs to
322
+ sender: Agent URN of the message sender
323
+ role: Message role (user, assistant, system)
324
+ parts: List of part IDs or part references
325
+ timestamp: When the message was sent (UTC)
326
+
327
+ Example:
328
+ >>> from datetime import datetime, timezone
329
+ >>> message = Message(
330
+ ... id="msg_01HX5K5P...",
331
+ ... task_id="task_01HX5K4N...",
332
+ ... sender="urn:asap:agent:coordinator",
333
+ ... role="user",
334
+ ... parts=["part_01HX5K...", "part_01HX5L..."],
335
+ ... timestamp=datetime.now(timezone.utc)
336
+ ... )
337
+ """
338
+
339
+ id: MessageID = Field(..., description="Unique message identifier (ULID)")
340
+ task_id: TaskID = Field(..., description="Parent task ID")
341
+ sender: AgentURN = Field(..., description="Sender agent URN")
342
+ role: MessageRole = Field(..., description="Message role (user, assistant, system)")
343
+ parts: list[PartID] = Field(..., description="Part IDs or references")
344
+ timestamp: datetime = Field(..., description="Message timestamp (UTC)")
345
+
346
+
347
+ class Artifact(ASAPBaseModel):
348
+ """Concrete output produced by task execution.
349
+
350
+ Artifacts represent the tangible results of task completion,
351
+ such as reports, data files, or other generated content.
352
+
353
+ Attributes:
354
+ id: Unique artifact identifier (ULID format)
355
+ task_id: ID of the task that produced this artifact
356
+ name: Human-readable artifact name
357
+ parts: List of part IDs that make up this artifact
358
+ created_at: When the artifact was created (UTC)
359
+
360
+ Example:
361
+ >>> from datetime import datetime, timezone
362
+ >>> artifact = Artifact(
363
+ ... id="art_01HX5K6Q...",
364
+ ... task_id="task_01HX5K4N...",
365
+ ... name="Q3 Market Analysis Report",
366
+ ... parts=["part_01HX5K..."],
367
+ ... created_at=datetime.now(timezone.utc)
368
+ ... )
369
+ """
370
+
371
+ id: ArtifactID = Field(..., description="Unique artifact identifier (ULID)")
372
+ task_id: TaskID = Field(..., description="Parent task ID")
373
+ name: str = Field(..., description="Human-readable artifact name")
374
+ parts: list[PartID] = Field(..., description="Part IDs making up this artifact")
375
+ created_at: datetime = Field(..., description="Creation timestamp (UTC)")
376
+
377
+
378
+ class StateSnapshot(ASAPBaseModel):
379
+ """First-class state persistence mechanism.
380
+
381
+ StateSnapshots enable task state to be saved and restored,
382
+ addressing a key limitation in other agent protocols. Supports
383
+ versioning and checkpoint flagging for important states.
384
+
385
+ Attributes:
386
+ id: Unique snapshot identifier (ULID format)
387
+ task_id: ID of the task this snapshot belongs to
388
+ version: Snapshot version number (auto-incremented)
389
+ data: Arbitrary state data (JSON-serializable dict)
390
+ checkpoint: Whether this is a significant checkpoint (default: False)
391
+ created_at: When the snapshot was created (UTC)
392
+
393
+ Example:
394
+ >>> from datetime import datetime, timezone
395
+ >>> snapshot = StateSnapshot(
396
+ ... id="snap_01HX5K7R...",
397
+ ... task_id="task_01HX5K4N...",
398
+ ... version=3,
399
+ ... data={"search_completed": True, "sources_analyzed": 15},
400
+ ... checkpoint=True,
401
+ ... created_at=datetime.now(timezone.utc)
402
+ ... )
403
+ """
404
+
405
+ id: SnapshotID = Field(..., description="Unique snapshot identifier (ULID)")
406
+ task_id: TaskID = Field(..., description="Parent task ID")
407
+ version: int = Field(..., description="Snapshot version number", ge=1)
408
+ data: dict[str, Any] = Field(..., description="State data (JSON-serializable)")
409
+ checkpoint: bool = Field(default=False, description="Whether this is a significant checkpoint")
410
+ created_at: datetime = Field(..., description="Creation timestamp (UTC)")
asap/models/enums.py ADDED
@@ -0,0 +1,71 @@
1
+ """Enumerations for ASAP protocol.
2
+
3
+ This module defines all enum types used in the protocol to ensure
4
+ type safety and prevent magic strings.
5
+ """
6
+
7
+ from enum import Enum
8
+
9
+
10
+ class TaskStatus(str, Enum):
11
+ """Task lifecycle states.
12
+
13
+ Tasks progress through these states during their lifecycle.
14
+ Terminal states are: COMPLETED, FAILED, CANCELLED.
15
+
16
+ Example:
17
+ >>> TaskStatus.COMPLETED.is_terminal()
18
+ True
19
+ >>> TaskStatus.WORKING.is_terminal()
20
+ False
21
+ """
22
+
23
+ SUBMITTED = "submitted"
24
+ WORKING = "working"
25
+ COMPLETED = "completed"
26
+ FAILED = "failed"
27
+ CANCELLED = "cancelled"
28
+ INPUT_REQUIRED = "input_required"
29
+
30
+ @classmethod
31
+ def terminal_states(cls) -> frozenset["TaskStatus"]:
32
+ """Return all terminal states.
33
+
34
+ Returns:
35
+ Frozen set containing all terminal task states
36
+ """
37
+ return frozenset({cls.COMPLETED, cls.FAILED, cls.CANCELLED})
38
+
39
+ def is_terminal(self) -> bool:
40
+ """Check if this status represents a terminal state."""
41
+ return self in self.terminal_states()
42
+
43
+
44
+ class MessageRole(str, Enum):
45
+ """Message sender roles.
46
+
47
+ Defines the role of the entity sending a message in a conversation.
48
+
49
+ Example:
50
+ >>> MessageRole.USER.value
51
+ 'user'
52
+ """
53
+
54
+ USER = "user"
55
+ ASSISTANT = "assistant"
56
+ SYSTEM = "system"
57
+
58
+
59
+ class UpdateType(str, Enum):
60
+ """Task update types.
61
+
62
+ Defines the type of update being sent for a task.
63
+
64
+ Example:
65
+ >>> UpdateType.PROGRESS.value
66
+ 'progress'
67
+ """
68
+
69
+ PROGRESS = "progress"
70
+ INPUT_REQUIRED = "input_required"
71
+ STATUS_CHANGE = "status_change"
@@ -0,0 +1,94 @@
1
+ """Envelope model for ASAP protocol messages.
2
+
3
+ The Envelope wraps all ASAP protocol messages, providing metadata for
4
+ routing, correlation, tracing, and versioning.
5
+ """
6
+
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from pydantic import Field, field_validator, model_validator
11
+
12
+ from asap.models.base import ASAPBaseModel
13
+ from asap.models.ids import generate_id
14
+ from asap.models.types import AgentURN
15
+
16
+
17
+ class Envelope(ASAPBaseModel):
18
+ """ASAP protocol message envelope.
19
+
20
+ Envelope wraps all protocol messages with metadata for routing,
21
+ correlation, tracing, and versioning. Auto-generates id and timestamp
22
+ if not provided.
23
+
24
+ Attributes:
25
+ id: Unique envelope identifier (auto-generated if not provided)
26
+ asap_version: ASAP protocol version (e.g., "0.1")
27
+ timestamp: Message timestamp in UTC (auto-generated if not provided)
28
+ sender: Sender agent URN
29
+ recipient: Recipient agent URN
30
+ payload_type: Type of payload (TaskRequest, TaskResponse, etc.)
31
+ payload: Actual message payload
32
+ correlation_id: Optional ID for correlating request/response pairs
33
+ trace_id: Optional ID for distributed tracing
34
+ extensions: Optional custom extensions
35
+
36
+ Example:
37
+ >>> from datetime import datetime, timezone
38
+ >>> envelope = Envelope(
39
+ ... asap_version="0.1",
40
+ ... sender="urn:asap:agent:coordinator",
41
+ ... recipient="urn:asap:agent:research-v1",
42
+ ... payload_type="TaskRequest",
43
+ ... payload={"conversation_id": "conv_123", "skill_id": "research", "input": {}}
44
+ ... )
45
+ >>> # id and timestamp are auto-generated
46
+ >>> assert envelope.id is not None
47
+ >>> assert envelope.timestamp is not None
48
+ """
49
+
50
+ id: str | None = Field(
51
+ default=None, description="Unique envelope identifier (ULID, auto-generated)"
52
+ )
53
+ asap_version: str = Field(..., description="ASAP protocol version")
54
+ timestamp: datetime | None = Field(
55
+ default=None, description="Message timestamp (UTC, auto-generated)"
56
+ )
57
+ sender: AgentURN = Field(..., description="Sender agent URN")
58
+ recipient: AgentURN = Field(..., description="Recipient agent URN")
59
+ payload_type: str = Field(..., description="Payload type discriminator")
60
+ payload: dict[str, Any] = Field(..., description="Message payload")
61
+ correlation_id: str | None = Field(
62
+ default=None, description="Optional correlation ID for request/response pairing"
63
+ )
64
+ trace_id: str | None = Field(
65
+ default=None, description="Optional trace ID for distributed tracing"
66
+ )
67
+ extensions: dict[str, Any] | None = Field(
68
+ default=None, description="Optional custom extensions"
69
+ )
70
+
71
+ @field_validator("id", mode="before")
72
+ @classmethod
73
+ def generate_id_if_missing(cls, v: str | None) -> str:
74
+ """Auto-generate ID if not provided."""
75
+ if v is None:
76
+ return generate_id()
77
+ return v
78
+
79
+ @field_validator("timestamp", mode="before")
80
+ @classmethod
81
+ def generate_timestamp_if_missing(cls, v: datetime | None) -> datetime:
82
+ """Auto-generate timestamp if not provided."""
83
+ if v is None:
84
+ return datetime.now(timezone.utc)
85
+ return v
86
+
87
+ @model_validator(mode="after")
88
+ def validate_response_correlation(self) -> "Envelope":
89
+ """Validate that response payloads have correlation_id for request tracking."""
90
+ response_types = {"TaskResponse", "McpToolResult", "McpResourceData"}
91
+
92
+ if self.payload_type in response_types and not self.correlation_id:
93
+ raise ValueError(f"{self.payload_type} must have correlation_id for request tracking")
94
+ return self
asap/models/ids.py ADDED
@@ -0,0 +1,55 @@
1
+ """ULID-based ID generation for ASAP protocol entities.
2
+
3
+ ULIDs (Universally Unique Lexicographically Sortable Identifiers) provide:
4
+ - 128-bit compatibility with UUID
5
+ - Lexicographic sorting by timestamp
6
+ - Canonically encoded as 26-character string (Crockford's Base32)
7
+ - Monotonically increasing within the same millisecond
8
+ """
9
+
10
+ from datetime import datetime
11
+
12
+ from ulid import ULID
13
+
14
+
15
+ def generate_id() -> str:
16
+ """Generate a new ULID string.
17
+
18
+ Returns:
19
+ A 26-character ULID string that is:
20
+ - Globally unique
21
+ - Lexicographically sortable by creation time
22
+ - URL-safe (uses Crockford's Base32 alphabet)
23
+
24
+ Example:
25
+ >>> id1 = generate_id()
26
+ >>> len(id1)
27
+ 26
28
+ >>> id2 = generate_id()
29
+ >>> id1 < id2 # Sortable by time
30
+ True
31
+ """
32
+ return str(ULID())
33
+
34
+
35
+ def extract_timestamp(ulid: str) -> datetime:
36
+ """Extract the timestamp from a ULID string.
37
+
38
+ Args:
39
+ ulid: A 26-character ULID string
40
+
41
+ Returns:
42
+ A timezone-aware datetime in UTC representing when the ULID was created
43
+
44
+ Raises:
45
+ ValueError: If the ULID string is invalid
46
+
47
+ Example:
48
+ >>> ulid = generate_id()
49
+ >>> timestamp = extract_timestamp(ulid)
50
+ >>> timestamp.tzinfo == timezone.utc
51
+ True
52
+ """
53
+ ulid_obj = ULID.from_str(ulid)
54
+ # ULID.datetime returns a timezone-aware datetime in UTC
55
+ return ulid_obj.datetime