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.
@@ -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:** &lt;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:** &lt;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