formbridge-sdk 0.1.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.
@@ -0,0 +1,54 @@
1
+ # Auto Claude data directory
2
+ .auto-claude/
3
+
4
+ # Auto Claude machine-specific config
5
+ .auto-claude-security.json
6
+ .auto-claude-status
7
+ .claude_settings.json
8
+
9
+ # Development verification artifacts
10
+ *VERIFICATION*.md
11
+
12
+ # Dependencies
13
+ node_modules/
14
+
15
+ # Python virtual environment
16
+ .venv/
17
+ __pycache__/
18
+ *.pyc
19
+ *.egg-info/
20
+
21
+ # Coverage reports
22
+ .coverage
23
+ coverage/
24
+ coverage-report.json
25
+
26
+ # Environment files
27
+ .env
28
+ .env.*
29
+ !.env.example
30
+ .env.local
31
+ .env.*.local
32
+
33
+ # Build outputs
34
+ dist/
35
+ *.tsbuildinfo
36
+
37
+ # Compiled output accidentally emitted into src/
38
+ src/*.js
39
+ src/*.js.map
40
+ src/*.d.ts
41
+ src/*.d.ts.map
42
+ src/**/*.js
43
+ src/**/*.js.map
44
+ src/**/*.d.ts
45
+ src/**/*.d.ts.map
46
+
47
+ # SQLite runtime files
48
+ *.db
49
+ *.db-shm
50
+ *.db-wal
51
+
52
+ # BMAD planning artifacts
53
+ _bmad/
54
+ _bmad-output/
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: formbridge-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for FormBridge — async and sync HTTP client
5
+ Author: FormBridge Team
6
+ License-Expression: MIT
7
+ Keywords: formbridge,forms,sdk,submissions
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: httpx>=0.24.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
16
+ Requires-Dist: pytest>=7.0; extra == 'dev'
17
+ Requires-Dist: respx>=0.21; extra == 'dev'
18
+ Requires-Dist: ruff>=0.1; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # formbridge-sdk
22
+
23
+ Python SDK for [FormBridge](https://github.com/agentkitai/formbridge) — async and sync HTTP client for managing form submissions.
24
+
25
+ ## Installation
26
+
27
+ This SDK is not yet published to PyPI. Install it from source:
28
+
29
+ ```bash
30
+ git clone https://github.com/agentkitai/formbridge.git
31
+ pip install ./formbridge/sdk/python
32
+ ```
33
+
34
+ ## Quick Start (Async)
35
+
36
+ ```python
37
+ import asyncio
38
+ from formbridge import FormBridgeClient, Actor
39
+
40
+ async def main():
41
+ async with FormBridgeClient(
42
+ url="http://localhost:3000",
43
+ api_key="your-api-key",
44
+ ) as client:
45
+ # Create a submission with initial fields
46
+ sub = await client.create_submission(
47
+ "vendor-onboarding",
48
+ fields={"company_name": "Acme Corp"},
49
+ actor=Actor(kind="agent", id="agent-1", name="Onboarding Bot"),
50
+ )
51
+ print(f"Created: {sub.submission_id}, state={sub.state}")
52
+ print(f"Missing fields: {sub.missing_fields}")
53
+
54
+ # Add more fields (resume_token rotates on each call)
55
+ result = await client.set_fields(
56
+ sub.intake_id,
57
+ sub.submission_id,
58
+ sub.resume_token,
59
+ {"contact_email": "alice@acme.com", "phone": "+1-555-0100"},
60
+ )
61
+ print(f"Updated: state={result.state}, missing={result.missing_fields}")
62
+
63
+ # Submit when all fields are filled
64
+ final = await client.submit(
65
+ sub.intake_id,
66
+ sub.submission_id,
67
+ result.resume_token,
68
+ )
69
+ print(f"Submitted: state={final.state}")
70
+
71
+ # Retrieve a submission
72
+ fetched = await client.get_submission(sub.intake_id, sub.submission_id)
73
+ print(f"Fields: {fetched.fields}")
74
+
75
+ asyncio.run(main())
76
+ ```
77
+
78
+ ## Quick Start (Sync)
79
+
80
+ ```python
81
+ from formbridge import FormBridgeClientSync, Actor
82
+
83
+ with FormBridgeClientSync(api_key="your-api-key") as client:
84
+ sub = client.create_submission(
85
+ "vendor-onboarding",
86
+ fields={"company_name": "Acme Corp"},
87
+ actor=Actor(kind="agent", id="agent-1"),
88
+ )
89
+ print(f"Created: {sub.submission_id}")
90
+ ```
91
+
92
+ ## Configuration
93
+
94
+ | Parameter | Env Var | Default |
95
+ |-----------|---------|---------|
96
+ | `url` | `FORMBRIDGE_URL` | `http://localhost:3000` |
97
+ | `api_key` | `FORMBRIDGE_API_KEY` | — |
98
+ | `timeout` | `FORMBRIDGE_TIMEOUT` | `10` (seconds) |
99
+
100
+ ## Error Handling
101
+
102
+ ```python
103
+ from formbridge import FormBridgeClient, FormBridgeError
104
+
105
+ async with FormBridgeClient() as client:
106
+ try:
107
+ sub = await client.get_submission("intake-1", "sub-123")
108
+ except FormBridgeError as e:
109
+ if e.is_connectivity_error:
110
+ print("Server unreachable — degrade gracefully")
111
+ elif e.status_code == 404:
112
+ print("Not found")
113
+ else:
114
+ print(f"API error: {e.error_type} — {e}")
115
+ ```
116
+
117
+ ## Retry Behavior
118
+
119
+ - **Retries on:** 429 (rate limited), 500, 502, 503, 504
120
+ - **Does NOT retry on:** 400, 401, 403, 404 (client errors)
121
+ - **Backoff:** 0.5s → 1.0s → 2.0s (exponential)
122
+ - **Max retries:** 3 (configurable via `max_retries`)
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,106 @@
1
+ # formbridge-sdk
2
+
3
+ Python SDK for [FormBridge](https://github.com/agentkitai/formbridge) — async and sync HTTP client for managing form submissions.
4
+
5
+ ## Installation
6
+
7
+ This SDK is not yet published to PyPI. Install it from source:
8
+
9
+ ```bash
10
+ git clone https://github.com/agentkitai/formbridge.git
11
+ pip install ./formbridge/sdk/python
12
+ ```
13
+
14
+ ## Quick Start (Async)
15
+
16
+ ```python
17
+ import asyncio
18
+ from formbridge import FormBridgeClient, Actor
19
+
20
+ async def main():
21
+ async with FormBridgeClient(
22
+ url="http://localhost:3000",
23
+ api_key="your-api-key",
24
+ ) as client:
25
+ # Create a submission with initial fields
26
+ sub = await client.create_submission(
27
+ "vendor-onboarding",
28
+ fields={"company_name": "Acme Corp"},
29
+ actor=Actor(kind="agent", id="agent-1", name="Onboarding Bot"),
30
+ )
31
+ print(f"Created: {sub.submission_id}, state={sub.state}")
32
+ print(f"Missing fields: {sub.missing_fields}")
33
+
34
+ # Add more fields (resume_token rotates on each call)
35
+ result = await client.set_fields(
36
+ sub.intake_id,
37
+ sub.submission_id,
38
+ sub.resume_token,
39
+ {"contact_email": "alice@acme.com", "phone": "+1-555-0100"},
40
+ )
41
+ print(f"Updated: state={result.state}, missing={result.missing_fields}")
42
+
43
+ # Submit when all fields are filled
44
+ final = await client.submit(
45
+ sub.intake_id,
46
+ sub.submission_id,
47
+ result.resume_token,
48
+ )
49
+ print(f"Submitted: state={final.state}")
50
+
51
+ # Retrieve a submission
52
+ fetched = await client.get_submission(sub.intake_id, sub.submission_id)
53
+ print(f"Fields: {fetched.fields}")
54
+
55
+ asyncio.run(main())
56
+ ```
57
+
58
+ ## Quick Start (Sync)
59
+
60
+ ```python
61
+ from formbridge import FormBridgeClientSync, Actor
62
+
63
+ with FormBridgeClientSync(api_key="your-api-key") as client:
64
+ sub = client.create_submission(
65
+ "vendor-onboarding",
66
+ fields={"company_name": "Acme Corp"},
67
+ actor=Actor(kind="agent", id="agent-1"),
68
+ )
69
+ print(f"Created: {sub.submission_id}")
70
+ ```
71
+
72
+ ## Configuration
73
+
74
+ | Parameter | Env Var | Default |
75
+ |-----------|---------|---------|
76
+ | `url` | `FORMBRIDGE_URL` | `http://localhost:3000` |
77
+ | `api_key` | `FORMBRIDGE_API_KEY` | — |
78
+ | `timeout` | `FORMBRIDGE_TIMEOUT` | `10` (seconds) |
79
+
80
+ ## Error Handling
81
+
82
+ ```python
83
+ from formbridge import FormBridgeClient, FormBridgeError
84
+
85
+ async with FormBridgeClient() as client:
86
+ try:
87
+ sub = await client.get_submission("intake-1", "sub-123")
88
+ except FormBridgeError as e:
89
+ if e.is_connectivity_error:
90
+ print("Server unreachable — degrade gracefully")
91
+ elif e.status_code == 404:
92
+ print("Not found")
93
+ else:
94
+ print(f"API error: {e.error_type} — {e}")
95
+ ```
96
+
97
+ ## Retry Behavior
98
+
99
+ - **Retries on:** 429 (rate limited), 500, 502, 503, 504
100
+ - **Does NOT retry on:** 400, 401, 403, 404 (client errors)
101
+ - **Backoff:** 0.5s → 1.0s → 2.0s (exponential)
102
+ - **Max retries:** 3 (configurable via `max_retries`)
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "formbridge-sdk"
7
+ version = "0.1.0"
8
+ description = "Python SDK for FormBridge — async and sync HTTP client"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "FormBridge Team" }]
13
+ keywords = ["formbridge", "sdk", "forms", "submissions"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = ["httpx>=0.24.0"]
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.21", "ruff>=0.1"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/formbridge"]
27
+
28
+ [tool.pytest.ini_options]
29
+ asyncio_mode = "auto"
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py39"
@@ -0,0 +1,14 @@
1
+ """FormBridge Python SDK — async and sync HTTP client."""
2
+
3
+ from formbridge.client import FormBridgeClient, FormBridgeClientSync
4
+ from formbridge.errors import FormBridgeError
5
+ from formbridge.types import Actor, FieldsResult, Submission
6
+
7
+ __all__ = [
8
+ "FormBridgeClient",
9
+ "FormBridgeClientSync",
10
+ "FormBridgeError",
11
+ "Actor",
12
+ "FieldsResult",
13
+ "Submission",
14
+ ]
@@ -0,0 +1,387 @@
1
+ """FormBridge async and sync HTTP clients.
2
+
3
+ Usage::
4
+
5
+ async with FormBridgeClient() as client:
6
+ sub = await client.create_submission("vendor-onboarding", fields={"company": "Acme"})
7
+ result = await client.set_fields(sub.intake_id, sub.submission_id, sub.resume_token, {"email": "a@b.com"})
8
+ final = await client.submit(sub.intake_id, sub.submission_id, result.resume_token)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ from typing import Any, Dict, Optional
17
+
18
+ try:
19
+ import httpx
20
+ except ImportError:
21
+ raise ImportError("httpx is required. Install with: pip install formbridge-sdk")
22
+
23
+ from formbridge.errors import FormBridgeError
24
+ from formbridge.types import Actor, FieldsResult, Submission
25
+
26
+ logger = logging.getLogger("formbridge.client")
27
+
28
+ _DEFAULT_URL = "http://localhost:3000"
29
+ _DEFAULT_TIMEOUT = 10.0
30
+ _RETRY_BACKOFFS = [0.5, 1.0, 2.0]
31
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
32
+
33
+
34
+ def _serialize_actor(actor: Optional[Actor]) -> Optional[Dict[str, Any]]:
35
+ if actor is None:
36
+ return None
37
+ d: Dict[str, Any] = {"kind": actor.kind, "id": actor.id}
38
+ if actor.name is not None:
39
+ d["name"] = actor.name
40
+ return d
41
+
42
+
43
+ class FormBridgeClient:
44
+ """Async HTTP client for FormBridge API.
45
+
46
+ Parameters:
47
+ url: Base URL. Defaults to ``FORMBRIDGE_URL`` env var or ``http://localhost:3000``.
48
+ api_key: API key for Bearer auth. Defaults to ``FORMBRIDGE_API_KEY`` env var.
49
+ timeout: Request timeout in seconds. Defaults to ``FORMBRIDGE_TIMEOUT`` env var or 10.
50
+ max_retries: Max retry attempts on 429/5xx (default 3).
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ url: Optional[str] = None,
56
+ api_key: Optional[str] = None,
57
+ timeout: Optional[float] = None,
58
+ max_retries: int = 3,
59
+ ) -> None:
60
+ self._url = (url or os.environ.get("FORMBRIDGE_URL", _DEFAULT_URL)).rstrip("/")
61
+ self._api_key = api_key or os.environ.get("FORMBRIDGE_API_KEY", "")
62
+ raw_timeout = timeout if timeout is not None else os.environ.get("FORMBRIDGE_TIMEOUT")
63
+ self._timeout = float(raw_timeout) if raw_timeout is not None else _DEFAULT_TIMEOUT
64
+ self._max_retries = max_retries
65
+
66
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
67
+ if self._api_key:
68
+ headers["Authorization"] = f"Bearer {self._api_key}"
69
+
70
+ self._http = httpx.AsyncClient(
71
+ base_url=self._url,
72
+ headers=headers,
73
+ timeout=self._timeout,
74
+ )
75
+
76
+ async def __aenter__(self) -> "FormBridgeClient":
77
+ return self
78
+
79
+ async def __aexit__(self, *args: object) -> None:
80
+ await self.close()
81
+
82
+ async def close(self) -> None:
83
+ """Close the underlying HTTP client."""
84
+ await self._http.aclose()
85
+
86
+ # ── Public API ────────────────────────────────────────────────────
87
+
88
+ async def create_submission(
89
+ self,
90
+ intake_id: str,
91
+ *,
92
+ fields: Optional[Dict[str, Any]] = None,
93
+ actor: Optional[Actor] = None,
94
+ idempotency_key: Optional[str] = None,
95
+ ) -> Submission:
96
+ """Create a new submission.
97
+
98
+ Args:
99
+ intake_id: The intake definition ID.
100
+ fields: Optional initial field values.
101
+ actor: Optional actor performing the action.
102
+ idempotency_key: Optional idempotency key for deduplication.
103
+
104
+ Returns:
105
+ Submission with id, state, resume_token, etc.
106
+
107
+ Raises:
108
+ FormBridgeError: On API or connectivity error.
109
+ """
110
+ body: Dict[str, Any] = {}
111
+ if fields:
112
+ body["fields"] = fields
113
+ if actor:
114
+ body["actor"] = _serialize_actor(actor)
115
+ if idempotency_key:
116
+ body["idempotencyKey"] = idempotency_key
117
+
118
+ data = await self._request("POST", f"/intake/{intake_id}/submissions", json_data=body)
119
+ sub = Submission.from_response(data)
120
+ # API may not return intakeId on create; fill it in
121
+ if not sub.intake_id:
122
+ sub = Submission(
123
+ submission_id=sub.submission_id,
124
+ intake_id=intake_id,
125
+ state=sub.state,
126
+ resume_token=sub.resume_token,
127
+ fields=sub.fields,
128
+ missing_fields=sub.missing_fields,
129
+ schema=sub.schema,
130
+ created_at=sub.created_at,
131
+ updated_at=sub.updated_at,
132
+ raw=sub.raw,
133
+ )
134
+ return sub
135
+
136
+ async def set_fields(
137
+ self,
138
+ intake_id: str,
139
+ submission_id: str,
140
+ resume_token: str,
141
+ fields: Dict[str, Any],
142
+ actor: Optional[Actor] = None,
143
+ ) -> FieldsResult:
144
+ """Update fields on a submission.
145
+
146
+ Args:
147
+ intake_id: The intake definition ID.
148
+ submission_id: The submission ID.
149
+ resume_token: Current resume token.
150
+ fields: Field values to set.
151
+ actor: Optional actor performing the action.
152
+
153
+ Returns:
154
+ FieldsResult with new state, resume_token (may rotate), missing_fields.
155
+
156
+ Raises:
157
+ FormBridgeError: On API or connectivity error.
158
+ """
159
+ body: Dict[str, Any] = {
160
+ "resumeToken": resume_token,
161
+ "fields": fields,
162
+ }
163
+ if actor:
164
+ body["actor"] = _serialize_actor(actor)
165
+
166
+ data = await self._request(
167
+ "PATCH", f"/intake/{intake_id}/submissions/{submission_id}", json_data=body
168
+ )
169
+ return FieldsResult.from_response(data)
170
+
171
+ async def submit(
172
+ self,
173
+ intake_id: str,
174
+ submission_id: str,
175
+ resume_token: str,
176
+ *,
177
+ actor: Optional[Actor] = None,
178
+ ) -> Submission:
179
+ """Submit a submission for processing.
180
+
181
+ Args:
182
+ intake_id: The intake definition ID.
183
+ submission_id: The submission ID.
184
+ resume_token: Current resume token.
185
+ actor: Optional actor performing the action.
186
+
187
+ Returns:
188
+ Final Submission state.
189
+
190
+ Raises:
191
+ FormBridgeError: On API or connectivity error.
192
+ """
193
+ body: Dict[str, Any] = {"resumeToken": resume_token}
194
+ if actor:
195
+ body["actor"] = _serialize_actor(actor)
196
+
197
+ data = await self._request(
198
+ "POST", f"/intake/{intake_id}/submissions/{submission_id}/submit", json_data=body
199
+ )
200
+ return Submission.from_response(data)
201
+
202
+ async def get_submission(
203
+ self,
204
+ intake_id: str,
205
+ submission_id: str,
206
+ ) -> Submission:
207
+ """Get a submission by ID.
208
+
209
+ Args:
210
+ intake_id: The intake definition ID.
211
+ submission_id: The submission ID.
212
+
213
+ Returns:
214
+ Submission details.
215
+
216
+ Raises:
217
+ FormBridgeError: On API or connectivity error.
218
+ """
219
+ data = await self._request(
220
+ "GET", f"/intake/{intake_id}/submissions/{submission_id}"
221
+ )
222
+ return Submission.from_response(data)
223
+
224
+ # ── Internals ─────────────────────────────────────────────────────
225
+
226
+ async def _request(
227
+ self,
228
+ method: str,
229
+ path: str,
230
+ *,
231
+ json_data: Optional[Any] = None,
232
+ ) -> Dict[str, Any]:
233
+ """Make HTTP request with retry and graceful error handling."""
234
+ last_exc: Optional[Exception] = None
235
+ backoffs = _RETRY_BACKOFFS[: self._max_retries]
236
+
237
+ for attempt in range(1 + len(backoffs)):
238
+ try:
239
+ resp = await self._http.request(method, path, json=json_data)
240
+
241
+ if resp.status_code in _RETRYABLE_STATUS and attempt < len(backoffs):
242
+ logger.warning(
243
+ "FormBridge returned %d (attempt %d/%d), retrying in %.1fs",
244
+ resp.status_code,
245
+ attempt + 1,
246
+ len(backoffs) + 1,
247
+ backoffs[attempt],
248
+ )
249
+ await asyncio.sleep(backoffs[attempt])
250
+ continue
251
+
252
+ data = resp.json()
253
+
254
+ if resp.status_code >= 400:
255
+ error_info = data.get("error", {}) if isinstance(data, dict) else {}
256
+ raise FormBridgeError(
257
+ error_info.get("message", f"HTTP {resp.status_code}"),
258
+ status_code=resp.status_code,
259
+ error_type=error_info.get("type"),
260
+ response_data=data,
261
+ )
262
+
263
+ return data
264
+
265
+ except FormBridgeError:
266
+ raise
267
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout) as exc:
268
+ last_exc = exc
269
+ if attempt < len(backoffs):
270
+ logger.warning(
271
+ "FormBridge connection error (attempt %d/%d): %s, retrying in %.1fs",
272
+ attempt + 1,
273
+ len(backoffs) + 1,
274
+ exc,
275
+ backoffs[attempt],
276
+ )
277
+ await asyncio.sleep(backoffs[attempt])
278
+ continue
279
+ raise FormBridgeError(
280
+ f"Connection failed: {exc}",
281
+ is_connectivity_error=True,
282
+ ) from exc
283
+ except Exception as exc:
284
+ raise FormBridgeError(
285
+ f"Unexpected error: {exc}",
286
+ is_connectivity_error=True,
287
+ ) from exc
288
+
289
+ # Should not reach here
290
+ if last_exc:
291
+ raise FormBridgeError(
292
+ f"Connection failed after retries: {last_exc}",
293
+ is_connectivity_error=True,
294
+ ) from last_exc
295
+ raise RuntimeError("Retry loop exhausted unexpectedly")
296
+
297
+
298
+ class FormBridgeClientSync:
299
+ """Synchronous wrapper around FormBridgeClient.
300
+
301
+ Works without an existing event loop. Creates its own loop internally.
302
+
303
+ Usage::
304
+
305
+ client = FormBridgeClientSync(api_key="sk-...")
306
+ sub = client.create_submission("vendor-onboarding", fields={"company": "Acme"})
307
+ client.close()
308
+ """
309
+
310
+ def __init__(self, **kwargs: Any) -> None:
311
+ self._async_client = FormBridgeClient(**kwargs)
312
+
313
+ def __enter__(self) -> "FormBridgeClientSync":
314
+ return self
315
+
316
+ def __exit__(self, *args: object) -> None:
317
+ self.close()
318
+
319
+ def close(self) -> None:
320
+ """Close the underlying HTTP client."""
321
+ self._run(self._async_client.close())
322
+
323
+ def create_submission(
324
+ self,
325
+ intake_id: str,
326
+ *,
327
+ fields: Optional[Dict[str, Any]] = None,
328
+ actor: Optional[Actor] = None,
329
+ idempotency_key: Optional[str] = None,
330
+ ) -> Submission:
331
+ """Create a new submission. See :meth:`FormBridgeClient.create_submission`."""
332
+ return self._run(
333
+ self._async_client.create_submission(
334
+ intake_id, fields=fields, actor=actor, idempotency_key=idempotency_key
335
+ )
336
+ )
337
+
338
+ def set_fields(
339
+ self,
340
+ intake_id: str,
341
+ submission_id: str,
342
+ resume_token: str,
343
+ fields: Dict[str, Any],
344
+ actor: Optional[Actor] = None,
345
+ ) -> FieldsResult:
346
+ """Update fields on a submission. See :meth:`FormBridgeClient.set_fields`."""
347
+ return self._run(
348
+ self._async_client.set_fields(intake_id, submission_id, resume_token, fields, actor)
349
+ )
350
+
351
+ def submit(
352
+ self,
353
+ intake_id: str,
354
+ submission_id: str,
355
+ resume_token: str,
356
+ *,
357
+ actor: Optional[Actor] = None,
358
+ ) -> Submission:
359
+ """Submit a submission. See :meth:`FormBridgeClient.submit`."""
360
+ return self._run(
361
+ self._async_client.submit(intake_id, submission_id, resume_token, actor=actor)
362
+ )
363
+
364
+ def get_submission(
365
+ self,
366
+ intake_id: str,
367
+ submission_id: str,
368
+ ) -> Submission:
369
+ """Get a submission by ID. See :meth:`FormBridgeClient.get_submission`."""
370
+ return self._run(self._async_client.get_submission(intake_id, submission_id))
371
+
372
+ @staticmethod
373
+ def _run(coro: Any) -> Any:
374
+ """Run a coroutine in a new event loop (safe from any context)."""
375
+ try:
376
+ loop = asyncio.get_running_loop()
377
+ except RuntimeError:
378
+ loop = None
379
+
380
+ if loop and loop.is_running():
381
+ # We're inside an async context — use a thread
382
+ import concurrent.futures
383
+
384
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
385
+ return pool.submit(asyncio.run, coro).result()
386
+ else:
387
+ return asyncio.run(coro)
@@ -0,0 +1,31 @@
1
+ """Error types for FormBridge SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class FormBridgeError(Exception):
9
+ """Base error for FormBridge SDK.
10
+
11
+ Attributes:
12
+ status_code: HTTP status code (None for connectivity errors).
13
+ error_type: Error type from API response (e.g. 'not_found').
14
+ is_connectivity_error: True if the error is due to a connection failure.
15
+ response_data: Raw response body if available.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ status_code: Optional[int] = None,
23
+ error_type: Optional[str] = None,
24
+ is_connectivity_error: bool = False,
25
+ response_data: Optional[Dict[str, Any]] = None,
26
+ ) -> None:
27
+ super().__init__(message)
28
+ self.status_code = status_code
29
+ self.error_type = error_type
30
+ self.is_connectivity_error = is_connectivity_error
31
+ self.response_data = response_data
File without changes
@@ -0,0 +1,73 @@
1
+ """Response types for FormBridge SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Actor:
11
+ """Submission actor."""
12
+
13
+ kind: str
14
+ id: str
15
+ name: Optional[str] = None
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Submission:
20
+ """Submission returned by the API."""
21
+
22
+ submission_id: str
23
+ intake_id: str
24
+ state: str
25
+ resume_token: Optional[str] = None
26
+ fields: Dict[str, Any] = field(default_factory=dict)
27
+ missing_fields: Optional[List[str]] = None
28
+ schema: Optional[Dict[str, Any]] = None
29
+ created_at: Optional[str] = None
30
+ updated_at: Optional[str] = None
31
+ raw: Dict[str, Any] = field(default_factory=dict)
32
+
33
+ @classmethod
34
+ def from_response(cls, data: Dict[str, Any]) -> "Submission":
35
+ """Parse API response into a Submission."""
36
+ return cls(
37
+ submission_id=data.get("submissionId", ""),
38
+ intake_id=data.get("intakeId", ""),
39
+ state=data.get("state", ""),
40
+ resume_token=data.get("resumeToken"),
41
+ fields=data.get("fields", {}),
42
+ missing_fields=data.get("missingFields"),
43
+ schema=data.get("schema"),
44
+ created_at=data.get("metadata", {}).get("createdAt")
45
+ if isinstance(data.get("metadata"), dict)
46
+ else data.get("createdAt"),
47
+ updated_at=data.get("metadata", {}).get("updatedAt")
48
+ if isinstance(data.get("metadata"), dict)
49
+ else data.get("updatedAt"),
50
+ raw=data,
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FieldsResult:
56
+ """Result from set_fields."""
57
+
58
+ submission_id: str
59
+ state: str
60
+ resume_token: str
61
+ missing_fields: Optional[List[str]] = None
62
+ raw: Dict[str, Any] = field(default_factory=dict)
63
+
64
+ @classmethod
65
+ def from_response(cls, data: Dict[str, Any]) -> "FieldsResult":
66
+ """Parse API response into a FieldsResult."""
67
+ return cls(
68
+ submission_id=data.get("submissionId", ""),
69
+ state=data.get("state", ""),
70
+ resume_token=data.get("resumeToken", ""),
71
+ missing_fields=data.get("missingFields"),
72
+ raw=data,
73
+ )
File without changes
@@ -0,0 +1,266 @@
1
+ """Unit tests for FormBridge SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ import httpx
7
+ import respx
8
+
9
+ from formbridge import (
10
+ FormBridgeClient,
11
+ FormBridgeClientSync,
12
+ FormBridgeError,
13
+ Actor,
14
+ )
15
+
16
+ BASE_URL = "http://test-formbridge:3000"
17
+
18
+
19
+ @pytest.fixture
20
+ def client():
21
+ return FormBridgeClient(url=BASE_URL, api_key="test-key")
22
+
23
+
24
+ @pytest.fixture
25
+ def sync_client():
26
+ return FormBridgeClientSync(url=BASE_URL, api_key="test-key")
27
+
28
+
29
+ # ── Auth Header ──────────────────────────────────────────────────────
30
+
31
+
32
+ @respx.mock
33
+ async def test_auth_header(client: FormBridgeClient):
34
+ """Bearer token sent on all requests."""
35
+ route = respx.get(f"{BASE_URL}/intake/test/submissions/sub1").mock(
36
+ return_value=httpx.Response(200, json={
37
+ "ok": True, "submissionId": "sub1", "intakeId": "test",
38
+ "state": "draft", "fields": {},
39
+ })
40
+ )
41
+ await client.get_submission("test", "sub1")
42
+ assert route.called
43
+ assert route.calls[0].request.headers["authorization"] == "Bearer test-key"
44
+ await client.close()
45
+
46
+
47
+ # ── create_submission ─────────────────────────────────────────────────
48
+
49
+
50
+ @respx.mock
51
+ async def test_create_submission_success(client: FormBridgeClient):
52
+ respx.post(f"{BASE_URL}/intake/vendor/submissions").mock(
53
+ return_value=httpx.Response(201, json={
54
+ "ok": True,
55
+ "submissionId": "sub_123",
56
+ "state": "in_progress",
57
+ "resumeToken": "tok_abc",
58
+ "schema": {"type": "object"},
59
+ "missingFields": ["email"],
60
+ })
61
+ )
62
+
63
+ sub = await client.create_submission(
64
+ "vendor",
65
+ fields={"company": "Acme"},
66
+ actor=Actor(kind="agent", id="agent-1", name="Bot"),
67
+ idempotency_key="idem-1",
68
+ )
69
+ assert sub.submission_id == "sub_123"
70
+ assert sub.state == "in_progress"
71
+ assert sub.resume_token == "tok_abc"
72
+ assert sub.missing_fields == ["email"]
73
+ assert sub.intake_id == "vendor"
74
+ await client.close()
75
+
76
+
77
+ # ── set_fields ────────────────────────────────────────────────────────
78
+
79
+
80
+ @respx.mock
81
+ async def test_set_fields_success(client: FormBridgeClient):
82
+ respx.patch(f"{BASE_URL}/intake/vendor/submissions/sub_123").mock(
83
+ return_value=httpx.Response(200, json={
84
+ "ok": True,
85
+ "submissionId": "sub_123",
86
+ "state": "in_progress",
87
+ "resumeToken": "tok_new",
88
+ "missingFields": [],
89
+ })
90
+ )
91
+
92
+ result = await client.set_fields(
93
+ "vendor", "sub_123", "tok_abc", {"email": "a@b.com"},
94
+ actor=Actor(kind="agent", id="agent-1"),
95
+ )
96
+ assert result.submission_id == "sub_123"
97
+ assert result.resume_token == "tok_new"
98
+ await client.close()
99
+
100
+
101
+ # ── submit ────────────────────────────────────────────────────────────
102
+
103
+
104
+ @respx.mock
105
+ async def test_submit_success(client: FormBridgeClient):
106
+ respx.post(f"{BASE_URL}/intake/vendor/submissions/sub_123/submit").mock(
107
+ return_value=httpx.Response(200, json={
108
+ "ok": True,
109
+ "submissionId": "sub_123",
110
+ "intakeId": "vendor",
111
+ "state": "submitted",
112
+ })
113
+ )
114
+
115
+ sub = await client.submit("vendor", "sub_123", "tok_abc")
116
+ assert sub.state == "submitted"
117
+ await client.close()
118
+
119
+
120
+ # ── get_submission ────────────────────────────────────────────────────
121
+
122
+
123
+ @respx.mock
124
+ async def test_get_submission_success(client: FormBridgeClient):
125
+ respx.get(f"{BASE_URL}/intake/vendor/submissions/sub_123").mock(
126
+ return_value=httpx.Response(200, json={
127
+ "ok": True,
128
+ "submissionId": "sub_123",
129
+ "intakeId": "vendor",
130
+ "state": "draft",
131
+ "fields": {"company": "Acme"},
132
+ "metadata": {"createdAt": "2026-01-01T00:00:00Z"},
133
+ })
134
+ )
135
+
136
+ sub = await client.get_submission("vendor", "sub_123")
137
+ assert sub.submission_id == "sub_123"
138
+ assert sub.fields == {"company": "Acme"}
139
+ assert sub.created_at == "2026-01-01T00:00:00Z"
140
+ await client.close()
141
+
142
+
143
+ # ── Retry on 429 ──────────────────────────────────────────────────────
144
+
145
+
146
+ @respx.mock
147
+ async def test_retry_on_429(client: FormBridgeClient):
148
+ """Should retry on 429 and succeed on subsequent attempt."""
149
+ route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
150
+ route.side_effect = [
151
+ httpx.Response(429, json={"ok": False, "error": {"type": "rate_limited"}}),
152
+ httpx.Response(200, json={
153
+ "ok": True, "submissionId": "s1", "intakeId": "v", "state": "draft", "fields": {},
154
+ }),
155
+ ]
156
+
157
+ # Override backoffs for fast test
158
+ import formbridge.client as mod
159
+ original = mod._RETRY_BACKOFFS
160
+ mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
161
+ try:
162
+ sub = await client.get_submission("v", "s1")
163
+ assert sub.submission_id == "s1"
164
+ assert route.call_count == 2
165
+ finally:
166
+ mod._RETRY_BACKOFFS = original
167
+ await client.close()
168
+
169
+
170
+ # ── Retry on 5xx then fail ────────────────────────────────────────────
171
+
172
+
173
+ @respx.mock
174
+ async def test_retry_5xx_then_fail(client: FormBridgeClient):
175
+ """After max retries on 5xx, should raise FormBridgeError."""
176
+ route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
177
+ route.side_effect = [
178
+ httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
179
+ httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
180
+ httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
181
+ httpx.Response(502, json={"ok": False, "error": {"type": "bad_gateway", "message": "down"}}),
182
+ ]
183
+
184
+ import formbridge.client as mod
185
+ original = mod._RETRY_BACKOFFS
186
+ mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
187
+ try:
188
+ with pytest.raises(FormBridgeError) as exc_info:
189
+ await client.get_submission("v", "s1")
190
+ assert exc_info.value.status_code == 502
191
+ assert route.call_count == 4 # 1 initial + 3 retries
192
+ finally:
193
+ mod._RETRY_BACKOFFS = original
194
+ await client.close()
195
+
196
+
197
+ # ── No retry on 400/401/403 ──────────────────────────────────────────
198
+
199
+
200
+ @respx.mock
201
+ async def test_no_retry_on_client_errors(client: FormBridgeClient):
202
+ """Should NOT retry on 400, 401, 403."""
203
+ route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
204
+ route.mock(return_value=httpx.Response(401, json={
205
+ "ok": False, "error": {"type": "unauthorized", "message": "bad key"},
206
+ }))
207
+
208
+ with pytest.raises(FormBridgeError) as exc_info:
209
+ await client.get_submission("v", "s1")
210
+ assert exc_info.value.status_code == 401
211
+ assert route.call_count == 1
212
+ await client.close()
213
+
214
+
215
+ # ── Connection refused → graceful error ───────────────────────────────
216
+
217
+
218
+ @respx.mock
219
+ async def test_connection_refused():
220
+ """Connection error should produce FormBridgeError with is_connectivity_error=True."""
221
+ route = respx.get(f"{BASE_URL}/intake/v/submissions/s1")
222
+ route.side_effect = httpx.ConnectError("Connection refused")
223
+
224
+ import formbridge.client as mod
225
+ original = mod._RETRY_BACKOFFS
226
+ mod._RETRY_BACKOFFS = [0.01, 0.01, 0.01]
227
+
228
+ client = FormBridgeClient(url=BASE_URL, api_key="test-key")
229
+ try:
230
+ with pytest.raises(FormBridgeError) as exc_info:
231
+ await client.get_submission("v", "s1")
232
+ assert exc_info.value.is_connectivity_error is True
233
+ assert "Connection" in str(exc_info.value)
234
+ finally:
235
+ mod._RETRY_BACKOFFS = original
236
+ await client.close()
237
+
238
+
239
+ # ── Sync client ───────────────────────────────────────────────────────
240
+
241
+
242
+ @respx.mock
243
+ def test_sync_client_create(sync_client: FormBridgeClientSync):
244
+ """Sync wrapper should work without event loop."""
245
+ respx.post(f"{BASE_URL}/intake/vendor/submissions").mock(
246
+ return_value=httpx.Response(201, json={
247
+ "ok": True,
248
+ "submissionId": "sub_sync",
249
+ "state": "draft",
250
+ "resumeToken": "tok_s",
251
+ })
252
+ )
253
+
254
+ sub = sync_client.create_submission("vendor", fields={"name": "Test"})
255
+ assert sub.submission_id == "sub_sync"
256
+ sync_client.close()
257
+
258
+
259
+ # ── API key not leaked in logs ────────────────────────────────────────
260
+
261
+
262
+ def test_api_key_not_in_repr():
263
+ """API key should not appear in string representations."""
264
+ client = FormBridgeClient(api_key="super-secret-key-123")
265
+ assert "super-secret-key-123" not in repr(client)
266
+ assert "super-secret-key-123" not in str(client)