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.
Files changed (75) hide show
  1. {axio-0.11.3 → axio-0.12.0}/.gitignore +1 -0
  2. {axio-0.11.3 → axio-0.12.0}/PKG-INFO +1 -1
  3. {axio-0.11.3 → axio-0.12.0}/pyproject.toml +1 -1
  4. {axio-0.11.3 → axio-0.12.0}/src/axio/agent.py +173 -7
  5. axio-0.12.0/src/axio/credentials/__init__.py +15 -0
  6. axio-0.12.0/src/axio/credentials/codec.py +148 -0
  7. axio-0.12.0/src/axio/credentials/files.py +148 -0
  8. axio-0.12.0/src/axio/credentials/stores.py +326 -0
  9. axio-0.12.0/src/axio/credentials/types.py +64 -0
  10. axio-0.12.0/src/axio/instructions.py +151 -0
  11. axio-0.12.0/src/axio/permission_policy.py +319 -0
  12. axio-0.12.0/src/axio/plugins.py +233 -0
  13. {axio-0.11.3 → axio-0.12.0}/src/axio/stream.py +4 -0
  14. {axio-0.11.3 → axio-0.12.0}/src/axio/testing.py +33 -1
  15. {axio-0.11.3 → axio-0.12.0}/src/axio/tool.py +83 -3
  16. axio-0.12.0/src/axio/tool_access.py +50 -0
  17. axio-0.12.0/src/axio/transport.py +383 -0
  18. axio-0.12.0/tests/test_agent_instructions.py +304 -0
  19. axio-0.12.0/tests/test_argument_coercion.py +152 -0
  20. axio-0.12.0/tests/test_credentials.py +189 -0
  21. axio-0.12.0/tests/test_instructions.py +107 -0
  22. axio-0.12.0/tests/test_permission_policy.py +281 -0
  23. axio-0.12.0/tests/test_plugins.py +220 -0
  24. axio-0.12.0/tests/test_preflight.py +137 -0
  25. {axio-0.11.3 → axio-0.12.0}/tests/test_stream.py +13 -0
  26. {axio-0.11.3 → axio-0.12.0}/tests/test_testing.py +68 -0
  27. axio-0.12.0/tests/test_transport_plugins.py +113 -0
  28. axio-0.11.3/src/axio/transport.py +0 -211
  29. {axio-0.11.3 → axio-0.12.0}/LICENSE +0 -0
  30. {axio-0.11.3 → axio-0.12.0}/Makefile +0 -0
  31. {axio-0.11.3 → axio-0.12.0}/README.md +0 -0
  32. {axio-0.11.3 → axio-0.12.0}/src/axio/__init__.py +0 -0
  33. {axio-0.11.3 → axio-0.12.0}/src/axio/agent_loader.py +0 -0
  34. {axio-0.11.3 → axio-0.12.0}/src/axio/blocks.py +0 -0
  35. {axio-0.11.3 → axio-0.12.0}/src/axio/compaction.py +0 -0
  36. {axio-0.11.3 → axio-0.12.0}/src/axio/context.py +0 -0
  37. {axio-0.11.3 → axio-0.12.0}/src/axio/events.py +0 -0
  38. {axio-0.11.3 → axio-0.12.0}/src/axio/exceptions.py +0 -0
  39. {axio-0.11.3 → axio-0.12.0}/src/axio/field.py +0 -0
  40. {axio-0.11.3 → axio-0.12.0}/src/axio/messages.py +0 -0
  41. {axio-0.11.3 → axio-0.12.0}/src/axio/models.py +0 -0
  42. {axio-0.11.3 → axio-0.12.0}/src/axio/permission.py +0 -0
  43. {axio-0.11.3 → axio-0.12.0}/src/axio/py.typed +0 -0
  44. {axio-0.11.3 → axio-0.12.0}/src/axio/realtime.py +0 -0
  45. {axio-0.11.3 → axio-0.12.0}/src/axio/retry.py +0 -0
  46. {axio-0.11.3 → axio-0.12.0}/src/axio/schema.py +0 -0
  47. {axio-0.11.3 → axio-0.12.0}/src/axio/selector.py +0 -0
  48. {axio-0.11.3 → axio-0.12.0}/src/axio/tool_args.py +0 -0
  49. {axio-0.11.3 → axio-0.12.0}/src/axio/types.py +0 -0
  50. {axio-0.11.3 → axio-0.12.0}/tests/conftest.py +0 -0
  51. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_branch.py +0 -0
  52. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_capabilities.py +0 -0
  53. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_loader.py +0 -0
  54. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_permission.py +0 -0
  55. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_run.py +0 -0
  56. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_stream.py +0 -0
  57. {axio-0.11.3 → axio-0.12.0}/tests/test_agent_tools.py +0 -0
  58. {axio-0.11.3 → axio-0.12.0}/tests/test_blocks.py +0 -0
  59. {axio-0.11.3 → axio-0.12.0}/tests/test_compaction.py +0 -0
  60. {axio-0.11.3 → axio-0.12.0}/tests/test_context.py +0 -0
  61. {axio-0.11.3 → axio-0.12.0}/tests/test_events.py +0 -0
  62. {axio-0.11.3 → axio-0.12.0}/tests/test_exceptions.py +0 -0
  63. {axio-0.11.3 → axio-0.12.0}/tests/test_field.py +0 -0
  64. {axio-0.11.3 → axio-0.12.0}/tests/test_init.py +0 -0
  65. {axio-0.11.3 → axio-0.12.0}/tests/test_models.py +0 -0
  66. {axio-0.11.3 → axio-0.12.0}/tests/test_permission.py +0 -0
  67. {axio-0.11.3 → axio-0.12.0}/tests/test_realtime.py +0 -0
  68. {axio-0.11.3 → axio-0.12.0}/tests/test_retry.py +0 -0
  69. {axio-0.11.3 → axio-0.12.0}/tests/test_schema.py +0 -0
  70. {axio-0.11.3 → axio-0.12.0}/tests/test_selector.py +0 -0
  71. {axio-0.11.3 → axio-0.12.0}/tests/test_tool.py +0 -0
  72. {axio-0.11.3 → axio-0.12.0}/tests/test_tool_args.py +0 -0
  73. {axio-0.11.3 → axio-0.12.0}/tests/test_transport.py +0 -0
  74. {axio-0.11.3 → axio-0.12.0}/tests/test_types.py +0 -0
  75. {axio-0.11.3 → axio-0.12.0}/uv.lock +0 -0
@@ -12,3 +12,4 @@ _build/
12
12
  .DS_Store
13
13
  *.sqlite
14
14
  *.db
15
+ .axio/mcp.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: axio
3
- Version: 0.11.3
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.11.3" # bumped to the git tag by CI on publish
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
- results = list(await asyncio.gather(*[self._call_one(b) for b in blocks]))
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 = list(await asyncio.gather(*[_run_one(b) for b in blocks]))
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
- for tool in self.tools:
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, self.tools))
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, self.system)
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)