webuplink 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.
- webuplink-0.1.0/.github/logo-dark.svg +17 -0
- webuplink-0.1.0/.github/logo-light.svg +17 -0
- webuplink-0.1.0/.github/workflows/ci.yml +21 -0
- webuplink-0.1.0/.github/workflows/publish.yml +32 -0
- webuplink-0.1.0/.gitignore +11 -0
- webuplink-0.1.0/CHANGELOG.md +19 -0
- webuplink-0.1.0/LICENSE +21 -0
- webuplink-0.1.0/PKG-INFO +106 -0
- webuplink-0.1.0/README.md +77 -0
- webuplink-0.1.0/pyproject.toml +62 -0
- webuplink-0.1.0/src/webuplink/__init__.py +64 -0
- webuplink-0.1.0/src/webuplink/_base_client.py +178 -0
- webuplink-0.1.0/src/webuplink/_client.py +348 -0
- webuplink-0.1.0/src/webuplink/_constants.py +11 -0
- webuplink-0.1.0/src/webuplink/_errors.py +93 -0
- webuplink-0.1.0/src/webuplink/_types.py +227 -0
- webuplink-0.1.0/src/webuplink/_version.py +3 -0
- webuplink-0.1.0/src/webuplink/py.typed +1 -0
- webuplink-0.1.0/tests/__init__.py +1 -0
- webuplink-0.1.0/tests/test_async_client.py +135 -0
- webuplink-0.1.0/tests/test_client.py +300 -0
- webuplink-0.1.0/tests/test_models.py +91 -0
- webuplink-0.1.0/tests/test_retry.py +169 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="160" viewBox="0 0 360 160">
|
|
2
|
+
<!-- WebUplink wordmark lockup — mark + text side by side. Dark mode. -->
|
|
3
|
+
<!-- PageMap 3×3 mark, vertically centered -->
|
|
4
|
+
<g transform="translate(20, 36)">
|
|
5
|
+
<rect x="0" y="0" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
6
|
+
<rect x="32" y="0" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
7
|
+
<rect x="64" y="0" width="24" height="24" rx="3.6" fill="#46999A"/>
|
|
8
|
+
<rect x="0" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
9
|
+
<rect x="32" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
10
|
+
<rect x="64" y="32" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
11
|
+
<rect x="0" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
12
|
+
<rect x="32" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
13
|
+
<rect x="64" y="64" width="24" height="24" rx="3.6" fill="#EDE7D9"/>
|
|
14
|
+
</g>
|
|
15
|
+
<!-- Wordmark text, vertically centered alongside mark -->
|
|
16
|
+
<text x="124" y="89" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="36" font-weight="600" letter-spacing="-0.02em" fill="#EDE7D9">WebUplink</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="160" viewBox="0 0 360 160">
|
|
2
|
+
<!-- WebUplink wordmark lockup — mark + text side by side. Light mode. -->
|
|
3
|
+
<!-- PageMap 3×3 mark, vertically centered -->
|
|
4
|
+
<g transform="translate(20, 36)">
|
|
5
|
+
<rect x="0" y="0" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
6
|
+
<rect x="32" y="0" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
7
|
+
<rect x="64" y="0" width="24" height="24" rx="3.6" fill="#0E6E78"/>
|
|
8
|
+
<rect x="0" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
9
|
+
<rect x="32" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
10
|
+
<rect x="64" y="32" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
11
|
+
<rect x="0" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
12
|
+
<rect x="32" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
13
|
+
<rect x="64" y="64" width="24" height="24" rx="3.6" fill="#1C1B18"/>
|
|
14
|
+
</g>
|
|
15
|
+
<!-- Wordmark text, vertically centered alongside mark -->
|
|
16
|
+
<text x="124" y="89" font-family="Inter, system-ui, -apple-system, sans-serif" font-size="36" font-weight="600" letter-spacing="-0.02em" fill="#1C1B18">WebUplink</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: Build & Test
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ['3.10', '3.12', '3.13']
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e '.[dev]'
|
|
21
|
+
- run: pytest
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
name: Publish
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
id-token: write
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: '3.12'
|
|
20
|
+
- run: pip install build
|
|
21
|
+
- name: Verify version matches release tag
|
|
22
|
+
run: |
|
|
23
|
+
PKG_VERSION=$(python -c "exec(open('src/webuplink/_version.py').read()); print(__version__)")
|
|
24
|
+
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
|
25
|
+
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
|
26
|
+
echo "::error::_version.py ($PKG_VERSION) does not match release tag ($TAG_VERSION)"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
- run: pip install -e '.[dev]'
|
|
30
|
+
- run: pytest
|
|
31
|
+
- run: python -m build
|
|
32
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-06-30)
|
|
4
|
+
|
|
5
|
+
Initial public release.
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- `browse()` — browse pages and execute tools with string shorthand, `BrowseParams`, or keyword arguments
|
|
10
|
+
- `close_session()` — explicit session cleanup
|
|
11
|
+
- `health()` — API health check with optional deep component checks
|
|
12
|
+
- `get_usage()` — usage and billing information for the authenticated tenant
|
|
13
|
+
- Sync (`WebUplink`) and async (`AsyncWebUplink`) clients with context manager support
|
|
14
|
+
- Idempotency-aware retry with configurable `max_retries`
|
|
15
|
+
- Typed error hierarchy: `WebUplinkError`, `AuthenticationError`, `RateLimitError`, `APIConnectionError`
|
|
16
|
+
- `Usage` metadata from `X-Usage-*` response headers
|
|
17
|
+
- Pydantic v2 models for all request/response types
|
|
18
|
+
- `py.typed` marker for PEP 561 type checker support
|
|
19
|
+
- Custom `httpx.Client` / `httpx.AsyncClient` injection for proxies, mTLS, and logging
|
webuplink-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WebUplink
|
|
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.
|
webuplink-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webuplink
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for WebUplink — the whole web, as function calls.
|
|
5
|
+
Project-URL: Homepage, https://webuplink.ai
|
|
6
|
+
Project-URL: Documentation, https://webuplink.ai/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/webuplink-dev/webuplink-python
|
|
8
|
+
Author-email: WebUplink <dev@webuplink.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agent,browser,sdk,web-automation,webuplink
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
21
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pyright>=1.1; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.11; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<picture>
|
|
31
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.svg">
|
|
32
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/logo-light.svg">
|
|
33
|
+
<img alt="WebUplink" src=".github/logo-light.svg" width="200">
|
|
34
|
+
</picture>
|
|
35
|
+
|
|
36
|
+
### The whole web, as function calls.
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/webuplink/)
|
|
39
|
+
[](https://github.com/webuplink-dev/webuplink-python/actions)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
|
|
42
|
+
Official Python SDK for [WebUplink](https://webuplink.ai).
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install webuplink
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quickstart
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from webuplink import WebUplink
|
|
54
|
+
|
|
55
|
+
client = WebUplink() # reads WEBUPLINK_API_KEY from env
|
|
56
|
+
|
|
57
|
+
# Browse a page — get back structured, callable tools
|
|
58
|
+
page = client.browse("https://example.com")
|
|
59
|
+
print(page.summary)
|
|
60
|
+
print(page.tools) # [Tool(name='...', description='...', params=[...]), ...]
|
|
61
|
+
|
|
62
|
+
# Execute a tool discovered on the page
|
|
63
|
+
tool = page.tools[0]
|
|
64
|
+
result = client.browse(
|
|
65
|
+
session_id=page.session_id,
|
|
66
|
+
tool=tool.name,
|
|
67
|
+
params={tool.params[0].name: "some value"},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Clean up
|
|
71
|
+
client.close_session(page.session_id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Async
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from webuplink import AsyncWebUplink
|
|
78
|
+
|
|
79
|
+
async with AsyncWebUplink() as client:
|
|
80
|
+
page = await client.browse("https://example.com")
|
|
81
|
+
print(page.tools)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## What you can build
|
|
85
|
+
|
|
86
|
+
- **AI agents that act on the web** — browse any site, get back typed tool definitions, execute actions
|
|
87
|
+
- **No selectors, no scraping** — WebUplink understands pages and generates callable tools automatically
|
|
88
|
+
- **Multi-step workflows** — sessions persist across navigations, so your agent can search → filter → select → checkout
|
|
89
|
+
- **Any website, zero configuration** — works on sites you've never seen before
|
|
90
|
+
|
|
91
|
+
## Documentation
|
|
92
|
+
|
|
93
|
+
Full reference at **[webuplink.ai/docs](https://webuplink.ai/docs)**.
|
|
94
|
+
|
|
95
|
+
## Contributing
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
git clone https://github.com/webuplink-dev/webuplink-python.git
|
|
99
|
+
cd webuplink-python
|
|
100
|
+
pip install -e ".[dev]"
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/logo-dark.svg">
|
|
3
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/logo-light.svg">
|
|
4
|
+
<img alt="WebUplink" src=".github/logo-light.svg" width="200">
|
|
5
|
+
</picture>
|
|
6
|
+
|
|
7
|
+
### The whole web, as function calls.
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/webuplink/)
|
|
10
|
+
[](https://github.com/webuplink-dev/webuplink-python/actions)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
|
|
13
|
+
Official Python SDK for [WebUplink](https://webuplink.ai).
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install webuplink
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from webuplink import WebUplink
|
|
25
|
+
|
|
26
|
+
client = WebUplink() # reads WEBUPLINK_API_KEY from env
|
|
27
|
+
|
|
28
|
+
# Browse a page — get back structured, callable tools
|
|
29
|
+
page = client.browse("https://example.com")
|
|
30
|
+
print(page.summary)
|
|
31
|
+
print(page.tools) # [Tool(name='...', description='...', params=[...]), ...]
|
|
32
|
+
|
|
33
|
+
# Execute a tool discovered on the page
|
|
34
|
+
tool = page.tools[0]
|
|
35
|
+
result = client.browse(
|
|
36
|
+
session_id=page.session_id,
|
|
37
|
+
tool=tool.name,
|
|
38
|
+
params={tool.params[0].name: "some value"},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Clean up
|
|
42
|
+
client.close_session(page.session_id)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Async
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from webuplink import AsyncWebUplink
|
|
49
|
+
|
|
50
|
+
async with AsyncWebUplink() as client:
|
|
51
|
+
page = await client.browse("https://example.com")
|
|
52
|
+
print(page.tools)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What you can build
|
|
56
|
+
|
|
57
|
+
- **AI agents that act on the web** — browse any site, get back typed tool definitions, execute actions
|
|
58
|
+
- **No selectors, no scraping** — WebUplink understands pages and generates callable tools automatically
|
|
59
|
+
- **Multi-step workflows** — sessions persist across navigations, so your agent can search → filter → select → checkout
|
|
60
|
+
- **Any website, zero configuration** — works on sites you've never seen before
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
Full reference at **[webuplink.ai/docs](https://webuplink.ai/docs)**.
|
|
65
|
+
|
|
66
|
+
## Contributing
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/webuplink-dev/webuplink-python.git
|
|
70
|
+
cd webuplink-python
|
|
71
|
+
pip install -e ".[dev]"
|
|
72
|
+
pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webuplink"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for WebUplink — the whole web, as function calls."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27,<1.0",
|
|
14
|
+
"pydantic>=2.0,<3.0",
|
|
15
|
+
]
|
|
16
|
+
keywords = ["webuplink", "ai-agent", "browser", "web-automation", "sdk"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
authors = [
|
|
27
|
+
{ name = "WebUplink", email = "dev@webuplink.ai" },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-asyncio>=1.0",
|
|
34
|
+
"respx>=0.22",
|
|
35
|
+
"ruff>=0.11",
|
|
36
|
+
"pyright>=1.1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://webuplink.ai"
|
|
41
|
+
Documentation = "https://webuplink.ai/docs"
|
|
42
|
+
Repository = "https://github.com/webuplink-dev/webuplink-python"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.version]
|
|
45
|
+
path = "src/webuplink/_version.py"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["src/webuplink"]
|
|
49
|
+
|
|
50
|
+
[tool.pyright]
|
|
51
|
+
typeCheckingMode = "strict"
|
|
52
|
+
pythonVersion = "3.10"
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
target-version = "py310"
|
|
56
|
+
line-length = 120
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""webuplink — Official Python SDK for WebUplink.
|
|
2
|
+
|
|
3
|
+
Browse and interact with any website via structured, typed tool definitions.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from webuplink import WebUplink
|
|
8
|
+
|
|
9
|
+
client = WebUplink(api_key="wup_your_api_key")
|
|
10
|
+
page = client.browse("https://example.com")
|
|
11
|
+
print(page.tools)
|
|
12
|
+
|
|
13
|
+
For async usage::
|
|
14
|
+
|
|
15
|
+
from webuplink import AsyncWebUplink
|
|
16
|
+
|
|
17
|
+
async with AsyncWebUplink() as client:
|
|
18
|
+
page = await client.browse("https://example.com")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from webuplink._client import AsyncWebUplink, WebUplink
|
|
22
|
+
from webuplink._errors import (
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
WebUplinkError,
|
|
27
|
+
)
|
|
28
|
+
from webuplink._types import (
|
|
29
|
+
BrowseParams,
|
|
30
|
+
BrowseResult,
|
|
31
|
+
ErrorCode,
|
|
32
|
+
HealthResult,
|
|
33
|
+
Tool,
|
|
34
|
+
ToolExecution,
|
|
35
|
+
ToolParam,
|
|
36
|
+
ToolResult,
|
|
37
|
+
Usage,
|
|
38
|
+
UsageResponse,
|
|
39
|
+
)
|
|
40
|
+
from webuplink._version import __version__
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Clients
|
|
44
|
+
"WebUplink",
|
|
45
|
+
"AsyncWebUplink",
|
|
46
|
+
# Errors
|
|
47
|
+
"WebUplinkError",
|
|
48
|
+
"AuthenticationError",
|
|
49
|
+
"RateLimitError",
|
|
50
|
+
"APIConnectionError",
|
|
51
|
+
# Types
|
|
52
|
+
"BrowseParams",
|
|
53
|
+
"BrowseResult",
|
|
54
|
+
"Tool",
|
|
55
|
+
"ToolParam",
|
|
56
|
+
"ToolResult",
|
|
57
|
+
"ToolExecution",
|
|
58
|
+
"Usage",
|
|
59
|
+
"UsageResponse",
|
|
60
|
+
"HealthResult",
|
|
61
|
+
"ErrorCode",
|
|
62
|
+
# Version
|
|
63
|
+
"__version__",
|
|
64
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared HTTP logic for both sync and async clients.
|
|
3
|
+
|
|
4
|
+
This module contains all request construction, response parsing, error
|
|
5
|
+
mapping, usage header extraction, and retry logic. The ``WebUplink``
|
|
6
|
+
and ``AsyncWebUplink`` classes delegate to these functions so the
|
|
7
|
+
logic exists in exactly one place.
|
|
8
|
+
|
|
9
|
+
Retry semantics match the TypeScript SDK exactly:
|
|
10
|
+
- API errors with ``retry_after``: sleep for ``retry_after`` seconds
|
|
11
|
+
- Connection errors: linear backoff (1s × attempt)
|
|
12
|
+
- Never retry 429 (amplifies load)
|
|
13
|
+
- Never retry tool execution (non-idempotent)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from webuplink._constants import USER_AGENT
|
|
24
|
+
from webuplink._errors import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
WebUplinkError,
|
|
28
|
+
)
|
|
29
|
+
from webuplink._types import BrowseResult, HealthResult, Usage, UsageResponse
|
|
30
|
+
|
|
31
|
+
DEFAULT_RETRY_DELAY_S = 5.0
|
|
32
|
+
"""Seconds to wait before retrying when the server omits ``retry_after``."""
|
|
33
|
+
|
|
34
|
+
# ── Header Construction ──────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_headers(api_key: str) -> dict[str, str]:
|
|
38
|
+
"""Build the default request headers."""
|
|
39
|
+
return {
|
|
40
|
+
"Authorization": f"Bearer {api_key}",
|
|
41
|
+
"User-Agent": USER_AGENT,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ── Response Parsing ─────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_browse_response(response: httpx.Response) -> BrowseResult:
|
|
49
|
+
"""Deserialize a successful browse response + usage headers."""
|
|
50
|
+
data = response.json()
|
|
51
|
+
usage = _parse_usage_headers(response)
|
|
52
|
+
return BrowseResult(**data, usage=usage)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_health_response(response: httpx.Response) -> HealthResult:
|
|
56
|
+
"""Deserialize a successful health response."""
|
|
57
|
+
return HealthResult(**response.json())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_usage_response(response: httpx.Response) -> UsageResponse:
|
|
61
|
+
"""Deserialize a successful usage response."""
|
|
62
|
+
return UsageResponse(**response.json())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_usage_headers(response: httpx.Response) -> Usage | None:
|
|
66
|
+
"""Extract ``X-Usage-*`` headers into a ``Usage`` model, or ``None``."""
|
|
67
|
+
count = response.headers.get("x-usage-action-count")
|
|
68
|
+
limit = response.headers.get("x-usage-action-limit")
|
|
69
|
+
period = response.headers.get("x-usage-period-start")
|
|
70
|
+
|
|
71
|
+
if not count or not limit or not period:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
return Usage(
|
|
75
|
+
action_count=int(count),
|
|
76
|
+
action_limit=int(limit),
|
|
77
|
+
period_start=period,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Error Parsing ────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_error_response(response: httpx.Response) -> WebUplinkError:
|
|
85
|
+
"""Map a non-2xx ``httpx.Response`` to a ``WebUplinkError`` (or subclass)."""
|
|
86
|
+
request_id = response.headers.get("x-request-id", "unknown")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
data: dict[str, Any] = response.json()
|
|
90
|
+
except Exception:
|
|
91
|
+
# Non-JSON body (e.g. 502 from load balancer)
|
|
92
|
+
return _create_error_from_status(
|
|
93
|
+
response.status_code,
|
|
94
|
+
response.reason_phrase or f"HTTP {response.status_code}",
|
|
95
|
+
code="INTERNAL_ERROR",
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
request_id=request_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
code = data.get("error", "INTERNAL_ERROR")
|
|
101
|
+
message = data.get("message", response.reason_phrase or f"HTTP {response.status_code}")
|
|
102
|
+
retry_after_raw = data.get("retry_after")
|
|
103
|
+
details = data.get("details")
|
|
104
|
+
|
|
105
|
+
# Retryability: the server sets retry_after for transient issues.
|
|
106
|
+
# 429 (RATE_LIMITED / QUOTA_EXCEEDED) carries retry_after but
|
|
107
|
+
# auto-retrying a throttle just amplifies load — never retry them.
|
|
108
|
+
retryable = (
|
|
109
|
+
response.status_code != 429
|
|
110
|
+
and retry_after_raw is not None
|
|
111
|
+
and float(retry_after_raw) > 0
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return _create_error_from_status(
|
|
115
|
+
response.status_code,
|
|
116
|
+
message,
|
|
117
|
+
code=code,
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
request_id=request_id,
|
|
120
|
+
retryable=retryable,
|
|
121
|
+
retry_after=float(retry_after_raw) if retry_after_raw is not None else None,
|
|
122
|
+
details=details,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _create_error_from_status(
|
|
127
|
+
status: int,
|
|
128
|
+
message: str,
|
|
129
|
+
**kwargs: Any,
|
|
130
|
+
) -> WebUplinkError:
|
|
131
|
+
"""Map HTTP status to the most specific error subclass."""
|
|
132
|
+
if status == 401:
|
|
133
|
+
return AuthenticationError(message, **kwargs)
|
|
134
|
+
if status == 429:
|
|
135
|
+
return RateLimitError(message, **kwargs)
|
|
136
|
+
return WebUplinkError(message, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Retry Logic ──────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def should_retry(
|
|
143
|
+
error: WebUplinkError,
|
|
144
|
+
*,
|
|
145
|
+
attempt: int,
|
|
146
|
+
max_retries: int,
|
|
147
|
+
has_tools: bool,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""Decide whether a failed request should be retried.
|
|
150
|
+
|
|
151
|
+
Rules (matching the TypeScript SDK exactly):
|
|
152
|
+
1. Must have retries remaining (``attempt < 1 + max_retries``)
|
|
153
|
+
2. The error must be marked retryable by ``parse_error_response``
|
|
154
|
+
3. Tool execution is never retried (non-idempotent)
|
|
155
|
+
"""
|
|
156
|
+
if attempt >= 1 + max_retries:
|
|
157
|
+
return False
|
|
158
|
+
if has_tools:
|
|
159
|
+
return False
|
|
160
|
+
return error.retryable
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_retry_delay(error: WebUplinkError) -> float:
|
|
164
|
+
"""Return the delay (in seconds) before retrying an API error."""
|
|
165
|
+
return error.retry_after if error.retry_after is not None else DEFAULT_RETRY_DELAY_S
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_connection_retry_delay(attempt: int) -> float:
|
|
169
|
+
"""Return the delay (in seconds) before retrying a connection error.
|
|
170
|
+
|
|
171
|
+
Uses linear backoff: 1s × attempt (matching the TS SDK).
|
|
172
|
+
"""
|
|
173
|
+
return 1.0 * attempt
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sleep_sync(seconds: float) -> None:
|
|
177
|
+
"""Blocking sleep for sync retry loops."""
|
|
178
|
+
time.sleep(seconds)
|