pictomancer 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.
- pictomancer-0.1.0/.github/workflows/publish.yml +36 -0
- pictomancer-0.1.0/PKG-INFO +186 -0
- pictomancer-0.1.0/README.md +157 -0
- pictomancer-0.1.0/pyproject.toml +46 -0
- pictomancer-0.1.0/src/pictomancer/__init__.py +24 -0
- pictomancer-0.1.0/src/pictomancer/client.py +161 -0
- pictomancer-0.1.0/src/pictomancer/langchain.py +123 -0
- pictomancer-0.1.0/src/pictomancer/models.py +58 -0
- pictomancer-0.1.0/src/pictomancer/py.typed +0 -0
- pictomancer-0.1.0/tests/__init__.py +0 -0
- pictomancer-0.1.0/tests/test_client.py +337 -0
- pictomancer-0.1.0/tests/test_langchain.py +126 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
- run: pip install -e ".[test,langchain]"
|
|
23
|
+
- run: pytest -v
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: test
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
- uses: actions/setup-python@v5
|
|
32
|
+
with:
|
|
33
|
+
python-version: "3.12"
|
|
34
|
+
- run: pip install build
|
|
35
|
+
- run: python -m build
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pictomancer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Pictomancer.ai — image processing for AI agents
|
|
5
|
+
Project-URL: Homepage, https://pictomancer.ai
|
|
6
|
+
Project-URL: Documentation, https://api.pictomancer.ai/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/pictomancer/python-sdk
|
|
8
|
+
Author-email: "Pictomancer.ai" <hello@pictomancer.ai>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-agents,image-processing,mcp,pictomancer,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: langchain-core>=0.3; extra == 'all'
|
|
22
|
+
Provides-Extra: langchain
|
|
23
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest-asyncio>=1.0; extra == 'test'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
27
|
+
Requires-Dist: respx>=0.22; extra == 'test'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="https://pictomancer.ai/icon.svg" width="80" height="80" alt="Pictomancer.ai">
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<h1 align="center">pictomancer</h1>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<strong>Image processing for AI agents.</strong><br>
|
|
38
|
+
Resize, compress, convert, crop & pipeline — sub-50 ms, pay-per-request.
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<a href="https://pypi.org/project/pictomancer/"><img src="https://img.shields.io/pypi/v/pictomancer?color=a855f7&style=flat-square" alt="PyPI"></a>
|
|
43
|
+
<a href="https://pypi.org/project/pictomancer/"><img src="https://img.shields.io/pypi/pyversions/pictomancer?style=flat-square" alt="Python"></a>
|
|
44
|
+
<a href="https://api.pictomancer.ai/docs"><img src="https://img.shields.io/badge/docs-api-a855f7?style=flat-square" alt="API Docs"></a>
|
|
45
|
+
<a href="https://x402.org"><img src="https://img.shields.io/badge/payments-x402_USDC-3b82f6?style=flat-square" alt="x402"></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
Thin async Python client for [Pictomancer.ai](https://pictomancer.ai) — the
|
|
51
|
+
image processing API built for autonomous agents. No API keys, no accounts.
|
|
52
|
+
First 50 requests free, then micropayments via
|
|
53
|
+
[x402](https://x402.org) (USDC on Base L2).
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install pictomancer
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
With [LangChain](https://python.langchain.com/) support:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install pictomancer[langchain]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick start
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import asyncio
|
|
71
|
+
from pictomancer import Pictomancer
|
|
72
|
+
|
|
73
|
+
async def main():
|
|
74
|
+
async with Pictomancer() as p:
|
|
75
|
+
# Free — get image metadata
|
|
76
|
+
info = await p.analyze("https://example.com/photo.jpg")
|
|
77
|
+
print(f"{info.size_bytes:,} bytes")
|
|
78
|
+
|
|
79
|
+
# Resize to 50%
|
|
80
|
+
data = await p.resize("https://example.com/photo.jpg", scale=0.5)
|
|
81
|
+
|
|
82
|
+
# Compress to q=70 WebP
|
|
83
|
+
data = await p.compress("https://example.com/photo.jpg", q=70, format="webp")
|
|
84
|
+
|
|
85
|
+
# Convert PNG → WebP
|
|
86
|
+
data = await p.convert("https://example.com/photo.png", format="webp")
|
|
87
|
+
|
|
88
|
+
# Crop a region
|
|
89
|
+
data = await p.crop("https://example.com/photo.jpg",
|
|
90
|
+
x=0, y=0, width=800, height=600)
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Pipeline
|
|
96
|
+
|
|
97
|
+
Chain up to 10 operations in a single request:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from pictomancer import Pictomancer, PipelineOp
|
|
101
|
+
|
|
102
|
+
async with Pictomancer() as p:
|
|
103
|
+
data = await p.pipeline("https://example.com/photo.jpg", [
|
|
104
|
+
PipelineOp(type="resize", params={"scale": "0.5"}),
|
|
105
|
+
PipelineOp(type="compress", params={"q": "70"}),
|
|
106
|
+
PipelineOp(type="convert", params={"format": "webp"}),
|
|
107
|
+
])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Supported formats
|
|
111
|
+
|
|
112
|
+
Query at runtime with `await p.info()`, or see the full list:
|
|
113
|
+
|
|
114
|
+
| Format | Extensions | Key options |
|
|
115
|
+
|--------|-----------|-------------|
|
|
116
|
+
| JPEG | `.jpg` | `q` (1–100), `strip`, `interlace` |
|
|
117
|
+
| PNG | `.png` | `compression` (0–9), `palette`, `strip` |
|
|
118
|
+
| WebP | `.webp` | `q` (1–100), `lossless`, `strip` |
|
|
119
|
+
| TIFF | `.tiff` | `compression` (deflate, lzw, zstd…), `q` |
|
|
120
|
+
| GIF | `.gif` | `dither` (0–1), `effort`, `bitdepth` |
|
|
121
|
+
|
|
122
|
+
## LangChain
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pictomancer.langchain import pictomancer_tools
|
|
126
|
+
|
|
127
|
+
tools = pictomancer_tools()
|
|
128
|
+
|
|
129
|
+
# 5 tools: analyze, resize, compress, convert, crop
|
|
130
|
+
# Pass to any LangChain agent, LangGraph tool node, or chain
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Each tool includes pricing in its description so cost-aware agents can make
|
|
134
|
+
informed decisions.
|
|
135
|
+
|
|
136
|
+
## x402 payments
|
|
137
|
+
|
|
138
|
+
The first **50 requests per agent** are free. After that, agents pay
|
|
139
|
+
automatically via [x402](https://x402.org) — USDC on Base L2.
|
|
140
|
+
|
|
141
|
+
Pass a wallet address for identity tracking:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
async with Pictomancer(wallet="0xYourAgentWallet") as p:
|
|
145
|
+
data = await p.resize("https://example.com/photo.jpg", scale=0.5)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Pricing
|
|
149
|
+
|
|
150
|
+
| Operation | Base cost | What it does |
|
|
151
|
+
|-----------|-----------|--------------|
|
|
152
|
+
| `analyze` | Free | Image metadata (size in bytes) |
|
|
153
|
+
| `resize` | $0.001 | Scale by factor |
|
|
154
|
+
| `compress` | $0.001 | Re-encode with quality control |
|
|
155
|
+
| `convert` | $0.002 | Format conversion |
|
|
156
|
+
| `crop` | $0.001 | Extract rectangular region |
|
|
157
|
+
| `pipeline` | Per-op | Chain operations, volume discount at 3+ |
|
|
158
|
+
|
|
159
|
+
**Size multipliers:** <1 MB → 1×, 1–5 MB → 1.5×, 5–10 MB → 2×, 10–50 MB → 3×.
|
|
160
|
+
|
|
161
|
+
## MCP
|
|
162
|
+
|
|
163
|
+
You can also connect directly to the hosted MCP server — no SDK needed:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"pictomancer": {
|
|
169
|
+
"url": "https://api.pictomancer.ai/mcp"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible client.
|
|
176
|
+
|
|
177
|
+
## Links
|
|
178
|
+
|
|
179
|
+
- [API Documentation](https://api.pictomancer.ai/docs)
|
|
180
|
+
- [OpenAPI Spec](https://api.pictomancer.ai/openapi.json)
|
|
181
|
+
- [Website](https://pictomancer.ai)
|
|
182
|
+
- [MCP Registry](https://registry.modelcontextprotocol.io) — `ai.pictomancer/image-processing`
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://pictomancer.ai/icon.svg" width="80" height="80" alt="Pictomancer.ai">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">pictomancer</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Image processing for AI agents.</strong><br>
|
|
9
|
+
Resize, compress, convert, crop & pipeline — sub-50 ms, pay-per-request.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://pypi.org/project/pictomancer/"><img src="https://img.shields.io/pypi/v/pictomancer?color=a855f7&style=flat-square" alt="PyPI"></a>
|
|
14
|
+
<a href="https://pypi.org/project/pictomancer/"><img src="https://img.shields.io/pypi/pyversions/pictomancer?style=flat-square" alt="Python"></a>
|
|
15
|
+
<a href="https://api.pictomancer.ai/docs"><img src="https://img.shields.io/badge/docs-api-a855f7?style=flat-square" alt="API Docs"></a>
|
|
16
|
+
<a href="https://x402.org"><img src="https://img.shields.io/badge/payments-x402_USDC-3b82f6?style=flat-square" alt="x402"></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
Thin async Python client for [Pictomancer.ai](https://pictomancer.ai) — the
|
|
22
|
+
image processing API built for autonomous agents. No API keys, no accounts.
|
|
23
|
+
First 50 requests free, then micropayments via
|
|
24
|
+
[x402](https://x402.org) (USDC on Base L2).
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install pictomancer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With [LangChain](https://python.langchain.com/) support:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pictomancer[langchain]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
from pictomancer import Pictomancer
|
|
43
|
+
|
|
44
|
+
async def main():
|
|
45
|
+
async with Pictomancer() as p:
|
|
46
|
+
# Free — get image metadata
|
|
47
|
+
info = await p.analyze("https://example.com/photo.jpg")
|
|
48
|
+
print(f"{info.size_bytes:,} bytes")
|
|
49
|
+
|
|
50
|
+
# Resize to 50%
|
|
51
|
+
data = await p.resize("https://example.com/photo.jpg", scale=0.5)
|
|
52
|
+
|
|
53
|
+
# Compress to q=70 WebP
|
|
54
|
+
data = await p.compress("https://example.com/photo.jpg", q=70, format="webp")
|
|
55
|
+
|
|
56
|
+
# Convert PNG → WebP
|
|
57
|
+
data = await p.convert("https://example.com/photo.png", format="webp")
|
|
58
|
+
|
|
59
|
+
# Crop a region
|
|
60
|
+
data = await p.crop("https://example.com/photo.jpg",
|
|
61
|
+
x=0, y=0, width=800, height=600)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Pipeline
|
|
67
|
+
|
|
68
|
+
Chain up to 10 operations in a single request:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pictomancer import Pictomancer, PipelineOp
|
|
72
|
+
|
|
73
|
+
async with Pictomancer() as p:
|
|
74
|
+
data = await p.pipeline("https://example.com/photo.jpg", [
|
|
75
|
+
PipelineOp(type="resize", params={"scale": "0.5"}),
|
|
76
|
+
PipelineOp(type="compress", params={"q": "70"}),
|
|
77
|
+
PipelineOp(type="convert", params={"format": "webp"}),
|
|
78
|
+
])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Supported formats
|
|
82
|
+
|
|
83
|
+
Query at runtime with `await p.info()`, or see the full list:
|
|
84
|
+
|
|
85
|
+
| Format | Extensions | Key options |
|
|
86
|
+
|--------|-----------|-------------|
|
|
87
|
+
| JPEG | `.jpg` | `q` (1–100), `strip`, `interlace` |
|
|
88
|
+
| PNG | `.png` | `compression` (0–9), `palette`, `strip` |
|
|
89
|
+
| WebP | `.webp` | `q` (1–100), `lossless`, `strip` |
|
|
90
|
+
| TIFF | `.tiff` | `compression` (deflate, lzw, zstd…), `q` |
|
|
91
|
+
| GIF | `.gif` | `dither` (0–1), `effort`, `bitdepth` |
|
|
92
|
+
|
|
93
|
+
## LangChain
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from pictomancer.langchain import pictomancer_tools
|
|
97
|
+
|
|
98
|
+
tools = pictomancer_tools()
|
|
99
|
+
|
|
100
|
+
# 5 tools: analyze, resize, compress, convert, crop
|
|
101
|
+
# Pass to any LangChain agent, LangGraph tool node, or chain
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each tool includes pricing in its description so cost-aware agents can make
|
|
105
|
+
informed decisions.
|
|
106
|
+
|
|
107
|
+
## x402 payments
|
|
108
|
+
|
|
109
|
+
The first **50 requests per agent** are free. After that, agents pay
|
|
110
|
+
automatically via [x402](https://x402.org) — USDC on Base L2.
|
|
111
|
+
|
|
112
|
+
Pass a wallet address for identity tracking:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
async with Pictomancer(wallet="0xYourAgentWallet") as p:
|
|
116
|
+
data = await p.resize("https://example.com/photo.jpg", scale=0.5)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Pricing
|
|
120
|
+
|
|
121
|
+
| Operation | Base cost | What it does |
|
|
122
|
+
|-----------|-----------|--------------|
|
|
123
|
+
| `analyze` | Free | Image metadata (size in bytes) |
|
|
124
|
+
| `resize` | $0.001 | Scale by factor |
|
|
125
|
+
| `compress` | $0.001 | Re-encode with quality control |
|
|
126
|
+
| `convert` | $0.002 | Format conversion |
|
|
127
|
+
| `crop` | $0.001 | Extract rectangular region |
|
|
128
|
+
| `pipeline` | Per-op | Chain operations, volume discount at 3+ |
|
|
129
|
+
|
|
130
|
+
**Size multipliers:** <1 MB → 1×, 1–5 MB → 1.5×, 5–10 MB → 2×, 10–50 MB → 3×.
|
|
131
|
+
|
|
132
|
+
## MCP
|
|
133
|
+
|
|
134
|
+
You can also connect directly to the hosted MCP server — no SDK needed:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"mcpServers": {
|
|
139
|
+
"pictomancer": {
|
|
140
|
+
"url": "https://api.pictomancer.ai/mcp"
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible client.
|
|
147
|
+
|
|
148
|
+
## Links
|
|
149
|
+
|
|
150
|
+
- [API Documentation](https://api.pictomancer.ai/docs)
|
|
151
|
+
- [OpenAPI Spec](https://api.pictomancer.ai/openapi.json)
|
|
152
|
+
- [Website](https://pictomancer.ai)
|
|
153
|
+
- [MCP Registry](https://registry.modelcontextprotocol.io) — `ai.pictomancer/image-processing`
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pictomancer"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Pictomancer.ai — image processing for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Pictomancer.ai", email = "hello@pictomancer.ai" }]
|
|
13
|
+
keywords = ["image-processing", "mcp", "ai-agents", "x402", "pictomancer"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Multimedia :: Graphics",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"httpx>=0.27",
|
|
24
|
+
"pydantic>=2.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
langchain = ["langchain-core>=0.3"]
|
|
29
|
+
all = ["pictomancer[langchain]"]
|
|
30
|
+
test = [
|
|
31
|
+
"pytest>=8.0",
|
|
32
|
+
"pytest-asyncio>=1.0",
|
|
33
|
+
"respx>=0.22",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://pictomancer.ai"
|
|
38
|
+
Documentation = "https://api.pictomancer.ai/docs"
|
|
39
|
+
Repository = "https://github.com/pictomancer/python-sdk"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/pictomancer"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Pictomancer.ai Python SDK — image processing for AI agents."""
|
|
2
|
+
|
|
3
|
+
from pictomancer.client import Pictomancer, PictomancerError
|
|
4
|
+
from pictomancer.models import (
|
|
5
|
+
AnalyzeResult,
|
|
6
|
+
CompressParams,
|
|
7
|
+
ConvertParams,
|
|
8
|
+
CropParams,
|
|
9
|
+
FormatSpec,
|
|
10
|
+
PipelineOp,
|
|
11
|
+
ResizeParams,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Pictomancer",
|
|
16
|
+
"PictomancerError",
|
|
17
|
+
"AnalyzeResult",
|
|
18
|
+
"CompressParams",
|
|
19
|
+
"ConvertParams",
|
|
20
|
+
"CropParams",
|
|
21
|
+
"FormatSpec",
|
|
22
|
+
"PipelineOp",
|
|
23
|
+
"ResizeParams",
|
|
24
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Async client for the Pictomancer.ai image processing API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from pictomancer.models import (
|
|
10
|
+
AnalyzeResult,
|
|
11
|
+
CompressParams,
|
|
12
|
+
ConvertParams,
|
|
13
|
+
CropParams,
|
|
14
|
+
FormatSpec,
|
|
15
|
+
PipelineOp,
|
|
16
|
+
ResizeParams,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_DEFAULT_BASE = "https://api.pictomancer.ai"
|
|
20
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PictomancerError(Exception):
|
|
24
|
+
"""Raised when the API returns a non-2xx status."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
27
|
+
self.status = status
|
|
28
|
+
self.detail = detail
|
|
29
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Pictomancer:
|
|
33
|
+
"""Thin async client for api.pictomancer.ai.
|
|
34
|
+
|
|
35
|
+
Usage::
|
|
36
|
+
|
|
37
|
+
async with Pictomancer() as p:
|
|
38
|
+
info = await p.analyze("https://example.com/photo.jpg")
|
|
39
|
+
data = await p.resize("https://example.com/photo.jpg", scale=0.5)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str = _DEFAULT_BASE,
|
|
46
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
47
|
+
wallet: str | None = None,
|
|
48
|
+
http_client: httpx.AsyncClient | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
headers: dict[str, str] = {}
|
|
51
|
+
if wallet:
|
|
52
|
+
headers["X-Agent-Wallet"] = wallet
|
|
53
|
+
self._owns_client = http_client is None
|
|
54
|
+
self._client = http_client or httpx.AsyncClient(
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
headers=headers,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self) -> Pictomancer:
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
64
|
+
await self.close()
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
if self._owns_client:
|
|
68
|
+
await self._client.aclose()
|
|
69
|
+
|
|
70
|
+
def _raise(self, resp: httpx.Response) -> None:
|
|
71
|
+
if resp.is_success:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
detail = resp.json().get("detail", resp.text)
|
|
75
|
+
except Exception:
|
|
76
|
+
detail = resp.text
|
|
77
|
+
raise PictomancerError(resp.status_code, str(detail))
|
|
78
|
+
|
|
79
|
+
async def info(self) -> list[FormatSpec]:
|
|
80
|
+
"""Return supported output formats and their options."""
|
|
81
|
+
resp = await self._client.get("/v1/info")
|
|
82
|
+
self._raise(resp)
|
|
83
|
+
return [FormatSpec.model_validate(f) for f in resp.json()["formats"]]
|
|
84
|
+
|
|
85
|
+
async def analyze(self, source: str) -> AnalyzeResult:
|
|
86
|
+
resp = await self._client.post("/v1/analyze", json={"source": source})
|
|
87
|
+
self._raise(resp)
|
|
88
|
+
return AnalyzeResult.model_validate(resp.json())
|
|
89
|
+
|
|
90
|
+
async def resize(
|
|
91
|
+
self,
|
|
92
|
+
source: str,
|
|
93
|
+
*,
|
|
94
|
+
scale: float | None = None,
|
|
95
|
+
scale_x: float | None = None,
|
|
96
|
+
scale_y: float | None = None,
|
|
97
|
+
format: str | None = None,
|
|
98
|
+
) -> bytes:
|
|
99
|
+
params = ResizeParams(scale=scale, scale_x=scale_x, scale_y=scale_y, format=format)
|
|
100
|
+
body = {"source": source, **params.model_dump(exclude_none=True)}
|
|
101
|
+
resp = await self._client.post("/v1/resize", json=body)
|
|
102
|
+
self._raise(resp)
|
|
103
|
+
return resp.content
|
|
104
|
+
|
|
105
|
+
async def compress(
|
|
106
|
+
self,
|
|
107
|
+
source: str,
|
|
108
|
+
*,
|
|
109
|
+
q: int | None = None,
|
|
110
|
+
format: str | None = None,
|
|
111
|
+
strip: bool | None = None,
|
|
112
|
+
) -> bytes:
|
|
113
|
+
params = CompressParams(q=q, format=format, strip=strip)
|
|
114
|
+
body = {"source": source, **params.model_dump(exclude_none=True)}
|
|
115
|
+
resp = await self._client.post("/v1/compress", json=body)
|
|
116
|
+
self._raise(resp)
|
|
117
|
+
return resp.content
|
|
118
|
+
|
|
119
|
+
async def convert(
|
|
120
|
+
self,
|
|
121
|
+
source: str,
|
|
122
|
+
*,
|
|
123
|
+
format: str,
|
|
124
|
+
q: int | None = None,
|
|
125
|
+
strip: bool | None = None,
|
|
126
|
+
lossless: bool | None = None,
|
|
127
|
+
) -> bytes:
|
|
128
|
+
params = ConvertParams(format=format, q=q, strip=strip, lossless=lossless)
|
|
129
|
+
body = {"source": source, **params.model_dump(exclude_none=True)}
|
|
130
|
+
resp = await self._client.post("/v1/convert", json=body)
|
|
131
|
+
self._raise(resp)
|
|
132
|
+
return resp.content
|
|
133
|
+
|
|
134
|
+
async def crop(
|
|
135
|
+
self,
|
|
136
|
+
source: str,
|
|
137
|
+
*,
|
|
138
|
+
x: int,
|
|
139
|
+
y: int,
|
|
140
|
+
width: int,
|
|
141
|
+
height: int,
|
|
142
|
+
format: str | None = None,
|
|
143
|
+
) -> bytes:
|
|
144
|
+
params = CropParams(x=x, y=y, width=width, height=height, format=format)
|
|
145
|
+
body = {"source": source, **params.model_dump(exclude_none=True)}
|
|
146
|
+
resp = await self._client.post("/v1/crop", json=body)
|
|
147
|
+
self._raise(resp)
|
|
148
|
+
return resp.content
|
|
149
|
+
|
|
150
|
+
async def pipeline(
|
|
151
|
+
self,
|
|
152
|
+
source: str,
|
|
153
|
+
operations: Sequence[PipelineOp],
|
|
154
|
+
) -> bytes:
|
|
155
|
+
body = {
|
|
156
|
+
"source": source,
|
|
157
|
+
"operations": [op.model_dump() for op in operations],
|
|
158
|
+
}
|
|
159
|
+
resp = await self._client.post("/v1/pipeline", json=body)
|
|
160
|
+
self._raise(resp)
|
|
161
|
+
return resp.content
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""LangChain tool wrappers for Pictomancer.ai.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install pictomancer[langchain]``.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
from pictomancer.langchain import pictomancer_tools
|
|
8
|
+
|
|
9
|
+
tools = pictomancer_tools()
|
|
10
|
+
# pass *tools* to your LangChain agent / tool node
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
|
|
17
|
+
from langchain_core.tools import StructuredTool
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from pictomancer.client import Pictomancer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _AnalyzeInput(BaseModel):
|
|
24
|
+
source: str = Field(description="Public image URL or base64 data URI.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ResizeInput(BaseModel):
|
|
28
|
+
source: str = Field(description="Public image URL or base64 data URI.")
|
|
29
|
+
scale: float = Field(description="Uniform scale factor (e.g. 0.5 = half size).")
|
|
30
|
+
format: str = Field("png", description="Output format: jpeg, png, webp.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _CompressInput(BaseModel):
|
|
34
|
+
source: str = Field(description="Public image URL or base64 data URI.")
|
|
35
|
+
q: int = Field(75, ge=1, le=100, description="Quality 1-100.")
|
|
36
|
+
format: str = Field("jpeg", description="Output format.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _ConvertInput(BaseModel):
|
|
40
|
+
source: str = Field(description="Public image URL or base64 data URI.")
|
|
41
|
+
format: str = Field(description="Target format: jpeg, png, webp, tiff, gif.")
|
|
42
|
+
q: int | None = Field(None, ge=1, le=100, description="Quality 1-100.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _CropInput(BaseModel):
|
|
46
|
+
source: str = Field(description="Public image URL or base64 data URI.")
|
|
47
|
+
x: int = Field(description="Left edge in pixels.")
|
|
48
|
+
y: int = Field(description="Top edge in pixels.")
|
|
49
|
+
width: int = Field(description="Width in pixels.")
|
|
50
|
+
height: int = Field(description="Height in pixels.")
|
|
51
|
+
format: str = Field("png", description="Output format.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _b64_uri(data: bytes, fmt: str) -> str:
|
|
55
|
+
return f"data:image/{fmt};base64,{base64.b64encode(data).decode()}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pictomancer_tools(
|
|
59
|
+
*,
|
|
60
|
+
base_url: str = "https://api.pictomancer.ai",
|
|
61
|
+
wallet: str | None = None,
|
|
62
|
+
) -> list[StructuredTool]:
|
|
63
|
+
"""Return a list of LangChain tools backed by the Pictomancer.ai API."""
|
|
64
|
+
|
|
65
|
+
async def _analyze(source: str) -> dict:
|
|
66
|
+
async with Pictomancer(base_url=base_url, wallet=wallet) as p:
|
|
67
|
+
result = await p.analyze(source)
|
|
68
|
+
return result.model_dump()
|
|
69
|
+
|
|
70
|
+
async def _resize(source: str, scale: float, format: str = "png") -> str:
|
|
71
|
+
async with Pictomancer(base_url=base_url, wallet=wallet) as p:
|
|
72
|
+
data = await p.resize(source, scale=scale, format=format)
|
|
73
|
+
return _b64_uri(data, format)
|
|
74
|
+
|
|
75
|
+
async def _compress(source: str, q: int = 75, format: str = "jpeg") -> str:
|
|
76
|
+
async with Pictomancer(base_url=base_url, wallet=wallet) as p:
|
|
77
|
+
data = await p.compress(source, q=q, format=format)
|
|
78
|
+
return _b64_uri(data, format)
|
|
79
|
+
|
|
80
|
+
async def _convert(source: str, format: str, q: int | None = None) -> str:
|
|
81
|
+
async with Pictomancer(base_url=base_url, wallet=wallet) as p:
|
|
82
|
+
data = await p.convert(source, format=format, q=q)
|
|
83
|
+
return _b64_uri(data, format)
|
|
84
|
+
|
|
85
|
+
async def _crop(
|
|
86
|
+
source: str, x: int, y: int, width: int, height: int, format: str = "png",
|
|
87
|
+
) -> str:
|
|
88
|
+
async with Pictomancer(base_url=base_url, wallet=wallet) as p:
|
|
89
|
+
data = await p.crop(source, x=x, y=y, width=width, height=height, format=format)
|
|
90
|
+
return _b64_uri(data, format)
|
|
91
|
+
|
|
92
|
+
return [
|
|
93
|
+
StructuredTool.from_function(
|
|
94
|
+
coroutine=_analyze,
|
|
95
|
+
name="pictomancer_analyze",
|
|
96
|
+
description="Get image file size in bytes. Free, no payment required.",
|
|
97
|
+
args_schema=_AnalyzeInput,
|
|
98
|
+
),
|
|
99
|
+
StructuredTool.from_function(
|
|
100
|
+
coroutine=_resize,
|
|
101
|
+
name="pictomancer_resize",
|
|
102
|
+
description="Resize an image by a scale factor. Returns base64 data URI. Costs $0.001/request.",
|
|
103
|
+
args_schema=_ResizeInput,
|
|
104
|
+
),
|
|
105
|
+
StructuredTool.from_function(
|
|
106
|
+
coroutine=_compress,
|
|
107
|
+
name="pictomancer_compress",
|
|
108
|
+
description="Compress an image with quality control. Returns base64 data URI. Costs $0.001/request.",
|
|
109
|
+
args_schema=_CompressInput,
|
|
110
|
+
),
|
|
111
|
+
StructuredTool.from_function(
|
|
112
|
+
coroutine=_convert,
|
|
113
|
+
name="pictomancer_convert",
|
|
114
|
+
description="Convert image format (jpeg/png/webp/tiff/gif). Returns base64 data URI. Costs $0.002/request.",
|
|
115
|
+
args_schema=_ConvertInput,
|
|
116
|
+
),
|
|
117
|
+
StructuredTool.from_function(
|
|
118
|
+
coroutine=_crop,
|
|
119
|
+
name="pictomancer_crop",
|
|
120
|
+
description="Crop a rectangular region from an image. Returns base64 data URI. Costs $0.001/request.",
|
|
121
|
+
args_schema=_CropInput,
|
|
122
|
+
),
|
|
123
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Request/response models mirroring the Pictomancer.ai REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnalyzeResult(BaseModel):
|
|
9
|
+
size_bytes: int = Field(description="File size of the fetched image in bytes.")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResizeParams(BaseModel):
|
|
13
|
+
scale: float | None = Field(None, description="Uniform scale factor (e.g. 0.5 = half size).")
|
|
14
|
+
scale_x: float | None = Field(None, description="Horizontal scale factor.")
|
|
15
|
+
scale_y: float | None = Field(None, description="Vertical scale factor.")
|
|
16
|
+
format: str | None = Field(None, description="Output format: jpeg, png, webp, tiff, gif.")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CompressParams(BaseModel):
|
|
20
|
+
q: int | None = Field(None, ge=1, le=100, description="Quality 1-100.")
|
|
21
|
+
format: str | None = Field(None, description="Output format.")
|
|
22
|
+
strip: bool | None = Field(None, description="Strip EXIF/ICC metadata.")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConvertParams(BaseModel):
|
|
26
|
+
format: str = Field(description="Target format: jpeg, png, webp, tiff, gif.")
|
|
27
|
+
q: int | None = Field(None, ge=1, le=100, description="Quality 1-100.")
|
|
28
|
+
strip: bool | None = Field(None, description="Strip metadata.")
|
|
29
|
+
lossless: bool | None = Field(None, description="Lossless encoding (webp only).")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CropParams(BaseModel):
|
|
33
|
+
x: int = Field(description="Left edge in pixels.")
|
|
34
|
+
y: int = Field(description="Top edge in pixels.")
|
|
35
|
+
width: int = Field(description="Width in pixels.")
|
|
36
|
+
height: int = Field(description="Height in pixels.")
|
|
37
|
+
format: str | None = Field(None, description="Output format.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PipelineOp(BaseModel):
|
|
41
|
+
type: str = Field(description="Operation: resize, compress, convert, crop.")
|
|
42
|
+
params: dict[str, str] = Field(default_factory=dict, description="Operation params as string k/v.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FormatOption(BaseModel):
|
|
46
|
+
name: str
|
|
47
|
+
kind: str
|
|
48
|
+
description: str = ""
|
|
49
|
+
default_str: str | None = None
|
|
50
|
+
min: float | None = None
|
|
51
|
+
max: float | None = None
|
|
52
|
+
enum_values: list[str] | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FormatSpec(BaseModel):
|
|
56
|
+
id: str = Field(description="Format identifier: jpeg, png, webp, tiff, gif.")
|
|
57
|
+
suffix: str = Field(description="File extension including dot.")
|
|
58
|
+
options: list[FormatOption] = Field(default_factory=list)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Unit tests for the Pictomancer client — no network calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from pictomancer import AnalyzeResult, FormatSpec, Pictomancer, PictomancerError, PipelineOp
|
|
10
|
+
|
|
11
|
+
BASE = "https://api.pictomancer.ai"
|
|
12
|
+
SOURCE = "https://example.com/img.jpg"
|
|
13
|
+
|
|
14
|
+
FORMAT_INFO_RESPONSE = {
|
|
15
|
+
"formats": [
|
|
16
|
+
{"id": "jpeg", "suffix": ".jpg", "options": [
|
|
17
|
+
{"name": "q", "kind": "int", "min": 1, "max": 100, "default_str": "85",
|
|
18
|
+
"description": "Quality"},
|
|
19
|
+
]},
|
|
20
|
+
{"id": "webp", "suffix": ".webp", "options": []},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sent_json(route: respx.Route) -> dict:
|
|
29
|
+
"""Extract the JSON payload sent in the first call to a mocked route."""
|
|
30
|
+
return json.loads(route.calls[0].request.content)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Fixtures ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
async def client():
|
|
38
|
+
async with Pictomancer() as c:
|
|
39
|
+
yield c
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
async def wallet_client():
|
|
44
|
+
async with Pictomancer(wallet="0xABC") as c:
|
|
45
|
+
yield c
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── info ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestInfo:
|
|
52
|
+
@respx.mock
|
|
53
|
+
async def test_returns_format_specs(self, client: Pictomancer):
|
|
54
|
+
respx.get(f"{BASE}/v1/info").respond(json=FORMAT_INFO_RESPONSE)
|
|
55
|
+
|
|
56
|
+
result = await client.info()
|
|
57
|
+
|
|
58
|
+
assert len(result) == 2
|
|
59
|
+
|
|
60
|
+
@respx.mock
|
|
61
|
+
async def test_parses_format_ids(self, client: Pictomancer):
|
|
62
|
+
respx.get(f"{BASE}/v1/info").respond(json=FORMAT_INFO_RESPONSE)
|
|
63
|
+
|
|
64
|
+
result = await client.info()
|
|
65
|
+
|
|
66
|
+
assert all(isinstance(f, FormatSpec) for f in result)
|
|
67
|
+
assert result[0].id == "jpeg"
|
|
68
|
+
assert result[1].id == "webp"
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
async def test_parses_format_options(self, client: Pictomancer):
|
|
72
|
+
respx.get(f"{BASE}/v1/info").respond(json=FORMAT_INFO_RESPONSE)
|
|
73
|
+
|
|
74
|
+
result = await client.info()
|
|
75
|
+
|
|
76
|
+
assert len(result[0].options) == 1
|
|
77
|
+
assert result[0].options[0].name == "q"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── analyze ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestAnalyze:
|
|
84
|
+
@respx.mock
|
|
85
|
+
async def test_returns_analyze_result_type(self, client: Pictomancer):
|
|
86
|
+
respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 42_000})
|
|
87
|
+
|
|
88
|
+
result = await client.analyze(SOURCE)
|
|
89
|
+
|
|
90
|
+
assert isinstance(result, AnalyzeResult)
|
|
91
|
+
|
|
92
|
+
@respx.mock
|
|
93
|
+
async def test_returns_correct_size(self, client: Pictomancer):
|
|
94
|
+
respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 42_000})
|
|
95
|
+
|
|
96
|
+
result = await client.analyze(SOURCE)
|
|
97
|
+
|
|
98
|
+
assert result.size_bytes == 42_000
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── resize ───────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestResize:
|
|
105
|
+
@respx.mock
|
|
106
|
+
async def test_returns_image_bytes(self, client: Pictomancer):
|
|
107
|
+
respx.post(f"{BASE}/v1/resize").respond(content=b"\x89PNG")
|
|
108
|
+
|
|
109
|
+
data = await client.resize(SOURCE, scale=0.5)
|
|
110
|
+
|
|
111
|
+
assert data == b"\x89PNG"
|
|
112
|
+
|
|
113
|
+
@respx.mock
|
|
114
|
+
async def test_sends_scale_param(self, client: Pictomancer):
|
|
115
|
+
route = respx.post(f"{BASE}/v1/resize").respond(content=b"x")
|
|
116
|
+
|
|
117
|
+
await client.resize(SOURCE, scale=0.5)
|
|
118
|
+
|
|
119
|
+
assert sent_json(route)["scale"] == 0.5
|
|
120
|
+
|
|
121
|
+
@respx.mock
|
|
122
|
+
async def test_excludes_none_params(self, client: Pictomancer):
|
|
123
|
+
route = respx.post(f"{BASE}/v1/resize").respond(content=b"x")
|
|
124
|
+
|
|
125
|
+
await client.resize(SOURCE, scale=0.5)
|
|
126
|
+
|
|
127
|
+
payload = sent_json(route)
|
|
128
|
+
assert "scale_x" not in payload
|
|
129
|
+
assert "scale_y" not in payload
|
|
130
|
+
assert "format" not in payload
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── compress ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestCompress:
|
|
137
|
+
@respx.mock
|
|
138
|
+
async def test_sends_quality(self, client: Pictomancer):
|
|
139
|
+
route = respx.post(f"{BASE}/v1/compress").respond(content=b"\xff\xd8")
|
|
140
|
+
|
|
141
|
+
await client.compress(SOURCE, q=60)
|
|
142
|
+
|
|
143
|
+
assert sent_json(route)["q"] == 60
|
|
144
|
+
|
|
145
|
+
@respx.mock
|
|
146
|
+
async def test_sends_strip(self, client: Pictomancer):
|
|
147
|
+
route = respx.post(f"{BASE}/v1/compress").respond(content=b"\xff\xd8")
|
|
148
|
+
|
|
149
|
+
await client.compress(SOURCE, strip=True)
|
|
150
|
+
|
|
151
|
+
assert sent_json(route)["strip"] is True
|
|
152
|
+
|
|
153
|
+
@respx.mock
|
|
154
|
+
async def test_excludes_none_params(self, client: Pictomancer):
|
|
155
|
+
route = respx.post(f"{BASE}/v1/compress").respond(content=b"\xff\xd8")
|
|
156
|
+
|
|
157
|
+
await client.compress(SOURCE, q=60, strip=True)
|
|
158
|
+
|
|
159
|
+
assert "format" not in sent_json(route)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── convert ──────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestConvert:
|
|
166
|
+
@respx.mock
|
|
167
|
+
async def test_sends_format(self, client: Pictomancer):
|
|
168
|
+
route = respx.post(f"{BASE}/v1/convert").respond(content=b"RIFF")
|
|
169
|
+
|
|
170
|
+
await client.convert(SOURCE, format="webp")
|
|
171
|
+
|
|
172
|
+
assert sent_json(route)["format"] == "webp"
|
|
173
|
+
|
|
174
|
+
@respx.mock
|
|
175
|
+
async def test_sends_quality(self, client: Pictomancer):
|
|
176
|
+
route = respx.post(f"{BASE}/v1/convert").respond(content=b"RIFF")
|
|
177
|
+
|
|
178
|
+
await client.convert(SOURCE, format="webp", q=85)
|
|
179
|
+
|
|
180
|
+
assert sent_json(route)["q"] == 85
|
|
181
|
+
|
|
182
|
+
@respx.mock
|
|
183
|
+
async def test_excludes_none_params(self, client: Pictomancer):
|
|
184
|
+
route = respx.post(f"{BASE}/v1/convert").respond(content=b"RIFF")
|
|
185
|
+
|
|
186
|
+
await client.convert(SOURCE, format="webp")
|
|
187
|
+
|
|
188
|
+
payload = sent_json(route)
|
|
189
|
+
assert "q" not in payload
|
|
190
|
+
assert "strip" not in payload
|
|
191
|
+
assert "lossless" not in payload
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── crop ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestCrop:
|
|
198
|
+
@respx.mock
|
|
199
|
+
async def test_sends_rect_params(self, client: Pictomancer):
|
|
200
|
+
route = respx.post(f"{BASE}/v1/crop").respond(content=b"\x89PNG")
|
|
201
|
+
|
|
202
|
+
await client.crop(SOURCE, x=10, y=20, width=100, height=200)
|
|
203
|
+
|
|
204
|
+
payload = sent_json(route)
|
|
205
|
+
assert payload["x"] == 10
|
|
206
|
+
assert payload["y"] == 20
|
|
207
|
+
assert payload["width"] == 100
|
|
208
|
+
assert payload["height"] == 200
|
|
209
|
+
|
|
210
|
+
@respx.mock
|
|
211
|
+
async def test_excludes_format_when_none(self, client: Pictomancer):
|
|
212
|
+
route = respx.post(f"{BASE}/v1/crop").respond(content=b"\x89PNG")
|
|
213
|
+
|
|
214
|
+
await client.crop(SOURCE, x=0, y=0, width=50, height=50)
|
|
215
|
+
|
|
216
|
+
assert "format" not in sent_json(route)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ── pipeline ─────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class TestPipeline:
|
|
223
|
+
@respx.mock
|
|
224
|
+
async def test_sends_operation_count(self, client: Pictomancer):
|
|
225
|
+
route = respx.post(f"{BASE}/v1/pipeline").respond(content=b"ok")
|
|
226
|
+
ops = [
|
|
227
|
+
PipelineOp(type="resize", params={"scale": "0.5"}),
|
|
228
|
+
PipelineOp(type="convert", params={"format": "webp"}),
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
await client.pipeline(SOURCE, ops)
|
|
232
|
+
|
|
233
|
+
assert len(sent_json(route)["operations"]) == 2
|
|
234
|
+
|
|
235
|
+
@respx.mock
|
|
236
|
+
async def test_sends_operation_types(self, client: Pictomancer):
|
|
237
|
+
route = respx.post(f"{BASE}/v1/pipeline").respond(content=b"ok")
|
|
238
|
+
ops = [
|
|
239
|
+
PipelineOp(type="resize", params={"scale": "0.5"}),
|
|
240
|
+
PipelineOp(type="convert", params={"format": "webp"}),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
await client.pipeline(SOURCE, ops)
|
|
244
|
+
|
|
245
|
+
types = [o["type"] for o in sent_json(route)["operations"]]
|
|
246
|
+
assert types == ["resize", "convert"]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── error handling ───────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TestErrorHandling:
|
|
253
|
+
@respx.mock
|
|
254
|
+
async def test_raises_on_4xx_with_json_detail(self, client: Pictomancer):
|
|
255
|
+
respx.post(f"{BASE}/v1/analyze").respond(
|
|
256
|
+
status_code=422, json={"detail": "source is required"},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
with pytest.raises(PictomancerError) as exc_info:
|
|
260
|
+
await client.analyze("")
|
|
261
|
+
|
|
262
|
+
assert exc_info.value.status == 422
|
|
263
|
+
|
|
264
|
+
@respx.mock
|
|
265
|
+
async def test_error_contains_detail_message(self, client: Pictomancer):
|
|
266
|
+
respx.post(f"{BASE}/v1/analyze").respond(
|
|
267
|
+
status_code=422, json={"detail": "source is required"},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
with pytest.raises(PictomancerError) as exc_info:
|
|
271
|
+
await client.analyze("")
|
|
272
|
+
|
|
273
|
+
assert "source is required" in exc_info.value.detail
|
|
274
|
+
|
|
275
|
+
@respx.mock
|
|
276
|
+
async def test_raises_on_non_json_error_body(self, client: Pictomancer):
|
|
277
|
+
respx.post(f"{BASE}/v1/analyze").respond(
|
|
278
|
+
status_code=500, content=b"Internal Server Error",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
with pytest.raises(PictomancerError) as exc_info:
|
|
282
|
+
await client.analyze(SOURCE)
|
|
283
|
+
|
|
284
|
+
assert exc_info.value.status == 500
|
|
285
|
+
assert "Internal Server Error" in exc_info.value.detail
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ── wallet header ────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class TestWalletHeader:
|
|
292
|
+
@respx.mock
|
|
293
|
+
async def test_sends_wallet_header(self, wallet_client: Pictomancer):
|
|
294
|
+
route = respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 1})
|
|
295
|
+
|
|
296
|
+
await wallet_client.analyze(SOURCE)
|
|
297
|
+
|
|
298
|
+
assert route.calls[0].request.headers["x-agent-wallet"] == "0xABC"
|
|
299
|
+
|
|
300
|
+
@respx.mock
|
|
301
|
+
async def test_omits_wallet_header_when_not_set(self, client: Pictomancer):
|
|
302
|
+
route = respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 1})
|
|
303
|
+
|
|
304
|
+
await client.analyze(SOURCE)
|
|
305
|
+
|
|
306
|
+
assert "x-agent-wallet" not in route.calls[0].request.headers
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── http_client injection ────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class TestClientInjection:
|
|
313
|
+
@respx.mock
|
|
314
|
+
async def test_uses_injected_client(self):
|
|
315
|
+
route = respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 99})
|
|
316
|
+
custom = httpx.AsyncClient(base_url=BASE)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
p = Pictomancer(http_client=custom)
|
|
320
|
+
result = await p.analyze(SOURCE)
|
|
321
|
+
|
|
322
|
+
assert result.size_bytes == 99
|
|
323
|
+
finally:
|
|
324
|
+
await custom.aclose()
|
|
325
|
+
|
|
326
|
+
@respx.mock
|
|
327
|
+
async def test_does_not_close_injected_client(self):
|
|
328
|
+
route = respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 1})
|
|
329
|
+
custom = httpx.AsyncClient(base_url=BASE)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
async with Pictomancer(http_client=custom) as p:
|
|
333
|
+
await p.analyze(SOURCE)
|
|
334
|
+
# After context exit, injected client should still be open
|
|
335
|
+
assert not custom.is_closed
|
|
336
|
+
finally:
|
|
337
|
+
await custom.aclose()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Unit tests for the LangChain tool adapter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import respx
|
|
7
|
+
|
|
8
|
+
BASE = "https://api.pictomancer.ai"
|
|
9
|
+
SOURCE = "https://example.com/img.jpg"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def tools():
|
|
14
|
+
from pictomancer.langchain import pictomancer_tools
|
|
15
|
+
return pictomancer_tools(base_url=BASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tool_by_name(tools: list, name: str):
|
|
19
|
+
return next(t for t in tools if t.name == name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestToolDiscovery:
|
|
23
|
+
def test_returns_five_tools(self, tools):
|
|
24
|
+
assert len(tools) == 5
|
|
25
|
+
|
|
26
|
+
def test_tool_names(self, tools):
|
|
27
|
+
names = {t.name for t in tools}
|
|
28
|
+
assert names == {
|
|
29
|
+
"pictomancer_analyze",
|
|
30
|
+
"pictomancer_resize",
|
|
31
|
+
"pictomancer_compress",
|
|
32
|
+
"pictomancer_convert",
|
|
33
|
+
"pictomancer_crop",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def test_all_tools_have_descriptions(self, tools):
|
|
37
|
+
assert all(t.description for t in tools)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestAnalyzeTool:
|
|
41
|
+
@respx.mock
|
|
42
|
+
async def test_returns_dict_with_size(self, tools):
|
|
43
|
+
respx.post(f"{BASE}/v1/analyze").respond(json={"size_bytes": 5000})
|
|
44
|
+
tool = _tool_by_name(tools, "pictomancer_analyze")
|
|
45
|
+
|
|
46
|
+
result = await tool.ainvoke({"source": SOURCE})
|
|
47
|
+
|
|
48
|
+
assert result == {"size_bytes": 5000}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestResizeTool:
|
|
52
|
+
@respx.mock
|
|
53
|
+
async def test_returns_base64_data_uri(self, tools):
|
|
54
|
+
respx.post(f"{BASE}/v1/resize").respond(content=b"\x89PNG")
|
|
55
|
+
tool = _tool_by_name(tools, "pictomancer_resize")
|
|
56
|
+
|
|
57
|
+
result = await tool.ainvoke({"source": SOURCE, "scale": 0.5, "format": "png"})
|
|
58
|
+
|
|
59
|
+
assert result.startswith("data:image/png;base64,")
|
|
60
|
+
|
|
61
|
+
@respx.mock
|
|
62
|
+
async def test_sends_scale_to_api(self, tools):
|
|
63
|
+
route = respx.post(f"{BASE}/v1/resize").respond(content=b"x")
|
|
64
|
+
tool = _tool_by_name(tools, "pictomancer_resize")
|
|
65
|
+
|
|
66
|
+
await tool.ainvoke({"source": SOURCE, "scale": 0.25, "format": "png"})
|
|
67
|
+
|
|
68
|
+
payload = json.loads(route.calls[0].request.content)
|
|
69
|
+
assert payload["scale"] == 0.25
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class TestCompressTool:
|
|
73
|
+
@respx.mock
|
|
74
|
+
async def test_returns_base64_data_uri(self, tools):
|
|
75
|
+
respx.post(f"{BASE}/v1/compress").respond(content=b"\xff\xd8")
|
|
76
|
+
tool = _tool_by_name(tools, "pictomancer_compress")
|
|
77
|
+
|
|
78
|
+
result = await tool.ainvoke({"source": SOURCE, "q": 60, "format": "jpeg"})
|
|
79
|
+
|
|
80
|
+
assert result.startswith("data:image/jpeg;base64,")
|
|
81
|
+
|
|
82
|
+
@respx.mock
|
|
83
|
+
async def test_sends_quality_to_api(self, tools):
|
|
84
|
+
route = respx.post(f"{BASE}/v1/compress").respond(content=b"x")
|
|
85
|
+
tool = _tool_by_name(tools, "pictomancer_compress")
|
|
86
|
+
|
|
87
|
+
await tool.ainvoke({"source": SOURCE, "q": 42, "format": "jpeg"})
|
|
88
|
+
|
|
89
|
+
assert json.loads(route.calls[0].request.content)["q"] == 42
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestConvertTool:
|
|
93
|
+
@respx.mock
|
|
94
|
+
async def test_returns_data_uri_with_target_format(self, tools):
|
|
95
|
+
respx.post(f"{BASE}/v1/convert").respond(content=b"RIFF")
|
|
96
|
+
tool = _tool_by_name(tools, "pictomancer_convert")
|
|
97
|
+
|
|
98
|
+
result = await tool.ainvoke({"source": SOURCE, "format": "webp"})
|
|
99
|
+
|
|
100
|
+
assert result.startswith("data:image/webp;base64,")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestCropTool:
|
|
104
|
+
@respx.mock
|
|
105
|
+
async def test_returns_base64_data_uri(self, tools):
|
|
106
|
+
respx.post(f"{BASE}/v1/crop").respond(content=b"\x89PNG")
|
|
107
|
+
tool = _tool_by_name(tools, "pictomancer_crop")
|
|
108
|
+
|
|
109
|
+
result = await tool.ainvoke({
|
|
110
|
+
"source": SOURCE, "x": 0, "y": 0, "width": 50, "height": 50, "format": "png",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
assert result.startswith("data:image/png;base64,")
|
|
114
|
+
|
|
115
|
+
@respx.mock
|
|
116
|
+
async def test_sends_crop_rect_to_api(self, tools):
|
|
117
|
+
route = respx.post(f"{BASE}/v1/crop").respond(content=b"x")
|
|
118
|
+
tool = _tool_by_name(tools, "pictomancer_crop")
|
|
119
|
+
|
|
120
|
+
await tool.ainvoke({
|
|
121
|
+
"source": SOURCE, "x": 10, "y": 20, "width": 100, "height": 200, "format": "png",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
payload = json.loads(route.calls[0].request.content)
|
|
125
|
+
assert payload["x"] == 10
|
|
126
|
+
assert payload["width"] == 100
|