cweb-forge 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.
- cweb_forge-0.1.0/LICENSE +21 -0
- cweb_forge-0.1.0/PKG-INFO +270 -0
- cweb_forge-0.1.0/README.md +249 -0
- cweb_forge-0.1.0/pyproject.toml +29 -0
- cweb_forge-0.1.0/setup.cfg +4 -0
- cweb_forge-0.1.0/src/cweb_forge/__init__.py +134 -0
- cweb_forge-0.1.0/src/cweb_forge/py.typed +0 -0
- cweb_forge-0.1.0/src/cweb_forge.egg-info/PKG-INFO +270 -0
- cweb_forge-0.1.0/src/cweb_forge.egg-info/SOURCES.txt +11 -0
- cweb_forge-0.1.0/src/cweb_forge.egg-info/dependency_links.txt +1 -0
- cweb_forge-0.1.0/src/cweb_forge.egg-info/requires.txt +1 -0
- cweb_forge-0.1.0/src/cweb_forge.egg-info/top_level.txt +1 -0
- cweb_forge-0.1.0/tests/test_client.py +29 -0
cweb_forge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PyStrap
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cweb-forge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for cWeb Forge serverless data, for applications and notebooks
|
|
5
|
+
Author: PyStrap
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://cweb.app
|
|
8
|
+
Project-URL: Documentation, https://github.com/pystrap/cweb-forge-python#readme
|
|
9
|
+
Project-URL: Source, https://github.com/pystrap/cweb-forge-python
|
|
10
|
+
Project-URL: Issues, https://github.com/pystrap/cweb-forge-python/issues
|
|
11
|
+
Keywords: cweb,forge,serverless,database,sdk,notebooks
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx<1,>=0.28
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# cWeb Forge · Python
|
|
23
|
+
|
|
24
|
+
**Serverless data and authentication API for your next application.**
|
|
25
|
+
|
|
26
|
+
Use cWeb Forge collections from Python backends, scripts and notebooks without
|
|
27
|
+
building a separate data API. This is the official Python SDK for
|
|
28
|
+
[cWeb Forge](https://cweb.app), with synchronous and asynchronous clients for
|
|
29
|
+
applications on cWeb or any other host.
|
|
30
|
+
|
|
31
|
+
- JSON document operations with collection access rules.
|
|
32
|
+
- Cursor pagination, equality filters and streaming iteration over pages.
|
|
33
|
+
- Version checks for concurrent edits and idempotency keys for create retries.
|
|
34
|
+
- Backend service keys or application-user access tokens.
|
|
35
|
+
- Works with Django, FastAPI, cWeb Studio and other Python notebook environments.
|
|
36
|
+
|
|
37
|
+
[Getting started](#getting-started) · [Async usage](#async-usage) ·
|
|
38
|
+
[Notebooks](#notebooks) · [Contributing](#contributing)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10 or later.
|
|
43
|
+
|
|
44
|
+
The first PyPI release is being prepared. Until it is available, clone this
|
|
45
|
+
repository and install from its local path:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
python -m pip install ./cweb-forge-python
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Once published, install from PyPI:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
python -m pip install cweb-forge
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The package name is `cweb-forge`; import it as `cweb_forge`.
|
|
58
|
+
|
|
59
|
+
## Getting started
|
|
60
|
+
|
|
61
|
+
1. Create a Forge application in the [cWeb dashboard](https://portal.cweb.app)
|
|
62
|
+
and copy its API endpoint, including `/v1/`.
|
|
63
|
+
2. Create a service key restricted to the collections and actions your backend
|
|
64
|
+
needs. Supply it as `CWEB_FORGE_KEY` through your environment or secret manager.
|
|
65
|
+
3. Use the application's `records` collection to try the SDK:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import os
|
|
69
|
+
from cweb_forge import ForgeClient
|
|
70
|
+
|
|
71
|
+
endpoint = 'https://my-app.forge.cweb.app/v1/' # Replace with your endpoint.
|
|
72
|
+
|
|
73
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
74
|
+
records = forge.collection('records')
|
|
75
|
+
created = records.create({'title': 'Draft', 'status': 'active'})
|
|
76
|
+
saved = records.update(
|
|
77
|
+
created['id'], {'title': 'Published'}, version=created['version']
|
|
78
|
+
)
|
|
79
|
+
print(saved['id'])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use a context manager to close connections, or call `forge.close()` when
|
|
83
|
+
finished. A service key may access records across application users according
|
|
84
|
+
to its scopes. Never embed one in frontend code, mobile apps or shared notebooks.
|
|
85
|
+
|
|
86
|
+
### Application-user tokens
|
|
87
|
+
|
|
88
|
+
`token` can instead be an application user's access token or a callable that
|
|
89
|
+
returns a fresh token before each request. The asynchronous client also accepts
|
|
90
|
+
an async callable. Token validation and collection policies are enforced by the
|
|
91
|
+
service; default owner access keeps a user's documents private to that user.
|
|
92
|
+
|
|
93
|
+
The Python SDK does not perform interactive sign-in or automatically refresh a
|
|
94
|
+
fixed token string. Your application's authentication integration must supply
|
|
95
|
+
and refresh its tokens. Use the
|
|
96
|
+
[JavaScript SDK](https://github.com/pystrap/cweb-forge-js#authentication) for
|
|
97
|
+
browser sign-in. Never use a cWeb dashboard login token as a Forge application token.
|
|
98
|
+
|
|
99
|
+
### Public reads without a token
|
|
100
|
+
|
|
101
|
+
Set the collection's read policy to **Anyone (public)** under **Collections &
|
|
102
|
+
rules**, then omit `token`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from cweb_forge import ForgeClient
|
|
106
|
+
|
|
107
|
+
with ForgeClient('https://my-app.forge.cweb.app/v1/') as forge:
|
|
108
|
+
page = forge.collection('products').list(limit=50)
|
|
109
|
+
for document in page['items']:
|
|
110
|
+
print(document['data'])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every document and field in that collection is public. Keep private data in
|
|
114
|
+
separate collections. Anonymous writes are not supported, and public reads
|
|
115
|
+
still consume the application's request allowances.
|
|
116
|
+
|
|
117
|
+
## Working with documents
|
|
118
|
+
|
|
119
|
+
Your JSON object is returned in `document['data']`, alongside metadata including
|
|
120
|
+
`id`, `owner`, `version`, `created_at` and `updated_at`.
|
|
121
|
+
|
|
122
|
+
| Method | Purpose |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `list(limit=50, after=None, field=None, equals=None)` | Read a page with `items` and `next_cursor`. |
|
|
125
|
+
| `get(document_id)` | Read one document. |
|
|
126
|
+
| `create(data, idempotency_key=None)` | Create a document; a key is generated if omitted. |
|
|
127
|
+
| `update(document_id, data, version=...)` | Patch the supplied fields. |
|
|
128
|
+
| `replace(document_id, data, version=...)` | Replace the document's data. |
|
|
129
|
+
| `delete(document_id, version=...)` | Delete a document. |
|
|
130
|
+
| `iterate(limit=50, field=None, equals=None)` | Iterate over documents across successive pages. |
|
|
131
|
+
|
|
132
|
+
Collection names start with a lowercase letter, contain lowercase letters,
|
|
133
|
+
numbers or underscores, and have a maximum length of 64 characters. Document
|
|
134
|
+
IDs are UUIDs returned by Forge.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
138
|
+
records = forge.collection('records')
|
|
139
|
+
page = records.list(limit=50, field='status', equals='active')
|
|
140
|
+
if page['next_cursor']:
|
|
141
|
+
next_page = records.list(
|
|
142
|
+
limit=50, after=page['next_cursor'], field='status', equals='active'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for document in records.iterate(limit=100, field='status', equals='active'):
|
|
146
|
+
print(document['id'])
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Equality filters accept scalar field values. Page limits range from 1 to 100
|
|
150
|
+
documents. Iteration fetches one page at a time; collecting every result into a
|
|
151
|
+
list still uses memory for the entire result.
|
|
152
|
+
|
|
153
|
+
Use the latest returned `version` for each update, replacement or deletion.
|
|
154
|
+
On a `409` conflict, reload and resolve the change before retrying. To retry a
|
|
155
|
+
create after an uncertain network outcome, reuse the same `idempotency_key`
|
|
156
|
+
for that logical operation within the server's 24-hour window. The SDK does not
|
|
157
|
+
automatically retry writes.
|
|
158
|
+
|
|
159
|
+
## Async usage
|
|
160
|
+
|
|
161
|
+
Use `AsyncForgeClient` in asynchronous applications. Await document operations,
|
|
162
|
+
and use `async for` when iterating:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import asyncio
|
|
166
|
+
import os
|
|
167
|
+
from cweb_forge import AsyncForgeClient
|
|
168
|
+
|
|
169
|
+
async def main():
|
|
170
|
+
async with AsyncForgeClient(
|
|
171
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
172
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
173
|
+
) as forge:
|
|
174
|
+
records = forge.collection('records')
|
|
175
|
+
created = await records.create({'title': 'Async example'})
|
|
176
|
+
print(created['id'])
|
|
177
|
+
async for document in records.iterate(limit=100):
|
|
178
|
+
print(document['id'])
|
|
179
|
+
|
|
180
|
+
asyncio.run(main())
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
In notebooks with an existing event loop, use `await main()` instead of
|
|
184
|
+
`asyncio.run(main())`. Close a manually managed async client with
|
|
185
|
+
`await forge.close()`.
|
|
186
|
+
|
|
187
|
+
## Notebooks
|
|
188
|
+
|
|
189
|
+
The SDK can fetch data for pandas, DuckDB or your own analysis code. Those
|
|
190
|
+
libraries are optional and installed separately. Start with a read-only key and
|
|
191
|
+
a bounded sample:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import os
|
|
195
|
+
import pandas as pd
|
|
196
|
+
from cweb_forge import ForgeClient
|
|
197
|
+
|
|
198
|
+
# Configure this secret in the notebook environment; do not paste it into a cell.
|
|
199
|
+
with ForgeClient(
|
|
200
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
201
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
202
|
+
) as forge:
|
|
203
|
+
page = forge.collection('sales').list(limit=100)
|
|
204
|
+
frame = pd.DataFrame([document['data'] for document in page['items']])
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This reads a page of JSON documents; it does not mount a database or automatically
|
|
208
|
+
copy the whole collection into Studio's catalog. Do not save credentials or
|
|
209
|
+
sensitive results in notebook outputs that you intend to share.
|
|
210
|
+
|
|
211
|
+
## Errors and timeouts
|
|
212
|
+
|
|
213
|
+
Catch `ForgeError` for API or transport failures. It includes `status`, `code`
|
|
214
|
+
and `request_id`; network failures use status `0`.
|
|
215
|
+
|
|
216
|
+
| Status | Suggested handling |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `401` | Renew credentials or sign the application user in again. |
|
|
219
|
+
| `403` | Check collection rules and service-key permissions. |
|
|
220
|
+
| `409` | Resolve a concurrent edit or idempotency conflict. |
|
|
221
|
+
| `429` | Inspect the error code for rate or quota limits before retrying. |
|
|
222
|
+
| `503` | Retry later with bounded backoff when safe. |
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from cweb_forge import ForgeError
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
229
|
+
page = forge.collection('records').list()
|
|
230
|
+
except ForgeError as error:
|
|
231
|
+
print(f'Forge request failed: {error.status} {error.code}')
|
|
232
|
+
# Share error.request_id with support if present; never log credentials.
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
HTTPX timeouts default to 15 seconds and can be configured with `timeout=30`
|
|
236
|
+
on the client. These are transport timeouts, not a deadline for a complete
|
|
237
|
+
multi-page iteration. HTTPS is required except on localhost; redirects and
|
|
238
|
+
proxy settings inherited from environment variables are disabled.
|
|
239
|
+
|
|
240
|
+
## Security and support
|
|
241
|
+
|
|
242
|
+
Use narrowly scoped keys and rotate them if exposed. Keep keys, application-user
|
|
243
|
+
tokens, private data and authentication headers out of logs and Git commits.
|
|
244
|
+
For vulnerabilities, follow the
|
|
245
|
+
[security policy](https://github.com/pystrap/cweb-forge-python/blob/main/SECURITY.md).
|
|
246
|
+
|
|
247
|
+
Report SDK bugs through
|
|
248
|
+
[GitHub issues](https://github.com/pystrap/cweb-forge-python/issues), with the
|
|
249
|
+
SDK/Python versions and a minimal reproduction. For account or billing help,
|
|
250
|
+
contact cWeb Support at [support@cweb.app](mailto:support@cweb.app).
|
|
251
|
+
|
|
252
|
+
## Contributing
|
|
253
|
+
|
|
254
|
+
Focused fixes, regression tests and documentation improvements are welcome.
|
|
255
|
+
From a clone of this repository:
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
python -m venv .venv
|
|
259
|
+
. .venv/bin/activate
|
|
260
|
+
python -m pip install -e .
|
|
261
|
+
python -m unittest discover -s tests -v
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Tests use mocked HTTP responses and do not require a live Forge account.
|
|
265
|
+
Include tests for changed behavior and never submit customer data or credentials.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
[MIT](https://github.com/pystrap/cweb-forge-python/blob/main/LICENSE).
|
|
270
|
+
Maintained by [PyStrap](https://pystrap.co.uk), the team behind cWeb.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# cWeb Forge · Python
|
|
2
|
+
|
|
3
|
+
**Serverless data and authentication API for your next application.**
|
|
4
|
+
|
|
5
|
+
Use cWeb Forge collections from Python backends, scripts and notebooks without
|
|
6
|
+
building a separate data API. This is the official Python SDK for
|
|
7
|
+
[cWeb Forge](https://cweb.app), with synchronous and asynchronous clients for
|
|
8
|
+
applications on cWeb or any other host.
|
|
9
|
+
|
|
10
|
+
- JSON document operations with collection access rules.
|
|
11
|
+
- Cursor pagination, equality filters and streaming iteration over pages.
|
|
12
|
+
- Version checks for concurrent edits and idempotency keys for create retries.
|
|
13
|
+
- Backend service keys or application-user access tokens.
|
|
14
|
+
- Works with Django, FastAPI, cWeb Studio and other Python notebook environments.
|
|
15
|
+
|
|
16
|
+
[Getting started](#getting-started) · [Async usage](#async-usage) ·
|
|
17
|
+
[Notebooks](#notebooks) · [Contributing](#contributing)
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Requires Python 3.10 or later.
|
|
22
|
+
|
|
23
|
+
The first PyPI release is being prepared. Until it is available, clone this
|
|
24
|
+
repository and install from its local path:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
python -m pip install ./cweb-forge-python
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Once published, install from PyPI:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
python -m pip install cweb-forge
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The package name is `cweb-forge`; import it as `cweb_forge`.
|
|
37
|
+
|
|
38
|
+
## Getting started
|
|
39
|
+
|
|
40
|
+
1. Create a Forge application in the [cWeb dashboard](https://portal.cweb.app)
|
|
41
|
+
and copy its API endpoint, including `/v1/`.
|
|
42
|
+
2. Create a service key restricted to the collections and actions your backend
|
|
43
|
+
needs. Supply it as `CWEB_FORGE_KEY` through your environment or secret manager.
|
|
44
|
+
3. Use the application's `records` collection to try the SDK:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
from cweb_forge import ForgeClient
|
|
49
|
+
|
|
50
|
+
endpoint = 'https://my-app.forge.cweb.app/v1/' # Replace with your endpoint.
|
|
51
|
+
|
|
52
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
53
|
+
records = forge.collection('records')
|
|
54
|
+
created = records.create({'title': 'Draft', 'status': 'active'})
|
|
55
|
+
saved = records.update(
|
|
56
|
+
created['id'], {'title': 'Published'}, version=created['version']
|
|
57
|
+
)
|
|
58
|
+
print(saved['id'])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use a context manager to close connections, or call `forge.close()` when
|
|
62
|
+
finished. A service key may access records across application users according
|
|
63
|
+
to its scopes. Never embed one in frontend code, mobile apps or shared notebooks.
|
|
64
|
+
|
|
65
|
+
### Application-user tokens
|
|
66
|
+
|
|
67
|
+
`token` can instead be an application user's access token or a callable that
|
|
68
|
+
returns a fresh token before each request. The asynchronous client also accepts
|
|
69
|
+
an async callable. Token validation and collection policies are enforced by the
|
|
70
|
+
service; default owner access keeps a user's documents private to that user.
|
|
71
|
+
|
|
72
|
+
The Python SDK does not perform interactive sign-in or automatically refresh a
|
|
73
|
+
fixed token string. Your application's authentication integration must supply
|
|
74
|
+
and refresh its tokens. Use the
|
|
75
|
+
[JavaScript SDK](https://github.com/pystrap/cweb-forge-js#authentication) for
|
|
76
|
+
browser sign-in. Never use a cWeb dashboard login token as a Forge application token.
|
|
77
|
+
|
|
78
|
+
### Public reads without a token
|
|
79
|
+
|
|
80
|
+
Set the collection's read policy to **Anyone (public)** under **Collections &
|
|
81
|
+
rules**, then omit `token`:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from cweb_forge import ForgeClient
|
|
85
|
+
|
|
86
|
+
with ForgeClient('https://my-app.forge.cweb.app/v1/') as forge:
|
|
87
|
+
page = forge.collection('products').list(limit=50)
|
|
88
|
+
for document in page['items']:
|
|
89
|
+
print(document['data'])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Every document and field in that collection is public. Keep private data in
|
|
93
|
+
separate collections. Anonymous writes are not supported, and public reads
|
|
94
|
+
still consume the application's request allowances.
|
|
95
|
+
|
|
96
|
+
## Working with documents
|
|
97
|
+
|
|
98
|
+
Your JSON object is returned in `document['data']`, alongside metadata including
|
|
99
|
+
`id`, `owner`, `version`, `created_at` and `updated_at`.
|
|
100
|
+
|
|
101
|
+
| Method | Purpose |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `list(limit=50, after=None, field=None, equals=None)` | Read a page with `items` and `next_cursor`. |
|
|
104
|
+
| `get(document_id)` | Read one document. |
|
|
105
|
+
| `create(data, idempotency_key=None)` | Create a document; a key is generated if omitted. |
|
|
106
|
+
| `update(document_id, data, version=...)` | Patch the supplied fields. |
|
|
107
|
+
| `replace(document_id, data, version=...)` | Replace the document's data. |
|
|
108
|
+
| `delete(document_id, version=...)` | Delete a document. |
|
|
109
|
+
| `iterate(limit=50, field=None, equals=None)` | Iterate over documents across successive pages. |
|
|
110
|
+
|
|
111
|
+
Collection names start with a lowercase letter, contain lowercase letters,
|
|
112
|
+
numbers or underscores, and have a maximum length of 64 characters. Document
|
|
113
|
+
IDs are UUIDs returned by Forge.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
117
|
+
records = forge.collection('records')
|
|
118
|
+
page = records.list(limit=50, field='status', equals='active')
|
|
119
|
+
if page['next_cursor']:
|
|
120
|
+
next_page = records.list(
|
|
121
|
+
limit=50, after=page['next_cursor'], field='status', equals='active'
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for document in records.iterate(limit=100, field='status', equals='active'):
|
|
125
|
+
print(document['id'])
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Equality filters accept scalar field values. Page limits range from 1 to 100
|
|
129
|
+
documents. Iteration fetches one page at a time; collecting every result into a
|
|
130
|
+
list still uses memory for the entire result.
|
|
131
|
+
|
|
132
|
+
Use the latest returned `version` for each update, replacement or deletion.
|
|
133
|
+
On a `409` conflict, reload and resolve the change before retrying. To retry a
|
|
134
|
+
create after an uncertain network outcome, reuse the same `idempotency_key`
|
|
135
|
+
for that logical operation within the server's 24-hour window. The SDK does not
|
|
136
|
+
automatically retry writes.
|
|
137
|
+
|
|
138
|
+
## Async usage
|
|
139
|
+
|
|
140
|
+
Use `AsyncForgeClient` in asynchronous applications. Await document operations,
|
|
141
|
+
and use `async for` when iterating:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import asyncio
|
|
145
|
+
import os
|
|
146
|
+
from cweb_forge import AsyncForgeClient
|
|
147
|
+
|
|
148
|
+
async def main():
|
|
149
|
+
async with AsyncForgeClient(
|
|
150
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
151
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
152
|
+
) as forge:
|
|
153
|
+
records = forge.collection('records')
|
|
154
|
+
created = await records.create({'title': 'Async example'})
|
|
155
|
+
print(created['id'])
|
|
156
|
+
async for document in records.iterate(limit=100):
|
|
157
|
+
print(document['id'])
|
|
158
|
+
|
|
159
|
+
asyncio.run(main())
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
In notebooks with an existing event loop, use `await main()` instead of
|
|
163
|
+
`asyncio.run(main())`. Close a manually managed async client with
|
|
164
|
+
`await forge.close()`.
|
|
165
|
+
|
|
166
|
+
## Notebooks
|
|
167
|
+
|
|
168
|
+
The SDK can fetch data for pandas, DuckDB or your own analysis code. Those
|
|
169
|
+
libraries are optional and installed separately. Start with a read-only key and
|
|
170
|
+
a bounded sample:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import os
|
|
174
|
+
import pandas as pd
|
|
175
|
+
from cweb_forge import ForgeClient
|
|
176
|
+
|
|
177
|
+
# Configure this secret in the notebook environment; do not paste it into a cell.
|
|
178
|
+
with ForgeClient(
|
|
179
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
180
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
181
|
+
) as forge:
|
|
182
|
+
page = forge.collection('sales').list(limit=100)
|
|
183
|
+
frame = pd.DataFrame([document['data'] for document in page['items']])
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
This reads a page of JSON documents; it does not mount a database or automatically
|
|
187
|
+
copy the whole collection into Studio's catalog. Do not save credentials or
|
|
188
|
+
sensitive results in notebook outputs that you intend to share.
|
|
189
|
+
|
|
190
|
+
## Errors and timeouts
|
|
191
|
+
|
|
192
|
+
Catch `ForgeError` for API or transport failures. It includes `status`, `code`
|
|
193
|
+
and `request_id`; network failures use status `0`.
|
|
194
|
+
|
|
195
|
+
| Status | Suggested handling |
|
|
196
|
+
| --- | --- |
|
|
197
|
+
| `401` | Renew credentials or sign the application user in again. |
|
|
198
|
+
| `403` | Check collection rules and service-key permissions. |
|
|
199
|
+
| `409` | Resolve a concurrent edit or idempotency conflict. |
|
|
200
|
+
| `429` | Inspect the error code for rate or quota limits before retrying. |
|
|
201
|
+
| `503` | Retry later with bounded backoff when safe. |
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from cweb_forge import ForgeError
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
208
|
+
page = forge.collection('records').list()
|
|
209
|
+
except ForgeError as error:
|
|
210
|
+
print(f'Forge request failed: {error.status} {error.code}')
|
|
211
|
+
# Share error.request_id with support if present; never log credentials.
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
HTTPX timeouts default to 15 seconds and can be configured with `timeout=30`
|
|
215
|
+
on the client. These are transport timeouts, not a deadline for a complete
|
|
216
|
+
multi-page iteration. HTTPS is required except on localhost; redirects and
|
|
217
|
+
proxy settings inherited from environment variables are disabled.
|
|
218
|
+
|
|
219
|
+
## Security and support
|
|
220
|
+
|
|
221
|
+
Use narrowly scoped keys and rotate them if exposed. Keep keys, application-user
|
|
222
|
+
tokens, private data and authentication headers out of logs and Git commits.
|
|
223
|
+
For vulnerabilities, follow the
|
|
224
|
+
[security policy](https://github.com/pystrap/cweb-forge-python/blob/main/SECURITY.md).
|
|
225
|
+
|
|
226
|
+
Report SDK bugs through
|
|
227
|
+
[GitHub issues](https://github.com/pystrap/cweb-forge-python/issues), with the
|
|
228
|
+
SDK/Python versions and a minimal reproduction. For account or billing help,
|
|
229
|
+
contact cWeb Support at [support@cweb.app](mailto:support@cweb.app).
|
|
230
|
+
|
|
231
|
+
## Contributing
|
|
232
|
+
|
|
233
|
+
Focused fixes, regression tests and documentation improvements are welcome.
|
|
234
|
+
From a clone of this repository:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
python -m venv .venv
|
|
238
|
+
. .venv/bin/activate
|
|
239
|
+
python -m pip install -e .
|
|
240
|
+
python -m unittest discover -s tests -v
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Tests use mocked HTTP responses and do not require a live Forge account.
|
|
244
|
+
Include tests for changed behavior and never submit customer data or credentials.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
[MIT](https://github.com/pystrap/cweb-forge-python/blob/main/LICENSE).
|
|
249
|
+
Maintained by [PyStrap](https://pystrap.co.uk), the team behind cWeb.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
[project]
|
|
5
|
+
name = "cweb-forge"
|
|
6
|
+
version = "0.1.0"
|
|
7
|
+
description = "Python SDK for cWeb Forge serverless data, for applications and notebooks"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{name = "PyStrap"}]
|
|
10
|
+
keywords = ["cweb", "forge", "serverless", "database", "sdk", "notebooks"]
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["httpx>=0.28,<1"]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Topic :: Database :: Front-Ends",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://cweb.app"
|
|
23
|
+
Documentation = "https://github.com/pystrap/cweb-forge-python#readme"
|
|
24
|
+
Source = "https://github.com/pystrap/cweb-forge-python"
|
|
25
|
+
Issues = "https://github.com/pystrap/cweb-forge-python/issues"
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
cweb_forge = ["py.typed"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""cWeb Forge: bounded HTTP clients, no hidden global credentials or retries."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
__all__ = ['ForgeClient', 'AsyncForgeClient', 'ForgeError']
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ForgeError(Exception):
|
|
13
|
+
def __init__(self, message, status=0, code='network_error', request_id=''):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status, self.code, self.request_id = status, code, request_id
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _endpoint(url):
|
|
19
|
+
p = urlsplit(url)
|
|
20
|
+
local = p.hostname in ('localhost', '127.0.0.1', '::1') or (p.hostname or '').endswith('.localhost')
|
|
21
|
+
if not p.hostname or (p.scheme != 'https' and not (p.scheme == 'http' and local)) or p.username or p.password or p.query or p.fragment:
|
|
22
|
+
raise ValueError('Use an HTTPS endpoint; HTTP is permitted only for local development.')
|
|
23
|
+
return url.rstrip('/')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _name(value):
|
|
27
|
+
if not re.fullmatch(r'[a-z][a-z0-9_]{0,63}', value):
|
|
28
|
+
raise ValueError('Invalid collection name.')
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _result(response):
|
|
33
|
+
if response.status_code == 204:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
value = response.json()
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ForgeError('Forge returned an invalid response.', response.status_code, 'invalid_response')
|
|
39
|
+
if not response.is_success:
|
|
40
|
+
error = value.get('error', {})
|
|
41
|
+
raise ForgeError(error.get('message', 'Forge request failed.'), response.status_code,
|
|
42
|
+
error.get('code', 'request_failed'), response.headers.get('X-Request-ID', ''))
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ForgeClient:
|
|
47
|
+
def __init__(self, endpoint: str, token=None, *, timeout: float = 15, transport=None):
|
|
48
|
+
self.endpoint, self.token = _endpoint(endpoint), token
|
|
49
|
+
self._http = httpx.Client(timeout=timeout, follow_redirects=False, trust_env=False, transport=transport)
|
|
50
|
+
|
|
51
|
+
def request(self, method, path, *, body=None, headers=None, params=None):
|
|
52
|
+
token = self.token() if callable(self.token) else self.token
|
|
53
|
+
auth = {'Authorization': 'Bearer ' + token} if token else {}
|
|
54
|
+
try:
|
|
55
|
+
return _result(self._http.request(method, self.endpoint + path, json=body, params=params,
|
|
56
|
+
headers={**auth, **(headers or {})}))
|
|
57
|
+
except httpx.HTTPError:
|
|
58
|
+
raise ForgeError('Unable to complete the Forge request.') from None
|
|
59
|
+
|
|
60
|
+
def collection(self, name: str):
|
|
61
|
+
return Collection(self, name)
|
|
62
|
+
|
|
63
|
+
def close(self): self._http.close()
|
|
64
|
+
def __enter__(self): return self
|
|
65
|
+
def __exit__(self, *args): self.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Collection:
|
|
69
|
+
def __init__(self, client, name):
|
|
70
|
+
self.client, self.path = client, '/collections/' + _name(name) + '/documents'
|
|
71
|
+
|
|
72
|
+
def list(self, *, limit=50, after=None, field=None, equals=None):
|
|
73
|
+
params = {'limit': limit}
|
|
74
|
+
if after: params['after'] = after
|
|
75
|
+
if field: params.update(field=field, value=json.dumps(equals, allow_nan=False))
|
|
76
|
+
return self.client.request('GET', self.path, params=params)
|
|
77
|
+
|
|
78
|
+
def get(self, document_id):
|
|
79
|
+
return self.client.request('GET', self.path + '/' + str(uuid.UUID(str(document_id))))
|
|
80
|
+
|
|
81
|
+
def create(self, data, *, idempotency_key=None):
|
|
82
|
+
return self.client.request('POST', self.path, body=data, headers={'Idempotency-Key': idempotency_key or str(uuid.uuid4())})
|
|
83
|
+
|
|
84
|
+
def replace(self, document_id, data, *, version):
|
|
85
|
+
return self.client.request('PUT', self.path + '/' + str(uuid.UUID(str(document_id))), body=data, headers={'If-Match': f'"{version}"'})
|
|
86
|
+
|
|
87
|
+
def update(self, document_id, data, *, version):
|
|
88
|
+
return self.client.request('PATCH', self.path + '/' + str(uuid.UUID(str(document_id))), body=data, headers={'If-Match': f'"{version}"'})
|
|
89
|
+
|
|
90
|
+
def delete(self, document_id, *, version):
|
|
91
|
+
return self.client.request('DELETE', self.path + '/' + str(uuid.UUID(str(document_id))), headers={'If-Match': f'"{version}"'})
|
|
92
|
+
|
|
93
|
+
def iterate(self, **options):
|
|
94
|
+
after = None
|
|
95
|
+
while True:
|
|
96
|
+
result = self.list(after=after, **options)
|
|
97
|
+
yield from result['items']
|
|
98
|
+
cursor = result.get('next_cursor')
|
|
99
|
+
if not cursor: return
|
|
100
|
+
if cursor == after: raise ForgeError('Invalid pagination cursor.', code='invalid_response')
|
|
101
|
+
after = cursor
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AsyncForgeClient:
|
|
105
|
+
def __init__(self, endpoint: str, token=None, *, timeout: float = 15, transport=None):
|
|
106
|
+
self.endpoint, self.token = _endpoint(endpoint), token
|
|
107
|
+
self._http = httpx.AsyncClient(timeout=timeout, follow_redirects=False, trust_env=False, transport=transport)
|
|
108
|
+
|
|
109
|
+
async def request(self, method, path, *, body=None, headers=None, params=None):
|
|
110
|
+
import inspect
|
|
111
|
+
token = self.token() if callable(self.token) else self.token
|
|
112
|
+
if inspect.isawaitable(token): token = await token
|
|
113
|
+
auth = {'Authorization': 'Bearer ' + token} if token else {}
|
|
114
|
+
try:
|
|
115
|
+
return _result(await self._http.request(method, self.endpoint + path, json=body, params=params, headers={**auth, **(headers or {})}))
|
|
116
|
+
except httpx.HTTPError:
|
|
117
|
+
raise ForgeError('Unable to complete the Forge request.') from None
|
|
118
|
+
|
|
119
|
+
def collection(self, name: str): return AsyncCollection(self, name)
|
|
120
|
+
async def close(self): await self._http.aclose()
|
|
121
|
+
async def __aenter__(self): return self
|
|
122
|
+
async def __aexit__(self, *args): await self.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncCollection(Collection):
|
|
126
|
+
async def iterate(self, **options):
|
|
127
|
+
after = None
|
|
128
|
+
while True:
|
|
129
|
+
result = await self.list(after=after, **options)
|
|
130
|
+
for item in result['items']: yield item
|
|
131
|
+
cursor = result.get('next_cursor')
|
|
132
|
+
if not cursor: return
|
|
133
|
+
if cursor == after: raise ForgeError('Invalid pagination cursor.', code='invalid_response')
|
|
134
|
+
after = cursor
|
|
File without changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cweb-forge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for cWeb Forge serverless data, for applications and notebooks
|
|
5
|
+
Author: PyStrap
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://cweb.app
|
|
8
|
+
Project-URL: Documentation, https://github.com/pystrap/cweb-forge-python#readme
|
|
9
|
+
Project-URL: Source, https://github.com/pystrap/cweb-forge-python
|
|
10
|
+
Project-URL: Issues, https://github.com/pystrap/cweb-forge-python/issues
|
|
11
|
+
Keywords: cweb,forge,serverless,database,sdk,notebooks
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx<1,>=0.28
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# cWeb Forge · Python
|
|
23
|
+
|
|
24
|
+
**Serverless data and authentication API for your next application.**
|
|
25
|
+
|
|
26
|
+
Use cWeb Forge collections from Python backends, scripts and notebooks without
|
|
27
|
+
building a separate data API. This is the official Python SDK for
|
|
28
|
+
[cWeb Forge](https://cweb.app), with synchronous and asynchronous clients for
|
|
29
|
+
applications on cWeb or any other host.
|
|
30
|
+
|
|
31
|
+
- JSON document operations with collection access rules.
|
|
32
|
+
- Cursor pagination, equality filters and streaming iteration over pages.
|
|
33
|
+
- Version checks for concurrent edits and idempotency keys for create retries.
|
|
34
|
+
- Backend service keys or application-user access tokens.
|
|
35
|
+
- Works with Django, FastAPI, cWeb Studio and other Python notebook environments.
|
|
36
|
+
|
|
37
|
+
[Getting started](#getting-started) · [Async usage](#async-usage) ·
|
|
38
|
+
[Notebooks](#notebooks) · [Contributing](#contributing)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10 or later.
|
|
43
|
+
|
|
44
|
+
The first PyPI release is being prepared. Until it is available, clone this
|
|
45
|
+
repository and install from its local path:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
python -m pip install ./cweb-forge-python
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Once published, install from PyPI:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
python -m pip install cweb-forge
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The package name is `cweb-forge`; import it as `cweb_forge`.
|
|
58
|
+
|
|
59
|
+
## Getting started
|
|
60
|
+
|
|
61
|
+
1. Create a Forge application in the [cWeb dashboard](https://portal.cweb.app)
|
|
62
|
+
and copy its API endpoint, including `/v1/`.
|
|
63
|
+
2. Create a service key restricted to the collections and actions your backend
|
|
64
|
+
needs. Supply it as `CWEB_FORGE_KEY` through your environment or secret manager.
|
|
65
|
+
3. Use the application's `records` collection to try the SDK:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import os
|
|
69
|
+
from cweb_forge import ForgeClient
|
|
70
|
+
|
|
71
|
+
endpoint = 'https://my-app.forge.cweb.app/v1/' # Replace with your endpoint.
|
|
72
|
+
|
|
73
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
74
|
+
records = forge.collection('records')
|
|
75
|
+
created = records.create({'title': 'Draft', 'status': 'active'})
|
|
76
|
+
saved = records.update(
|
|
77
|
+
created['id'], {'title': 'Published'}, version=created['version']
|
|
78
|
+
)
|
|
79
|
+
print(saved['id'])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Use a context manager to close connections, or call `forge.close()` when
|
|
83
|
+
finished. A service key may access records across application users according
|
|
84
|
+
to its scopes. Never embed one in frontend code, mobile apps or shared notebooks.
|
|
85
|
+
|
|
86
|
+
### Application-user tokens
|
|
87
|
+
|
|
88
|
+
`token` can instead be an application user's access token or a callable that
|
|
89
|
+
returns a fresh token before each request. The asynchronous client also accepts
|
|
90
|
+
an async callable. Token validation and collection policies are enforced by the
|
|
91
|
+
service; default owner access keeps a user's documents private to that user.
|
|
92
|
+
|
|
93
|
+
The Python SDK does not perform interactive sign-in or automatically refresh a
|
|
94
|
+
fixed token string. Your application's authentication integration must supply
|
|
95
|
+
and refresh its tokens. Use the
|
|
96
|
+
[JavaScript SDK](https://github.com/pystrap/cweb-forge-js#authentication) for
|
|
97
|
+
browser sign-in. Never use a cWeb dashboard login token as a Forge application token.
|
|
98
|
+
|
|
99
|
+
### Public reads without a token
|
|
100
|
+
|
|
101
|
+
Set the collection's read policy to **Anyone (public)** under **Collections &
|
|
102
|
+
rules**, then omit `token`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from cweb_forge import ForgeClient
|
|
106
|
+
|
|
107
|
+
with ForgeClient('https://my-app.forge.cweb.app/v1/') as forge:
|
|
108
|
+
page = forge.collection('products').list(limit=50)
|
|
109
|
+
for document in page['items']:
|
|
110
|
+
print(document['data'])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Every document and field in that collection is public. Keep private data in
|
|
114
|
+
separate collections. Anonymous writes are not supported, and public reads
|
|
115
|
+
still consume the application's request allowances.
|
|
116
|
+
|
|
117
|
+
## Working with documents
|
|
118
|
+
|
|
119
|
+
Your JSON object is returned in `document['data']`, alongside metadata including
|
|
120
|
+
`id`, `owner`, `version`, `created_at` and `updated_at`.
|
|
121
|
+
|
|
122
|
+
| Method | Purpose |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `list(limit=50, after=None, field=None, equals=None)` | Read a page with `items` and `next_cursor`. |
|
|
125
|
+
| `get(document_id)` | Read one document. |
|
|
126
|
+
| `create(data, idempotency_key=None)` | Create a document; a key is generated if omitted. |
|
|
127
|
+
| `update(document_id, data, version=...)` | Patch the supplied fields. |
|
|
128
|
+
| `replace(document_id, data, version=...)` | Replace the document's data. |
|
|
129
|
+
| `delete(document_id, version=...)` | Delete a document. |
|
|
130
|
+
| `iterate(limit=50, field=None, equals=None)` | Iterate over documents across successive pages. |
|
|
131
|
+
|
|
132
|
+
Collection names start with a lowercase letter, contain lowercase letters,
|
|
133
|
+
numbers or underscores, and have a maximum length of 64 characters. Document
|
|
134
|
+
IDs are UUIDs returned by Forge.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
138
|
+
records = forge.collection('records')
|
|
139
|
+
page = records.list(limit=50, field='status', equals='active')
|
|
140
|
+
if page['next_cursor']:
|
|
141
|
+
next_page = records.list(
|
|
142
|
+
limit=50, after=page['next_cursor'], field='status', equals='active'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for document in records.iterate(limit=100, field='status', equals='active'):
|
|
146
|
+
print(document['id'])
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Equality filters accept scalar field values. Page limits range from 1 to 100
|
|
150
|
+
documents. Iteration fetches one page at a time; collecting every result into a
|
|
151
|
+
list still uses memory for the entire result.
|
|
152
|
+
|
|
153
|
+
Use the latest returned `version` for each update, replacement or deletion.
|
|
154
|
+
On a `409` conflict, reload and resolve the change before retrying. To retry a
|
|
155
|
+
create after an uncertain network outcome, reuse the same `idempotency_key`
|
|
156
|
+
for that logical operation within the server's 24-hour window. The SDK does not
|
|
157
|
+
automatically retry writes.
|
|
158
|
+
|
|
159
|
+
## Async usage
|
|
160
|
+
|
|
161
|
+
Use `AsyncForgeClient` in asynchronous applications. Await document operations,
|
|
162
|
+
and use `async for` when iterating:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import asyncio
|
|
166
|
+
import os
|
|
167
|
+
from cweb_forge import AsyncForgeClient
|
|
168
|
+
|
|
169
|
+
async def main():
|
|
170
|
+
async with AsyncForgeClient(
|
|
171
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
172
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
173
|
+
) as forge:
|
|
174
|
+
records = forge.collection('records')
|
|
175
|
+
created = await records.create({'title': 'Async example'})
|
|
176
|
+
print(created['id'])
|
|
177
|
+
async for document in records.iterate(limit=100):
|
|
178
|
+
print(document['id'])
|
|
179
|
+
|
|
180
|
+
asyncio.run(main())
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
In notebooks with an existing event loop, use `await main()` instead of
|
|
184
|
+
`asyncio.run(main())`. Close a manually managed async client with
|
|
185
|
+
`await forge.close()`.
|
|
186
|
+
|
|
187
|
+
## Notebooks
|
|
188
|
+
|
|
189
|
+
The SDK can fetch data for pandas, DuckDB or your own analysis code. Those
|
|
190
|
+
libraries are optional and installed separately. Start with a read-only key and
|
|
191
|
+
a bounded sample:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import os
|
|
195
|
+
import pandas as pd
|
|
196
|
+
from cweb_forge import ForgeClient
|
|
197
|
+
|
|
198
|
+
# Configure this secret in the notebook environment; do not paste it into a cell.
|
|
199
|
+
with ForgeClient(
|
|
200
|
+
'https://my-app.forge.cweb.app/v1/',
|
|
201
|
+
token=os.environ['CWEB_FORGE_KEY'],
|
|
202
|
+
) as forge:
|
|
203
|
+
page = forge.collection('sales').list(limit=100)
|
|
204
|
+
frame = pd.DataFrame([document['data'] for document in page['items']])
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This reads a page of JSON documents; it does not mount a database or automatically
|
|
208
|
+
copy the whole collection into Studio's catalog. Do not save credentials or
|
|
209
|
+
sensitive results in notebook outputs that you intend to share.
|
|
210
|
+
|
|
211
|
+
## Errors and timeouts
|
|
212
|
+
|
|
213
|
+
Catch `ForgeError` for API or transport failures. It includes `status`, `code`
|
|
214
|
+
and `request_id`; network failures use status `0`.
|
|
215
|
+
|
|
216
|
+
| Status | Suggested handling |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `401` | Renew credentials or sign the application user in again. |
|
|
219
|
+
| `403` | Check collection rules and service-key permissions. |
|
|
220
|
+
| `409` | Resolve a concurrent edit or idempotency conflict. |
|
|
221
|
+
| `429` | Inspect the error code for rate or quota limits before retrying. |
|
|
222
|
+
| `503` | Retry later with bounded backoff when safe. |
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from cweb_forge import ForgeError
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
with ForgeClient(endpoint, token=os.environ['CWEB_FORGE_KEY']) as forge:
|
|
229
|
+
page = forge.collection('records').list()
|
|
230
|
+
except ForgeError as error:
|
|
231
|
+
print(f'Forge request failed: {error.status} {error.code}')
|
|
232
|
+
# Share error.request_id with support if present; never log credentials.
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
HTTPX timeouts default to 15 seconds and can be configured with `timeout=30`
|
|
236
|
+
on the client. These are transport timeouts, not a deadline for a complete
|
|
237
|
+
multi-page iteration. HTTPS is required except on localhost; redirects and
|
|
238
|
+
proxy settings inherited from environment variables are disabled.
|
|
239
|
+
|
|
240
|
+
## Security and support
|
|
241
|
+
|
|
242
|
+
Use narrowly scoped keys and rotate them if exposed. Keep keys, application-user
|
|
243
|
+
tokens, private data and authentication headers out of logs and Git commits.
|
|
244
|
+
For vulnerabilities, follow the
|
|
245
|
+
[security policy](https://github.com/pystrap/cweb-forge-python/blob/main/SECURITY.md).
|
|
246
|
+
|
|
247
|
+
Report SDK bugs through
|
|
248
|
+
[GitHub issues](https://github.com/pystrap/cweb-forge-python/issues), with the
|
|
249
|
+
SDK/Python versions and a minimal reproduction. For account or billing help,
|
|
250
|
+
contact cWeb Support at [support@cweb.app](mailto:support@cweb.app).
|
|
251
|
+
|
|
252
|
+
## Contributing
|
|
253
|
+
|
|
254
|
+
Focused fixes, regression tests and documentation improvements are welcome.
|
|
255
|
+
From a clone of this repository:
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
python -m venv .venv
|
|
259
|
+
. .venv/bin/activate
|
|
260
|
+
python -m pip install -e .
|
|
261
|
+
python -m unittest discover -s tests -v
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Tests use mocked HTTP responses and do not require a live Forge account.
|
|
265
|
+
Include tests for changed behavior and never submit customer data or credentials.
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
[MIT](https://github.com/pystrap/cweb-forge-python/blob/main/LICENSE).
|
|
270
|
+
Maintained by [PyStrap](https://pystrap.co.uk), the team behind cWeb.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/cweb_forge/__init__.py
|
|
5
|
+
src/cweb_forge/py.typed
|
|
6
|
+
src/cweb_forge.egg-info/PKG-INFO
|
|
7
|
+
src/cweb_forge.egg-info/SOURCES.txt
|
|
8
|
+
src/cweb_forge.egg-info/dependency_links.txt
|
|
9
|
+
src/cweb_forge.egg-info/requires.txt
|
|
10
|
+
src/cweb_forge.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1,>=0.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cweb_forge
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import httpx
|
|
3
|
+
from cweb_forge import ForgeClient, AsyncForgeClient, ForgeError
|
|
4
|
+
|
|
5
|
+
URL = 'https://my-app.forge.cweb.app/v1/'
|
|
6
|
+
|
|
7
|
+
class ClientTests(unittest.TestCase):
|
|
8
|
+
def test_headers_and_concurrency(self):
|
|
9
|
+
calls = []
|
|
10
|
+
def handler(request):
|
|
11
|
+
calls.append(request)
|
|
12
|
+
return httpx.Response(200, json={'id': 'example'})
|
|
13
|
+
with ForgeClient(URL, 'fg_secret', transport=httpx.MockTransport(handler)) as client:
|
|
14
|
+
client.collection('records').create({'a': 1}, idempotency_key='stable')
|
|
15
|
+
client.collection('records').update('00000000-0000-4000-8000-000000000001', {'a': 2}, version=1)
|
|
16
|
+
self.assertEqual(calls[0].headers['Idempotency-Key'], 'stable')
|
|
17
|
+
self.assertEqual(calls[1].headers['If-Match'], '"1"')
|
|
18
|
+
self.assertEqual(calls[0].headers['Authorization'], 'Bearer fg_secret')
|
|
19
|
+
|
|
20
|
+
def test_errors_and_redirects(self):
|
|
21
|
+
with ForgeClient(URL, transport=httpx.MockTransport(lambda r: httpx.Response(429, json={'error': {'message': 'Limit', 'code': 'quota_exceeded'}}))) as client:
|
|
22
|
+
with self.assertRaises(ForgeError) as result: client.collection('records').list()
|
|
23
|
+
self.assertEqual(result.exception.code, 'quota_exceeded')
|
|
24
|
+
with self.assertRaises(ValueError): ForgeClient('http://example.org')
|
|
25
|
+
|
|
26
|
+
class AsyncTests(unittest.IsolatedAsyncioTestCase):
|
|
27
|
+
async def test_async_iteration(self):
|
|
28
|
+
async with AsyncForgeClient(URL, transport=httpx.MockTransport(lambda r: httpx.Response(200, json={'items': [{'id': '1'}], 'next_cursor': None}))) as client:
|
|
29
|
+
self.assertEqual([x async for x in client.collection('records').iterate()], [{'id': '1'}])
|