onceonly-sdk 1.2.0__py3-none-any.whl → 2.0.1__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,168 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+ from ..client import OnceOnly
8
+
9
+
10
+ def _stable_hash_args(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> str:
11
+ def default_encoder(obj: Any) -> Any:
12
+ # Pydantic v2
13
+ md = getattr(obj, "model_dump", None)
14
+ if callable(md):
15
+ try:
16
+ return md()
17
+ except Exception:
18
+ pass
19
+
20
+ # Pydantic v1
21
+ dct = getattr(obj, "dict", None)
22
+ if callable(dct):
23
+ try:
24
+ return dct()
25
+ except Exception:
26
+ pass
27
+
28
+ # Dataclasses
29
+ if hasattr(obj, "__dataclass_fields__"):
30
+ try:
31
+ import dataclasses
32
+
33
+ return dataclasses.asdict(obj)
34
+ except Exception:
35
+ pass
36
+
37
+ if isinstance(obj, (bytes, bytearray)):
38
+ return obj.hex()
39
+
40
+ return str(obj)
41
+
42
+ payload = {"args": args, "kwargs": {k: v for k, v in sorted(kwargs.items())}}
43
+
44
+ try:
45
+ raw = json.dumps(
46
+ payload,
47
+ ensure_ascii=False,
48
+ default=default_encoder,
49
+ sort_keys=True,
50
+ separators=(",", ":"),
51
+ )
52
+ except Exception:
53
+ raw = str(payload)
54
+
55
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
56
+
57
+
58
+ def _hash_tool_input(tool_input: Any) -> str:
59
+ """
60
+ Stable hash for LangChain tool_input across versions.
61
+ """
62
+ if isinstance(tool_input, dict):
63
+ return _stable_hash_args((), tool_input)
64
+ return _stable_hash_args((tool_input,), {})
65
+
66
+
67
+ def make_idempotent_tool(
68
+ tool: Any,
69
+ *,
70
+ client: OnceOnly,
71
+ key_prefix: str = "tool",
72
+ ttl: int = 86400,
73
+ meta: Optional[Dict[str, Any]] = None,
74
+ ) -> Any:
75
+ """
76
+ Optional LangChain integration (no hard dependency).
77
+ Usage: pip install langchain-core
78
+
79
+ Wraps a BaseTool so repeated calls with the same tool_input become idempotent.
80
+
81
+ Implementation detail:
82
+ - We override invoke()/ainvoke() to avoid BaseTool._run signature differences across LC versions
83
+ and to support both single-input Tool and StructuredTool.
84
+ """
85
+ try:
86
+ from langchain_core.tools import BaseTool # type: ignore
87
+ except ImportError as e:
88
+ raise ImportError("LangChain is not installed. Install langchain-core to use this integration.") from e
89
+
90
+ if not isinstance(tool, BaseTool):
91
+ raise TypeError("tool must be an instance of langchain_core.tools.BaseTool")
92
+
93
+ base_meta: Dict[str, Any] = {"tool": getattr(tool, "name", tool.__class__.__name__)}
94
+ if meta:
95
+ base_meta.update(meta)
96
+
97
+ class IdempotentTool(BaseTool): # type: ignore[misc]
98
+ name: str
99
+ description: str
100
+
101
+ _tool: BaseTool
102
+ _client: OnceOnly
103
+ _key_prefix: str
104
+ _ttl: int
105
+ _meta: Dict[str, Any]
106
+
107
+ def __init__(self) -> None:
108
+ super().__init__(name=tool.name, description=tool.description)
109
+ object.__setattr__(self, "_tool", tool)
110
+ object.__setattr__(self, "_client", client)
111
+ object.__setattr__(self, "_key_prefix", key_prefix)
112
+ object.__setattr__(self, "_ttl", int(ttl))
113
+ object.__setattr__(self, "_meta", base_meta)
114
+
115
+ # Preserve schema + a few common attrs
116
+ if hasattr(tool, "args_schema"):
117
+ try:
118
+ object.__setattr__(self, "args_schema", getattr(tool, "args_schema"))
119
+ except Exception:
120
+ pass
121
+
122
+ for attr in ("return_direct", "tags", "metadata", "callbacks", "verbose"):
123
+ if hasattr(tool, attr):
124
+ try:
125
+ setattr(self, attr, getattr(tool, attr))
126
+ except Exception:
127
+ pass
128
+
129
+ def invoke(self, tool_input: Any, config: Any = None, **kwargs: Any) -> Any: # type: ignore[override]
130
+ h = _hash_tool_input(tool_input)
131
+ key = f"{self._key_prefix}:{self.name}:{h}"
132
+
133
+ res = self._client.check_lock(key=key, ttl=self._ttl, meta=self._meta)
134
+ if res.duplicate:
135
+ return f"Action '{self.name}' skipped (idempotency key duplicate)."
136
+
137
+ # Delegate: let LangChain handle parsing/validation/config
138
+ if config is None:
139
+ return self._tool.invoke(tool_input, **kwargs)
140
+ return self._tool.invoke(tool_input, config=config, **kwargs)
141
+
142
+ async def ainvoke(self, tool_input: Any, config: Any = None, **kwargs: Any) -> Any: # type: ignore[override]
143
+ h = _hash_tool_input(tool_input)
144
+ key = f"{self._key_prefix}:{self.name}:{h}"
145
+
146
+ res = await self._client.check_lock_async(key=key, ttl=self._ttl, meta=self._meta)
147
+ if res.duplicate:
148
+ return f"Action '{self.name}' skipped (idempotency key duplicate)."
149
+
150
+ ainvoke = getattr(self._tool, "ainvoke", None)
151
+ if callable(ainvoke):
152
+ if config is None:
153
+ return await ainvoke(tool_input, **kwargs)
154
+ return await ainvoke(tool_input, config=config, **kwargs)
155
+
156
+ # Fallback
157
+ if config is None:
158
+ return self._tool.invoke(tool_input, **kwargs)
159
+ return self._tool.invoke(tool_input, config=config, **kwargs)
160
+
161
+ # Keep BaseTool abstract contract satisfied; not used because we override invoke/ainvoke.
162
+ def _run(self, *args: Any, **kwargs: Any) -> Any:
163
+ raise RuntimeError("IdempotentTool delegates via invoke(); _run() should not be called.")
164
+
165
+ async def _arun(self, *args: Any, **kwargs: Any) -> Any:
166
+ raise RuntimeError("IdempotentTool delegates via ainvoke(); _arun() should not be called.")
167
+
168
+ return IdempotentTool()
onceonly/models.py CHANGED
@@ -14,3 +14,14 @@ class CheckLockResult:
14
14
  request_id: Optional[str]
15
15
  status_code: int
16
16
  raw: Dict[str, Any]
17
+
18
+ def should_proceed(self) -> bool:
19
+ """
20
+ Helper for agents/tools:
21
+ - True => proceed with the expensive/side-effect operation
22
+ - False => treat as duplicate (or blocked)
23
+ """
24
+ return bool(self.locked) and not bool(self.duplicate)
25
+
26
+ def is_duplicate(self) -> bool:
27
+ return bool(self.duplicate)
onceonly/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "2.0.1"
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: onceonly-sdk
3
+ Version: 2.0.1
4
+ Summary: Python SDK for OnceOnly idempotency API
5
+ Author-email: OnceOnly <support@onceonly.tech>
6
+ License: MIT
7
+ Project-URL: Homepage, https://onceonly.tech/
8
+ Project-URL: Documentation, https://onceonly.tech/docs/
9
+ Project-URL: Repository, https://github.com/mykolademyanov/onceonly-python
10
+ Keywords: idempotency,automation,zapier,make,ai-agents
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx>=0.25
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=7.0; extra == "test"
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
21
+ Requires-Dist: anyio>=4.0; extra == "test"
22
+ Provides-Extra: langchain
23
+ Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
24
+ Dynamic: license-file
25
+
26
+ # OnceOnly Python SDK
27
+
28
+ **The Idempotency Layer for AI Agents, Webhooks, and Distributed Systems.**
29
+
30
+ OnceOnly is a high-performance Python SDK that ensures **exactly-once execution**.
31
+ It prevents duplicate actions (payments, emails, tool calls) in unstable environments like
32
+ AI agents, webhooks, retries, or background workers.
33
+
34
+ Website: https://onceonly.tech/ai/
35
+ Documentation: https://onceonly.tech/docs/
36
+
37
+ ---
38
+
39
+ ## Features
40
+
41
+ - Sync + Async client (httpx-based)
42
+ - Fail-open mode for production safety
43
+ - Stable idempotency keys (supports Pydantic & dataclasses)
44
+ - Decorators for zero-boilerplate usage
45
+ - Native AI API (long-running jobs, local side-effects)
46
+ - Optional AI / LangChain integrations
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install onceonly-sdk
54
+ ```
55
+
56
+ ### With LangChain support included:
57
+
58
+ ```bash
59
+ pip install "onceonly-sdk[langchain]"
60
+ ```
61
+
62
+ ---
63
+
64
+ ## Quick Start (Webhooks / Automations)
65
+
66
+ ```python
67
+ from onceonly import OnceOnly
68
+
69
+ client = OnceOnly(
70
+ api_key="once_live_...",
71
+ fail_open=True # default: continues if API is down
72
+ )
73
+
74
+ res = client.check_lock(key="order:123", ttl=300)
75
+
76
+ if res.duplicate:
77
+ print("Duplicate blocked")
78
+ else:
79
+ print("First execution")
80
+ ```
81
+
82
+ Use `check_lock()` for:
83
+ - Webhooks
84
+ - Make / Zapier scenarios
85
+ - Cron jobs
86
+ - Distributed workers
87
+
88
+ ---
89
+
90
+ ## AI Jobs (Server-side)
91
+
92
+ Use the AI API for long-running or asynchronous jobs.
93
+
94
+ ```python
95
+ result = client.ai.run_and_wait(
96
+ key="ai:job:daily_summary:2026-01-09",
97
+ metadata={"task": "daily_summary", "model": "gpt-4.1"},
98
+ timeout=60,
99
+ )
100
+
101
+ print(result.status)
102
+ print(result.result)
103
+ ```
104
+
105
+ - Charged **once per key**
106
+ - Polling is free
107
+ - Safe across retries and restarts
108
+
109
+ ---
110
+
111
+ ## AI Agents / Local Side-Effects
112
+
113
+ Use the AI Lease API when your code performs the side-effect locally
114
+ (payments, emails, webhooks) but still needs exactly-once guarantees.
115
+
116
+ ```python
117
+ lease = client.ai.lease(key="ai:agent:charge:user_42:invoice_100", ttl=300)
118
+
119
+ if lease["status"] == "acquired":
120
+ try:
121
+ do_side_effect()
122
+ client.ai.complete(key=KEY, lease_id=lease["lease_id"], result={"ok": True})
123
+ except Exception:
124
+ client.ai.fail(key=KEY, lease_id=lease["lease_id"], error_code="failed")
125
+ ```
126
+
127
+ ---
128
+
129
+ ## LangChain Integration 🤖
130
+
131
+ ```python
132
+ from onceonly.integrations.langchain import make_idempotent_tool
133
+
134
+ tool = make_idempotent_tool(
135
+ original_tool,
136
+ client=client,
137
+ key_prefix="agent:tool"
138
+ )
139
+ ```
140
+
141
+ Repeated tool calls with the same inputs will execute **exactly once**,
142
+ even across retries or agent restarts.
143
+
144
+ See `examples/ai/` for canonical patterns.
145
+
146
+ ---
147
+
148
+ ## Decorators
149
+
150
+ ```python
151
+ from onceonly.decorators import idempotent
152
+
153
+ @idempotent(client, ttl=3600)
154
+ def process_order(order_id):
155
+ ...
156
+ ```
157
+
158
+ Idempotency keys are generated automatically and remain stable across restarts.
159
+
160
+ ---
161
+
162
+ ## Fail-Open Mode
163
+
164
+ Fail-open is enabled by default.
165
+
166
+ Network errors, timeouts, or server errors (5xx) will **not break your application**.
167
+ The SDK will allow execution to continue safely.
168
+
169
+ Fail-open never applies to:
170
+ - Auth errors (401 / 403)
171
+ - Plan limits (402)
172
+ - Validation errors (422)
173
+ - Rate limits (429)
174
+
175
+ ---
176
+
177
+ ## Support
178
+
179
+ Need help?
180
+ Email: support@onceonly.tech
181
+ Or open an issue on GitHub.
182
+
183
+ ---
184
+
185
+ ## License
186
+
187
+ MIT
@@ -0,0 +1,17 @@
1
+ onceonly/__init__.py,sha256=KMS6F4DejM5nI5-gw3UC8SvETnK90oUE9V5pskh--Uw,481
2
+ onceonly/_http.py,sha256=bFAgrLv0T7cGFq3LqaQCwEiqx-VfKEiT8jUommmhRws,3240
3
+ onceonly/_util.py,sha256=YVdEWn1bvipAzR3g3oXpHmgLiaODwGRB1IGA3gHZ2PM,1273
4
+ onceonly/ai.py,sha256=-yaO1ZRlEO-Qqou4P_Q0cemZYwFSIRDM1Lu_AwfF5PY,14578
5
+ onceonly/ai_models.py,sha256=7bHYnAavdb3c-4nlh9HgRY18949TgmU9XfXfv3PXQEE,2910
6
+ onceonly/client.py,sha256=6DtLdWc-7_bAXsaaewUQUTHVnCkRZGsc-PByMVPRhYY,12838
7
+ onceonly/decorators.py,sha256=nP7Wu-RAQQNaTwyOnibzClEgcBJvYheMrG3_KztdlG8,5171
8
+ onceonly/exceptions.py,sha256=Issh08A4IHSDaysJhVZNRCU9W_9BfiGt65UHaMhDCs4,1156
9
+ onceonly/models.py,sha256=hVEBPgIVZP3ELjWYIFSFCKPzI38t5DA0gio9FvrmHJg,678
10
+ onceonly/version.py,sha256=wAxkK8w13vqoF47A8iqWdSlIgRRXmZiQ0R4wePZfzhs,22
11
+ onceonly/integrations/__init__.py,sha256=0tk-2HTTsmc42NhWuR_G_Afmz5-5WG8NvmlO7iIPkIY,34
12
+ onceonly/integrations/langchain.py,sha256=cdpHIluddX48uYeDeE1cxmn-arruVdE3k6gvZxYC9z4,5821
13
+ onceonly_sdk-2.0.1.dist-info/licenses/LICENSE,sha256=YQQ8IT_P7hcGmmLFFuOy3eKDZ90e1cqef_okg85oAiQ,129
14
+ onceonly_sdk-2.0.1.dist-info/METADATA,sha256=j1xhLIcpLYW6x4qmLKJp8qoKbDHrOhueu6beqF6DVmI,4131
15
+ onceonly_sdk-2.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ onceonly_sdk-2.0.1.dist-info/top_level.txt,sha256=lvz-sHerZcTwlZW-uYoda_wgx62kY07GdtzIdw89hnU,9
17
+ onceonly_sdk-2.0.1.dist-info/RECORD,,
@@ -1,153 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: onceonly-sdk
3
- Version: 1.2.0
4
- Summary: Python SDK for OnceOnly idempotency API
5
- Author-email: OnceOnly <support@onceonly.tech>
6
- License: MIT
7
- Project-URL: Homepage, https://onceonly.tech/
8
- Project-URL: Documentation, https://onceonly.tech/docs/
9
- Project-URL: Repository, https://github.com/mykolademyanov/onceonly-python
10
- Keywords: idempotency,automation,zapier,make,ai-agents
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Operating System :: OS Independent
14
- Requires-Python: >=3.9
15
- Description-Content-Type: text/markdown
16
- License-File: LICENSE
17
- Requires-Dist: httpx>=0.25
18
- Provides-Extra: test
19
- Requires-Dist: pytest>=7.0; extra == "test"
20
- Dynamic: license-file
21
-
22
- # OnceOnly Python SDK
23
-
24
- **The Idempotency Layer for AI Agents, Webhooks, and Distributed Systems.**
25
-
26
- OnceOnly is a high-performance Python SDK designed to ensure **exactly-once execution**.
27
- It prevents duplicate actions (payments, emails, tool calls) in unstable environments like
28
- AI agents, webhooks, or background workers.
29
-
30
- Website - https://onceonly.tech/ai/
31
-
32
- [![PyPI version](https://img.shields.io/pypi/v/onceonly.svg)](https://pypi.org/project/onceonly-sdk/)
33
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
-
35
- ---
36
-
37
- ## Features
38
-
39
- - **Sync + Async Client** — built on httpx for modern Python stacks
40
- - **Connection Pooling** — high performance under heavy load
41
- - **Fail-Open Mode** — business logic keeps running even if API is unreachable
42
- - **Smart Decorator** — automatic idempotency based on function arguments
43
- - **Typed Results & Exceptions**
44
-
45
- ---
46
-
47
- ## Installation
48
-
49
- ```bash
50
- pip install onceonly-sdk
51
- ```
52
-
53
- ---
54
-
55
- ## Quick Start
56
-
57
- ```python
58
- from onceonly import OnceOnly
59
-
60
- client = OnceOnly(api_key="once_live_...")
61
-
62
- result = client.check_lock(
63
- key="order:123",
64
- ttl=300, # 300 seconds = 5 minutes (clamped by your plan)
65
- )
66
-
67
- if result.duplicate:
68
- print("Duplicate blocked")
69
- else:
70
- print("First execution")
71
- ```
72
-
73
- ---
74
-
75
- ## Async Usage
76
-
77
- ```python
78
- async def handler():
79
- result = await client.check_lock_async("order:123")
80
- if result.locked:
81
- print("Locked")
82
- ```
83
-
84
- ---
85
-
86
- ## TTL Behavior
87
-
88
- - TTL is specified in seconds
89
- - If ttl is not provided, the server applies the plan default TTL
90
- - If ttl is provided, it is automatically clamped to your plan limits
91
-
92
- ---
93
-
94
- ## Metadata
95
-
96
- You can optionally attach metadata to each check-lock call.
97
- Metadata is useful for debugging, tracing, and server-side analytics.
98
-
99
- Rules:
100
- - JSON-serializable only
101
- - Size-limited
102
- - Safely logged on the server
103
-
104
- ---
105
-
106
- ## Decorator
107
-
108
- The SDK provides an optional decorator that automatically generates
109
- an idempotency key based on the **function name and arguments**.
110
-
111
- This allows you to add exactly-once guarantees to existing code
112
- with zero manual key management.
113
-
114
- ```python
115
- from onceonly.decorators import idempotent
116
-
117
- @idempotent(client, ttl=3600)
118
- def process_order(order_id):
119
- ...
120
- ```
121
-
122
- ---
123
-
124
- ## Fail-Open Mode
125
-
126
- Enabled by default.
127
-
128
- If a network error, timeout, or server error (5xx) occurs, the SDK returns a locked result
129
- instead of breaking your application.
130
-
131
- Fail-open never triggers for:
132
- - Authentication errors (401 / 403)
133
- - Plan limits (402)
134
- - Validation errors (422)
135
- - Rate limits (429)
136
-
137
- ---
138
-
139
- ## Exceptions
140
-
141
- | Exception | HTTP Status | Description |
142
- |--------------------|------------|------------------------------------------|
143
- | UnauthorizedError | 401 / 403 | Invalid or disabled API key |
144
- | OverLimitError | 402 | Plan limit reached |
145
- | RateLimitError | 429 | Too many requests |
146
- | ValidationError | 422 | Invalid input |
147
- | ApiError | 5xx / other| Server or unexpected API error |
148
-
149
- ---
150
-
151
- ## License
152
-
153
- MIT
@@ -1,10 +0,0 @@
1
- onceonly/__init__.py,sha256=_SwJ4Y2345WJKfpW2IsOVfgNaUzjZqTSSOnPQBroW-M,452
2
- onceonly/client.py,sha256=1RMTkGDpalHfx-xEH8DZsn9ZJ3VeGomG6PKAQQjPdZQ,13361
3
- onceonly/decorators.py,sha256=9eBhRUQDBGTUXVyRYlpYy77y-EHF-udX3ERkrfIt9Kg,2651
4
- onceonly/exceptions.py,sha256=RP556LUtO54TPFTZybF4dVA9n3TByFn35esz7EbcrpY,994
5
- onceonly/models.py,sha256=xhPfP1kpyHYfKJNiac2REeXTpPLzrEWJeqn-p6B6taQ,329
6
- onceonly_sdk-1.2.0.dist-info/licenses/LICENSE,sha256=YQQ8IT_P7hcGmmLFFuOy3eKDZ90e1cqef_okg85oAiQ,129
7
- onceonly_sdk-1.2.0.dist-info/METADATA,sha256=dvml-Mhrd_n0xFEhk7-rvShxiYfaLcl_Pq2vyGD0XRo,3891
8
- onceonly_sdk-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
- onceonly_sdk-1.2.0.dist-info/top_level.txt,sha256=lvz-sHerZcTwlZW-uYoda_wgx62kY07GdtzIdw89hnU,9
10
- onceonly_sdk-1.2.0.dist-info/RECORD,,