axio 0.11.3__tar.gz → 0.12.0__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.
- {axio-0.11.3 → axio-0.12.0}/.gitignore +1 -0
- {axio-0.11.3 → axio-0.12.0}/PKG-INFO +1 -1
- {axio-0.11.3 → axio-0.12.0}/pyproject.toml +1 -1
- {axio-0.11.3 → axio-0.12.0}/src/axio/agent.py +173 -7
- axio-0.12.0/src/axio/credentials/__init__.py +15 -0
- axio-0.12.0/src/axio/credentials/codec.py +148 -0
- axio-0.12.0/src/axio/credentials/files.py +148 -0
- axio-0.12.0/src/axio/credentials/stores.py +326 -0
- axio-0.12.0/src/axio/credentials/types.py +64 -0
- axio-0.12.0/src/axio/instructions.py +151 -0
- axio-0.12.0/src/axio/permission_policy.py +319 -0
- axio-0.12.0/src/axio/plugins.py +233 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/stream.py +4 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/testing.py +33 -1
- {axio-0.11.3 → axio-0.12.0}/src/axio/tool.py +83 -3
- axio-0.12.0/src/axio/tool_access.py +50 -0
- axio-0.12.0/src/axio/transport.py +383 -0
- axio-0.12.0/tests/test_agent_instructions.py +304 -0
- axio-0.12.0/tests/test_argument_coercion.py +152 -0
- axio-0.12.0/tests/test_credentials.py +189 -0
- axio-0.12.0/tests/test_instructions.py +107 -0
- axio-0.12.0/tests/test_permission_policy.py +281 -0
- axio-0.12.0/tests/test_plugins.py +220 -0
- axio-0.12.0/tests/test_preflight.py +137 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_stream.py +13 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_testing.py +68 -0
- axio-0.12.0/tests/test_transport_plugins.py +113 -0
- axio-0.11.3/src/axio/transport.py +0 -211
- {axio-0.11.3 → axio-0.12.0}/LICENSE +0 -0
- {axio-0.11.3 → axio-0.12.0}/Makefile +0 -0
- {axio-0.11.3 → axio-0.12.0}/README.md +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/__init__.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/agent_loader.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/blocks.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/compaction.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/context.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/events.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/exceptions.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/field.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/messages.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/models.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/permission.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/py.typed +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/realtime.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/retry.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/schema.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/selector.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/tool_args.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/src/axio/types.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/conftest.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_branch.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_capabilities.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_loader.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_permission.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_run.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_stream.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_agent_tools.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_blocks.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_compaction.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_context.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_events.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_exceptions.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_field.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_init.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_models.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_permission.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_realtime.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_retry.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_schema.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_selector.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_tool.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_tool_args.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_transport.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/tests/test_types.py +0 -0
- {axio-0.11.3 → axio-0.12.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: axio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Minimal, streaming-first, protocol-driven foundation for LLM-powered agents
|
|
5
5
|
Project-URL: Documentation, https://docs.axio-agent.com
|
|
6
6
|
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0" # bumped to the git tag by CI on publish
|
|
4
4
|
description = "Minimal, streaming-first, protocol-driven foundation for LLM-powered agents"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -7,7 +7,7 @@ import dataclasses
|
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
9
|
import time
|
|
10
|
-
from collections.abc import AsyncGenerator, AsyncIterator, Iterable, Iterator
|
|
10
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable, Iterator, Mapping
|
|
11
11
|
from contextlib import aclosing, suppress
|
|
12
12
|
from dataclasses import dataclass, field, replace
|
|
13
13
|
from datetime import datetime
|
|
@@ -45,11 +45,15 @@ from .events import (
|
|
|
45
45
|
VideoOutput,
|
|
46
46
|
)
|
|
47
47
|
from .exceptions import StreamError
|
|
48
|
+
from .instructions import LOAD_INSTRUCTION_TOOL, Instruction, InstructionState, LoadedInstruction
|
|
48
49
|
from .messages import Message
|
|
49
50
|
from .models import Capability
|
|
51
|
+
from .permission import PermissionGuard
|
|
52
|
+
from .plugins import ToolProvider, collect_tools
|
|
50
53
|
from .selector import ToolSelector
|
|
51
54
|
from .stream import AgentStream
|
|
52
55
|
from .tool import Tool
|
|
56
|
+
from .tool_access import Access
|
|
53
57
|
from .transport import CompletionTransport
|
|
54
58
|
from .types import StopReason, Usage
|
|
55
59
|
|
|
@@ -281,9 +285,100 @@ class Agent:
|
|
|
281
285
|
selector: ToolSelector | None = field(default=None)
|
|
282
286
|
max_iterations: int = field(default=50)
|
|
283
287
|
last_iteration_message: Message | None = field(default=None)
|
|
288
|
+
before_turn: Callable[[int], Awaitable[None]] | None = field(default=None, kw_only=True)
|
|
289
|
+
before_tools: Callable[[list[ToolUseBlock], int], Awaitable[list[ToolResultBlock]]] | None = field(
|
|
290
|
+
default=None, kw_only=True
|
|
291
|
+
)
|
|
292
|
+
providers: list[ToolProvider] = field(default_factory=list, kw_only=True)
|
|
293
|
+
instructions: list[Instruction] = field(default_factory=list, kw_only=True)
|
|
294
|
+
required_instructions: tuple[str, ...] = field(default=(), kw_only=True)
|
|
295
|
+
before_turn_hooks: tuple[Callable[[int], Awaitable[None]], ...] = field(default=(), kw_only=True)
|
|
296
|
+
before_tools_hooks: tuple[Callable[[list[ToolUseBlock], int], Awaitable[list[ToolResultBlock]]], ...] = field(
|
|
297
|
+
default=(), kw_only=True
|
|
298
|
+
)
|
|
299
|
+
system_contributions: tuple[Callable[[], str], ...] = field(default=(), kw_only=True)
|
|
300
|
+
tool_guards: tuple[PermissionGuard, ...] = field(default=(), kw_only=True)
|
|
301
|
+
_instruction_state: InstructionState = field(default_factory=InstructionState, init=False, repr=False)
|
|
302
|
+
_instruction_tool: Tool[Any] | None = field(default=None, init=False, repr=False)
|
|
303
|
+
_iteration_tools: list[Tool[Any]] | None = field(default=None, init=False, repr=False)
|
|
304
|
+
_guarded_tools: dict[int, tuple[Tool[Any], tuple[PermissionGuard, ...], Tool[Any]]] = field(
|
|
305
|
+
default_factory=dict, init=False, repr=False
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _catalog(self) -> list[Instruction]:
|
|
309
|
+
return [*self.instructions, *(item for provider in self.providers for item in provider.get_instructions())]
|
|
310
|
+
|
|
311
|
+
def _base_tools(self) -> list[Tool[Any]]:
|
|
312
|
+
tools = collect_tools(self.providers, tools=self.tools)
|
|
313
|
+
if not self._catalog():
|
|
314
|
+
return tools
|
|
315
|
+
if self._instruction_tool is None:
|
|
316
|
+
self._instruction_tool = Tool(
|
|
317
|
+
name=LOAD_INSTRUCTION_TOOL, handler=self._load_instruction_tool, access=self._instruction_access
|
|
318
|
+
)
|
|
319
|
+
return [*tools, self._instruction_tool]
|
|
320
|
+
|
|
321
|
+
def get_tools(self) -> list[Tool[Any]]:
|
|
322
|
+
"""Return collected tools before selection, including activated instructions."""
|
|
323
|
+
self._instruction_state.synchronize(self._catalog())
|
|
324
|
+
return self._apply_tool_guards(
|
|
325
|
+
self._instruction_state.collect_tools(self._base_tools(), self._instruction_state.loaded.values())
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _instruction_access(self, values: Mapping[str, Any]) -> tuple[Access, ...]:
|
|
329
|
+
id = values.get("id")
|
|
330
|
+
for instruction in self._catalog():
|
|
331
|
+
if instruction.id == id:
|
|
332
|
+
return instruction.load_access()
|
|
333
|
+
raise ValueError(f"Unknown instruction: {id!r}")
|
|
334
|
+
|
|
335
|
+
async def load_instruction(self, id: str) -> LoadedInstruction:
|
|
336
|
+
"""Load provider content and stage it for the next model iteration."""
|
|
337
|
+
self._instruction_state.synchronize(self._catalog())
|
|
338
|
+
return await self._instruction_state.load(id, self._base_tools())
|
|
339
|
+
|
|
340
|
+
async def _load_instruction_tool(self, id: str) -> str:
|
|
341
|
+
"""Load a listed instruction and make its associated tools available on the next turn."""
|
|
342
|
+
return (await self.load_instruction(id)).text
|
|
343
|
+
|
|
344
|
+
async def _prepare_instructions(self) -> list[Tool[Any]]:
|
|
345
|
+
self._instruction_state.synchronize(self._catalog())
|
|
346
|
+
try:
|
|
347
|
+
for id in self.required_instructions:
|
|
348
|
+
await self.load_instruction(id)
|
|
349
|
+
return self._instruction_state.commit(self._base_tools())
|
|
350
|
+
except BaseException:
|
|
351
|
+
self._instruction_state.pending.clear()
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
def _effective_system(self) -> str:
|
|
355
|
+
parts = (self.system, *(part() for part in self.system_contributions), self._instruction_state.render())
|
|
356
|
+
return "\n\n".join(filter(None, parts))
|
|
357
|
+
|
|
358
|
+
def _apply_tool_guards(self, tools: list[Tool[Any]]) -> list[Tool[Any]]:
|
|
359
|
+
"""Append application guards to every tool, including newly loaded tools."""
|
|
360
|
+
if not self.tool_guards:
|
|
361
|
+
self._guarded_tools.clear()
|
|
362
|
+
return tools
|
|
363
|
+
cached: dict[int, tuple[Tool[Any], tuple[PermissionGuard, ...], Tool[Any]]] = {}
|
|
364
|
+
result: list[Tool[Any]] = []
|
|
365
|
+
for tool in tools:
|
|
366
|
+
entry = self._guarded_tools.get(id(tool))
|
|
367
|
+
if entry is None or entry[0] is not tool or entry[1] != self.tool_guards:
|
|
368
|
+
guards = (*tool.guards, *(guard for guard in self.tool_guards if guard not in tool.guards))
|
|
369
|
+
guarded = replace(tool, guards=guards)
|
|
370
|
+
# Guard wrappers share the provider tool's concurrency budget across agents.
|
|
371
|
+
object.__setattr__(guarded, "_semaphore", tool._semaphore)
|
|
372
|
+
entry = (tool, self.tool_guards, guarded)
|
|
373
|
+
cached[id(tool)] = entry
|
|
374
|
+
result.append(entry[2])
|
|
375
|
+
self._guarded_tools = cached
|
|
376
|
+
return result
|
|
284
377
|
|
|
285
378
|
def copy(self, **overrides: Any) -> Self:
|
|
286
|
-
"""Return a new Agent with *overrides* applied."""
|
|
379
|
+
"""Return a new Agent with fresh instruction state and *overrides* applied."""
|
|
380
|
+
overrides.setdefault("instructions", list(self.instructions))
|
|
381
|
+
overrides.setdefault("providers", list(self.providers))
|
|
287
382
|
return dataclasses.replace(self, **overrides)
|
|
288
383
|
|
|
289
384
|
def run_stream(self, user_message: str, context: ContextStore) -> AgentStream:
|
|
@@ -315,11 +410,54 @@ class Agent:
|
|
|
315
410
|
|
|
316
411
|
async def dispatch_tools(self, blocks: list[ToolUseBlock], iteration: int) -> list[ToolResultBlock]:
|
|
317
412
|
logger.info("Dispatching %d tool(s): %r", len(blocks), blocks)
|
|
318
|
-
|
|
413
|
+
runnable, deferred = await self._preflight_tools(blocks, iteration)
|
|
414
|
+
results = [*deferred, *await asyncio.gather(*[self._call_one(b) for b in runnable])]
|
|
319
415
|
error_count = sum(1 for r in results if r.is_error)
|
|
320
416
|
logger.info("Tools complete: %d total, %d errors", len(results), error_count)
|
|
321
417
|
return results
|
|
322
418
|
|
|
419
|
+
async def _preflight_tools(
|
|
420
|
+
self, blocks: list[ToolUseBlock], iteration: int
|
|
421
|
+
) -> tuple[list[ToolUseBlock], list[ToolResultBlock]]:
|
|
422
|
+
"""Get replacement results before any handler in this batch can run.
|
|
423
|
+
|
|
424
|
+
The callback can defer all calls or a subset. Each returned result must
|
|
425
|
+
refer to a distinct call in this batch. Exceptions fail the whole batch;
|
|
426
|
+
cancellation propagates to the normal interrupted-result handling.
|
|
427
|
+
"""
|
|
428
|
+
hooks = (*((self.before_tools,) if self.before_tools is not None else ()), *self.before_tools_hooks)
|
|
429
|
+
if not hooks:
|
|
430
|
+
return blocks, []
|
|
431
|
+
try:
|
|
432
|
+
results: list[ToolResultBlock] = []
|
|
433
|
+
remaining = blocks
|
|
434
|
+
for hook in hooks:
|
|
435
|
+
if not remaining:
|
|
436
|
+
break
|
|
437
|
+
replacement = await hook(remaining, iteration)
|
|
438
|
+
ids = [result.tool_use_id for result in replacement]
|
|
439
|
+
deferred_ids = set(ids)
|
|
440
|
+
if len(deferred_ids) != len(ids) or not deferred_ids <= {block.id for block in remaining}:
|
|
441
|
+
raise ValueError("Tool preflight returned duplicate or unknown call IDs")
|
|
442
|
+
results.extend(replacement)
|
|
443
|
+
remaining = [block for block in remaining if block.id not in deferred_ids]
|
|
444
|
+
if self._instruction_state.pending:
|
|
445
|
+
return [], [
|
|
446
|
+
ToolResultBlock(
|
|
447
|
+
tool_use_id=block.id,
|
|
448
|
+
content="Instructions changed. Review the updated instructions before retrying this batch.",
|
|
449
|
+
is_error=True,
|
|
450
|
+
)
|
|
451
|
+
for block in blocks
|
|
452
|
+
]
|
|
453
|
+
except Exception as error:
|
|
454
|
+
self._instruction_state.pending.clear()
|
|
455
|
+
return [], [
|
|
456
|
+
ToolResultBlock(tool_use_id=block.id, content=f"Tool preflight failed: {error}", is_error=True)
|
|
457
|
+
for block in blocks
|
|
458
|
+
]
|
|
459
|
+
return remaining, results
|
|
460
|
+
|
|
323
461
|
async def _dispatch_tools_streaming(
|
|
324
462
|
self,
|
|
325
463
|
blocks: list[ToolUseBlock],
|
|
@@ -328,6 +466,7 @@ class Agent:
|
|
|
328
466
|
) -> list[ToolResultBlock]:
|
|
329
467
|
"""Like dispatch_tools but pushes ToolOutputDelta events for streaming tools."""
|
|
330
468
|
logger.info("Dispatching %d tool(s) with streaming: %r", len(blocks), blocks)
|
|
469
|
+
runnable, deferred = await self._preflight_tools(blocks, iteration)
|
|
331
470
|
|
|
332
471
|
async def _run_one(block: ToolUseBlock) -> ToolResultBlock:
|
|
333
472
|
tool = self._find_tool(block.name)
|
|
@@ -345,13 +484,24 @@ class Agent:
|
|
|
345
484
|
return ToolResultBlock(tool_use_id=block.id, content=str(exc), is_error=True)
|
|
346
485
|
return ToolResultBlock(tool_use_id=block.id, content=tool.format_stream_result(chunks))
|
|
347
486
|
|
|
348
|
-
results =
|
|
487
|
+
results = [*deferred, *await asyncio.gather(*[_run_one(b) for b in runnable])]
|
|
349
488
|
error_count = sum(1 for r in results if r.is_error)
|
|
350
489
|
logger.info("Tools complete: %d total, %d errors", len(results), error_count)
|
|
351
490
|
return results
|
|
352
491
|
|
|
353
492
|
def _find_tool(self, name: str) -> Tool[Any] | None:
|
|
354
|
-
|
|
493
|
+
return self.get_tool(name)
|
|
494
|
+
|
|
495
|
+
def get_tool(self, name: str) -> Tool[Any] | None:
|
|
496
|
+
"""Return the dispatchable tool, including its access declaration and guards.
|
|
497
|
+
|
|
498
|
+
During a run this uses the current iteration's selected tool set.
|
|
499
|
+
Preflight hooks can inspect effects without accessing a second registry.
|
|
500
|
+
"""
|
|
501
|
+
tools = self._iteration_tools
|
|
502
|
+
if tools is None:
|
|
503
|
+
tools = self.get_tools()
|
|
504
|
+
for tool in tools:
|
|
355
505
|
if tool.name == name:
|
|
356
506
|
return tool
|
|
357
507
|
return None
|
|
@@ -679,6 +829,18 @@ class Agent:
|
|
|
679
829
|
|
|
680
830
|
try:
|
|
681
831
|
for iteration in range(1, self.max_iterations + 1):
|
|
832
|
+
try:
|
|
833
|
+
if self.before_turn is not None:
|
|
834
|
+
await self.before_turn(iteration)
|
|
835
|
+
for hook in self.before_turn_hooks:
|
|
836
|
+
await hook(iteration)
|
|
837
|
+
tools = self._apply_tool_guards(await self._prepare_instructions())
|
|
838
|
+
system = self._effective_system()
|
|
839
|
+
except Exception as exc:
|
|
840
|
+
yield Error(exception=exc)
|
|
841
|
+
yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
|
|
842
|
+
session_end_emitted = True
|
|
843
|
+
return
|
|
682
844
|
history = await context.get_history()
|
|
683
845
|
logger.info("Iteration %d, history length=%d", iteration, len(history))
|
|
684
846
|
effective_history = (
|
|
@@ -691,10 +853,11 @@ class Agent:
|
|
|
691
853
|
if model_caps is not None and Capability.tool_use not in model_caps:
|
|
692
854
|
active_tools: list[Tool[Any]] = []
|
|
693
855
|
else:
|
|
694
|
-
active_tools = list(await self._select_tools(effective_history,
|
|
856
|
+
active_tools = list(await self._select_tools(effective_history, tools))
|
|
857
|
+
self._iteration_tools = active_tools
|
|
695
858
|
|
|
696
859
|
turn = _Turn()
|
|
697
|
-
stream = self.transport.stream(effective_history, active_tools,
|
|
860
|
+
stream = self.transport.stream(effective_history, active_tools, system)
|
|
698
861
|
try:
|
|
699
862
|
# `_consume` closes the stream in its `finally`, which runs only on close.
|
|
700
863
|
# Left to the collector, a caller that walked away held the response open.
|
|
@@ -828,3 +991,6 @@ class Agent:
|
|
|
828
991
|
if not session_end_emitted:
|
|
829
992
|
yield SessionEndEvent(stop_reason=StopReason.error, total_usage=total_usage)
|
|
830
993
|
raise
|
|
994
|
+
finally:
|
|
995
|
+
self._iteration_tools = None
|
|
996
|
+
self._instruction_state.pending.clear()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Typed credential storage shared by transport providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .codec import CredentialCodec as CredentialCodec
|
|
6
|
+
from .codec import record_id as record_id
|
|
7
|
+
from .codec import record_key as record_key
|
|
8
|
+
from .stores import Credentials as Credentials
|
|
9
|
+
from .stores import FileCredentialStore as FileCredentialStore
|
|
10
|
+
from .stores import SQLiteCredentialStore as SQLiteCredentialStore
|
|
11
|
+
from .stores import default_credentials_path as default_credentials_path
|
|
12
|
+
from .types import CredentialConflict as CredentialConflict
|
|
13
|
+
from .types import CredentialError as CredentialError
|
|
14
|
+
from .types import CredentialStore as CredentialStore
|
|
15
|
+
from .types import complete_operation as complete_operation
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Validate and serialize typed credential dataclasses without provider codecs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import types
|
|
9
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Annotated, Any, Literal, Union, cast, get_args, get_origin, get_type_hints
|
|
12
|
+
|
|
13
|
+
from .types import CredentialError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record_id(record: object) -> str:
|
|
17
|
+
"""Find the one dataclass field marked with metadata={\"credential_key\": True}."""
|
|
18
|
+
keys: list[object] = []
|
|
19
|
+
|
|
20
|
+
def visit(value: object) -> None:
|
|
21
|
+
if not is_dataclass(value) or isinstance(value, type):
|
|
22
|
+
return
|
|
23
|
+
for item in fields(value):
|
|
24
|
+
child = getattr(value, item.name)
|
|
25
|
+
if item.metadata.get("credential_key"):
|
|
26
|
+
keys.append(child)
|
|
27
|
+
else:
|
|
28
|
+
visit(child)
|
|
29
|
+
|
|
30
|
+
visit(record)
|
|
31
|
+
if len(keys) != 1 or not isinstance(keys[0], str) or not keys[0]:
|
|
32
|
+
raise CredentialError("Credentials require one nonempty string key.")
|
|
33
|
+
return keys[0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def record_key(key: str) -> str:
|
|
37
|
+
"""Hash record identifiers to keep them out of filesystem paths."""
|
|
38
|
+
if not isinstance(key, str) or not key:
|
|
39
|
+
raise CredentialError("A nonempty credential key is required.")
|
|
40
|
+
# Keep the original prefix so existing stores use the same process locks.
|
|
41
|
+
return "account-" + hashlib.sha256(key.encode()).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def json_value(value: Any) -> Any:
|
|
45
|
+
"""Convert dataclass containers to JSON values without stringifying secrets."""
|
|
46
|
+
if isinstance(value, Enum):
|
|
47
|
+
return json_value(value.value)
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
if any(not isinstance(key, str) for key in value):
|
|
50
|
+
raise ValueError
|
|
51
|
+
return {key: json_value(item) for key, item in value.items()}
|
|
52
|
+
if isinstance(value, (set, frozenset)):
|
|
53
|
+
return sorted((json_value(item) for item in value), key=lambda item: json.dumps(item, sort_keys=True))
|
|
54
|
+
if isinstance(value, (tuple, list)):
|
|
55
|
+
return [json_value(item) for item in value]
|
|
56
|
+
if value is None or type(value) in (str, int, bool):
|
|
57
|
+
return value
|
|
58
|
+
if type(value) is float and math.isfinite(value):
|
|
59
|
+
return value
|
|
60
|
+
raise ValueError
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def restore(value: Any, annotation: Any) -> Any:
|
|
64
|
+
"""Restore declared JSON-compatible types without coercing invalid values."""
|
|
65
|
+
origin, args = get_origin(annotation), get_args(annotation)
|
|
66
|
+
if origin is Annotated:
|
|
67
|
+
return restore(value, args[0])
|
|
68
|
+
if origin in (types.UnionType, Union):
|
|
69
|
+
for member in args:
|
|
70
|
+
try:
|
|
71
|
+
return restore(value, member)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
pass
|
|
74
|
+
raise ValueError
|
|
75
|
+
if origin is Literal:
|
|
76
|
+
if any(type(value) is type(choice) and value == choice for choice in args):
|
|
77
|
+
return value
|
|
78
|
+
raise ValueError
|
|
79
|
+
if annotation is type(None) and value is None:
|
|
80
|
+
return None
|
|
81
|
+
if annotation in (str, int, bool) and type(value) is annotation:
|
|
82
|
+
return value
|
|
83
|
+
if annotation is float and type(value) in (int, float) and math.isfinite(value):
|
|
84
|
+
return float(value)
|
|
85
|
+
if isinstance(annotation, type) and issubclass(annotation, Enum):
|
|
86
|
+
for member in annotation:
|
|
87
|
+
if type(value) is type(member.value) and value == member.value:
|
|
88
|
+
return member
|
|
89
|
+
raise ValueError
|
|
90
|
+
if origin in (list, set, frozenset, tuple) and isinstance(value, list):
|
|
91
|
+
if origin is tuple and (not args or args[-1] is not Ellipsis):
|
|
92
|
+
if len(value) != len(args):
|
|
93
|
+
raise ValueError
|
|
94
|
+
return tuple(restore(item, kind) for item, kind in zip(value, args, strict=True))
|
|
95
|
+
return origin(restore(item, args[0]) for item in value)
|
|
96
|
+
if origin is dict and args[0] is str and isinstance(value, dict):
|
|
97
|
+
if any(not isinstance(key, str) for key in value):
|
|
98
|
+
raise ValueError
|
|
99
|
+
return {key: restore(item, args[1]) for key, item in value.items()}
|
|
100
|
+
if isinstance(annotation, type) and is_dataclass(annotation) and isinstance(value, dict):
|
|
101
|
+
declared = {item.name for item in fields(annotation) if item.init}
|
|
102
|
+
if value.keys() != declared:
|
|
103
|
+
raise ValueError
|
|
104
|
+
hints = get_type_hints(annotation, include_extras=True)
|
|
105
|
+
return annotation(**{name: restore(value[name], hints[name]) for name in declared})
|
|
106
|
+
raise ValueError
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CredentialCodec[T]:
|
|
110
|
+
"""Versioned dataclass records with safe errors and strict type checks."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, record_type: type[T]) -> None:
|
|
113
|
+
if not isinstance(record_type, type) or not is_dataclass(record_type):
|
|
114
|
+
raise TypeError("Credential records must be dataclasses.")
|
|
115
|
+
self.record_type = record_type
|
|
116
|
+
|
|
117
|
+
def from_dict(self, data: Any, key: str | None = None) -> T:
|
|
118
|
+
try:
|
|
119
|
+
if not isinstance(data, dict) or type(data.get("version")) is not int or data["version"] != 1:
|
|
120
|
+
raise ValueError
|
|
121
|
+
record = cast(
|
|
122
|
+
T, restore({name: value for name, value in data.items() if name != "version"}, self.record_type)
|
|
123
|
+
)
|
|
124
|
+
identity = record_id(record)
|
|
125
|
+
if key is not None and identity != key:
|
|
126
|
+
raise ValueError
|
|
127
|
+
return record
|
|
128
|
+
except (ValueError, TypeError, KeyError, OverflowError, RecursionError):
|
|
129
|
+
raise CredentialError("Invalid stored credential record.") from None
|
|
130
|
+
|
|
131
|
+
def to_dict(self, record: T) -> dict[str, Any]:
|
|
132
|
+
try:
|
|
133
|
+
if not isinstance(record, self.record_type) or not is_dataclass(record) or isinstance(record, type):
|
|
134
|
+
raise ValueError
|
|
135
|
+
data: dict[str, Any] = {"version": 1, **json_value(asdict(record))}
|
|
136
|
+
self.from_dict(data, record_id(record))
|
|
137
|
+
return data
|
|
138
|
+
except (ValueError, TypeError, OverflowError, RecursionError):
|
|
139
|
+
raise CredentialError("Invalid credential record.") from None
|
|
140
|
+
|
|
141
|
+
def decode(self, raw: str, key: str | None = None) -> T:
|
|
142
|
+
try:
|
|
143
|
+
return self.from_dict(json.loads(raw), key)
|
|
144
|
+
except (ValueError, TypeError, RecursionError):
|
|
145
|
+
raise CredentialError("Invalid stored credential record.") from None
|
|
146
|
+
|
|
147
|
+
def encode(self, record: T) -> str:
|
|
148
|
+
return json.dumps(self.to_dict(record), allow_nan=False, separators=(",", ":"))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Protected files and locks for local credential backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import AsyncIterator, Iterator
|
|
11
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .types import CredentialError, complete_operation
|
|
15
|
+
|
|
16
|
+
if os.name == "posix":
|
|
17
|
+
import fcntl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_file(info: os.stat_result, *, directory: bool = False) -> None:
|
|
21
|
+
expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)
|
|
22
|
+
protected = not (info.st_mode & 0o022) if directory else stat.S_IMODE(info.st_mode) == 0o600
|
|
23
|
+
if not expected or info.st_uid != os.getuid() or not protected:
|
|
24
|
+
raise CredentialError(
|
|
25
|
+
"Credential paths must be owned by this user, files mode 0600, directories not writable by others."
|
|
26
|
+
)
|
|
27
|
+
if not directory and info.st_nlink != 1:
|
|
28
|
+
raise CredentialError("Credential files must not have hard links.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def open_file(path: Path, flags: int) -> int:
|
|
32
|
+
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600)
|
|
33
|
+
try:
|
|
34
|
+
check_file(os.fstat(fd))
|
|
35
|
+
except BaseException:
|
|
36
|
+
os.close(fd)
|
|
37
|
+
raise
|
|
38
|
+
return fd
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sync_directory(path: Path) -> None:
|
|
42
|
+
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
43
|
+
try:
|
|
44
|
+
os.fsync(fd)
|
|
45
|
+
finally:
|
|
46
|
+
os.close(fd)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_directory(path: Path) -> None:
|
|
50
|
+
try:
|
|
51
|
+
path.mkdir(mode=0o700)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
make_directory(path.parent)
|
|
54
|
+
make_directory(path)
|
|
55
|
+
except FileExistsError:
|
|
56
|
+
pass
|
|
57
|
+
else:
|
|
58
|
+
sync_directory(path.parent)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProtectedDirectory:
|
|
62
|
+
def __init__(self, path: Path) -> None:
|
|
63
|
+
if os.name != "posix":
|
|
64
|
+
raise CredentialError("Local credential stores require POSIX permissions and file locks.")
|
|
65
|
+
self.path = path.absolute()
|
|
66
|
+
|
|
67
|
+
def prepare(self) -> None:
|
|
68
|
+
try:
|
|
69
|
+
make_directory(self.path)
|
|
70
|
+
check_file(self.path.lstat(), directory=True)
|
|
71
|
+
except OSError:
|
|
72
|
+
raise CredentialError("Could not open the protected credential directory.") from None
|
|
73
|
+
|
|
74
|
+
def read(self, name: str) -> str | None:
|
|
75
|
+
self.prepare()
|
|
76
|
+
try:
|
|
77
|
+
fd = open_file(self.path / name, os.O_RDONLY)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return None
|
|
80
|
+
except OSError:
|
|
81
|
+
raise CredentialError("Could not open the protected credential file.") from None
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(fd, "r", encoding="utf-8") as stream:
|
|
84
|
+
return stream.read()
|
|
85
|
+
except (OSError, UnicodeError):
|
|
86
|
+
raise CredentialError("Could not read the credential file.") from None
|
|
87
|
+
|
|
88
|
+
def write(self, name: str, raw: str) -> None:
|
|
89
|
+
self.prepare()
|
|
90
|
+
temporary: str | None = None
|
|
91
|
+
try:
|
|
92
|
+
destination = self.path / name
|
|
93
|
+
try:
|
|
94
|
+
check_file(destination.lstat())
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
pass
|
|
97
|
+
fd, temporary = tempfile.mkstemp(prefix=".credential-", dir=self.path)
|
|
98
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
99
|
+
stream.write(raw)
|
|
100
|
+
stream.flush()
|
|
101
|
+
os.fsync(stream.fileno())
|
|
102
|
+
os.replace(temporary, destination)
|
|
103
|
+
sync_directory(self.path)
|
|
104
|
+
except OSError:
|
|
105
|
+
raise CredentialError("Could not commit the credential file.") from None
|
|
106
|
+
finally:
|
|
107
|
+
if temporary is not None:
|
|
108
|
+
try:
|
|
109
|
+
Path(temporary).unlink(missing_ok=True)
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
@asynccontextmanager
|
|
114
|
+
async def lock(self, key: str) -> AsyncIterator[None]:
|
|
115
|
+
await complete_operation(asyncio.to_thread(self.prepare))
|
|
116
|
+
try:
|
|
117
|
+
fd = open_file(self.path / (key + ".lock"), os.O_RDWR | os.O_CREAT)
|
|
118
|
+
except OSError:
|
|
119
|
+
raise CredentialError("Could not open the credential lock.") from None
|
|
120
|
+
try:
|
|
121
|
+
while True:
|
|
122
|
+
try:
|
|
123
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
124
|
+
break
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
if exc.errno not in (errno.EACCES, errno.EAGAIN):
|
|
127
|
+
raise CredentialError("Could not acquire the credential lock.") from None
|
|
128
|
+
await asyncio.sleep(0.025)
|
|
129
|
+
yield
|
|
130
|
+
finally:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
|
|
133
|
+
@contextmanager
|
|
134
|
+
def blocking_lock(self, key: str) -> Iterator[None]:
|
|
135
|
+
"""Hold a short file-update lock from a worker thread."""
|
|
136
|
+
self.prepare()
|
|
137
|
+
try:
|
|
138
|
+
fd = open_file(self.path / (key + ".lock"), os.O_RDWR | os.O_CREAT)
|
|
139
|
+
except OSError:
|
|
140
|
+
raise CredentialError("Could not open the credential update lock.") from None
|
|
141
|
+
try:
|
|
142
|
+
try:
|
|
143
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
144
|
+
except OSError:
|
|
145
|
+
raise CredentialError("Could not acquire the credential update lock.") from None
|
|
146
|
+
yield
|
|
147
|
+
finally:
|
|
148
|
+
os.close(fd)
|