langchain-pexafy 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.
- langchain_pexafy-0.1.0/.github/workflows/ci.yml +22 -0
- langchain_pexafy-0.1.0/.gitignore +8 -0
- langchain_pexafy-0.1.0/CHANGELOG.md +7 -0
- langchain_pexafy-0.1.0/LICENSE +21 -0
- langchain_pexafy-0.1.0/PKG-INFO +154 -0
- langchain_pexafy-0.1.0/README.md +123 -0
- langchain_pexafy-0.1.0/examples/agent.py +28 -0
- langchain_pexafy-0.1.0/pyproject.toml +48 -0
- langchain_pexafy-0.1.0/src/langchain_pexafy/__init__.py +21 -0
- langchain_pexafy-0.1.0/src/langchain_pexafy/_format.py +83 -0
- langchain_pexafy-0.1.0/src/langchain_pexafy/toolkit.py +34 -0
- langchain_pexafy-0.1.0/src/langchain_pexafy/tools.py +292 -0
- langchain_pexafy-0.1.0/tests/integration_tests/test_standard.py +47 -0
- langchain_pexafy-0.1.0/tests/unit_tests/test_standard.py +53 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python }}
|
|
19
|
+
cache: pip
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: pytest -q tests/unit_tests
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
First release: `PexafySearchPhotos`, `PexafyFindSimilarPhotos`, `PexafyGetPhoto`,
|
|
6
|
+
`PexafyToolkit`. Sync and async, compact JSON content with the full records as the
|
|
7
|
+
tool artifact, LangChain standard unit and integration tests.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marouane Tijani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-pexafy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools for Pexafy: semantic search over 9M+ free stock photos from Unsplash, Pexels, Pixabay and more
|
|
5
|
+
Project-URL: Homepage, https://pexafy.com
|
|
6
|
+
Project-URL: Documentation, https://github.com/Pexafy/langchain-pexafy#readme
|
|
7
|
+
Project-URL: Source, https://github.com/Pexafy/langchain-pexafy
|
|
8
|
+
Project-URL: Issues, https://github.com/Pexafy/langchain-pexafy/issues
|
|
9
|
+
Author-email: Marouane Tijani <marouane@pexafy.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,image search,langchain,langgraph,semantic search,stock photos,tools
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
24
|
+
Requires-Dist: pexafy>=0.1.1
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: langchain-tests>=0.3; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# langchain-pexafy
|
|
33
|
+
|
|
34
|
+
[LangChain](https://docs.langchain.com) and [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)
|
|
35
|
+
tools for [Pexafy](https://pexafy.com): semantic search over 9M+ free stock photos
|
|
36
|
+
from Unsplash, Pexels, Pixabay and six more libraries, behind one API. Your agent
|
|
37
|
+
describes the picture it needs in plain English and gets back real photographs,
|
|
38
|
+
each with its image URL, alt text, licence and the credit line to print.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -U langchain-pexafy
|
|
42
|
+
export PEXAFY_API_KEY="pexafy_api_..."
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Get a key at [pexafy.com/dashboard/api-keys](https://pexafy.com/dashboard/api-keys/).
|
|
46
|
+
The free plan gives 5,000 requests a month at 20 a minute; the key is issued
|
|
47
|
+
immediately, with no card and no app review.
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
| Tool | Name the model sees | What it does |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `PexafySearchPhotos` | `pexafy_search_photos` | Search by describing the scene; optional `orientation` filter |
|
|
54
|
+
| `PexafyFindSimilarPhotos` | `pexafy_find_similar_photos` | Photos that look like one already found, by `photo_id` |
|
|
55
|
+
| `PexafyGetPhoto` | `pexafy_get_photo` | One photo's details and credit line, by `photo_id` |
|
|
56
|
+
| `PexafyToolkit` | — | The three tools above, sharing one key |
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from langchain_pexafy import PexafySearchPhotos
|
|
60
|
+
|
|
61
|
+
search = PexafySearchPhotos(max_results=3)
|
|
62
|
+
print(search.invoke({"query": "a red bicycle leaning against a white wall"}))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Each photo comes back as a compact record, sized for a model's context:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"rank": 1,
|
|
70
|
+
"photo_id": "019e1ea7-2e82-7a34-a451-4c6c7c8250f4",
|
|
71
|
+
"alt_text": "Red bicycle parked against white wall with front wheel facing left and back wheel right",
|
|
72
|
+
"url": "https://images.unsplash.com/photo-1520538254843-27a40bae5e3a?w=1280",
|
|
73
|
+
"thumbnail_url": "https://images.unsplash.com/photo-1520538254843-27a40bae5e3a?w=400",
|
|
74
|
+
"width": 4896,
|
|
75
|
+
"height": 3264,
|
|
76
|
+
"orientation": "landscape",
|
|
77
|
+
"dominant_color": "#CAB8B4",
|
|
78
|
+
"photographer": "Mitchel Lensink",
|
|
79
|
+
"source": "Unsplash",
|
|
80
|
+
"source_page_url": "https://unsplash.com/photos/red-bicycle-near-white-wall-Hx_dY7Xeszo",
|
|
81
|
+
"license": "free",
|
|
82
|
+
"credit": "Photo by Mitchel Lensink on Unsplash (https://pexafy.com/legal/licenses/#unsplash)"
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When an agent calls the tool, the `ToolMessage` content is that JSON list and its
|
|
87
|
+
`artifact` holds the full API records (five image sizes, blur hash, HTML credit…),
|
|
88
|
+
so your code can use them without spending the model's tokens.
|
|
89
|
+
|
|
90
|
+
## In an agent
|
|
91
|
+
|
|
92
|
+
`create_agent` runs on LangGraph:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from langchain.agents import create_agent
|
|
96
|
+
from langchain_pexafy import PexafyToolkit
|
|
97
|
+
|
|
98
|
+
agent = create_agent(
|
|
99
|
+
"anthropic:claude-haiku-4-5",
|
|
100
|
+
tools=PexafyToolkit(max_results=4).get_tools(),
|
|
101
|
+
system_prompt="You illustrate articles. For each section, pick one photo, "
|
|
102
|
+
"give its URL, alt text and the exact credit line.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
result = agent.invoke({"messages": [{
|
|
106
|
+
"role": "user",
|
|
107
|
+
"content": "Blog post 'A weekend in Lisbon': sections 'Trams', "
|
|
108
|
+
"'Pastéis de nata', 'Sunset at Miradouro'. One photo each.",
|
|
109
|
+
}]})
|
|
110
|
+
print(result["messages"][-1].content)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The tools work the same in a hand-built LangGraph graph (`ToolNode(tools)`) and
|
|
114
|
+
in any model's `bind_tools(...)`. Every tool has an async path (`ainvoke`).
|
|
115
|
+
|
|
116
|
+
## Writing queries
|
|
117
|
+
|
|
118
|
+
Search runs on meaning, not keywords: `two people hiking on a ridge at dawn` ranks
|
|
119
|
+
better than `hiking dawn people`. Describe what should be in the picture rather
|
|
120
|
+
than what it is for. Leave `orientation` unset unless a shape is really needed: it
|
|
121
|
+
removes every photo of another shape before ranking.
|
|
122
|
+
|
|
123
|
+
The tool descriptions already tell the model this, so an agent writes good queries
|
|
124
|
+
without help.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Rate limits, exhausted quotas and unknown photo ids come back to the model as a
|
|
129
|
+
short message it can act on (`handle_tool_error=True`). A missing key fails at
|
|
130
|
+
construction, before any call. Set `handle_tool_error=False` to get the exception
|
|
131
|
+
instead.
|
|
132
|
+
|
|
133
|
+
## Credits and licences
|
|
134
|
+
|
|
135
|
+
Photos are free to use under their library's licence; `license` and `source_page_url`
|
|
136
|
+
say which. On the free plan, print the `credit` line next to each photo you
|
|
137
|
+
publish. Pexafy finds existing photographs: it does not generate images.
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e ".[dev]"
|
|
143
|
+
pytest tests/unit_tests # offline
|
|
144
|
+
PEXAFY_API_KEY=... pytest tests/integration_tests # live API
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Both suites are LangChain's standard tool tests (`langchain-tests`).
|
|
148
|
+
|
|
149
|
+
## Links
|
|
150
|
+
|
|
151
|
+
- [Pexafy API docs](https://docs.pexafy.com) · [Python SDK](https://github.com/Pexafy/pexafy-python) (`pip install pexafy`)
|
|
152
|
+
- [Pexafy MCP server](https://github.com/Pexafy/pexafy-mcp) for MCP clients (`https://mcp.pexafy.com/mcp`)
|
|
153
|
+
|
|
154
|
+
MIT licence.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# langchain-pexafy
|
|
2
|
+
|
|
3
|
+
[LangChain](https://docs.langchain.com) and [LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)
|
|
4
|
+
tools for [Pexafy](https://pexafy.com): semantic search over 9M+ free stock photos
|
|
5
|
+
from Unsplash, Pexels, Pixabay and six more libraries, behind one API. Your agent
|
|
6
|
+
describes the picture it needs in plain English and gets back real photographs,
|
|
7
|
+
each with its image URL, alt text, licence and the credit line to print.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install -U langchain-pexafy
|
|
11
|
+
export PEXAFY_API_KEY="pexafy_api_..."
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Get a key at [pexafy.com/dashboard/api-keys](https://pexafy.com/dashboard/api-keys/).
|
|
15
|
+
The free plan gives 5,000 requests a month at 20 a minute; the key is issued
|
|
16
|
+
immediately, with no card and no app review.
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
|
|
20
|
+
| Tool | Name the model sees | What it does |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `PexafySearchPhotos` | `pexafy_search_photos` | Search by describing the scene; optional `orientation` filter |
|
|
23
|
+
| `PexafyFindSimilarPhotos` | `pexafy_find_similar_photos` | Photos that look like one already found, by `photo_id` |
|
|
24
|
+
| `PexafyGetPhoto` | `pexafy_get_photo` | One photo's details and credit line, by `photo_id` |
|
|
25
|
+
| `PexafyToolkit` | — | The three tools above, sharing one key |
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from langchain_pexafy import PexafySearchPhotos
|
|
29
|
+
|
|
30
|
+
search = PexafySearchPhotos(max_results=3)
|
|
31
|
+
print(search.invoke({"query": "a red bicycle leaning against a white wall"}))
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Each photo comes back as a compact record, sized for a model's context:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"rank": 1,
|
|
39
|
+
"photo_id": "019e1ea7-2e82-7a34-a451-4c6c7c8250f4",
|
|
40
|
+
"alt_text": "Red bicycle parked against white wall with front wheel facing left and back wheel right",
|
|
41
|
+
"url": "https://images.unsplash.com/photo-1520538254843-27a40bae5e3a?w=1280",
|
|
42
|
+
"thumbnail_url": "https://images.unsplash.com/photo-1520538254843-27a40bae5e3a?w=400",
|
|
43
|
+
"width": 4896,
|
|
44
|
+
"height": 3264,
|
|
45
|
+
"orientation": "landscape",
|
|
46
|
+
"dominant_color": "#CAB8B4",
|
|
47
|
+
"photographer": "Mitchel Lensink",
|
|
48
|
+
"source": "Unsplash",
|
|
49
|
+
"source_page_url": "https://unsplash.com/photos/red-bicycle-near-white-wall-Hx_dY7Xeszo",
|
|
50
|
+
"license": "free",
|
|
51
|
+
"credit": "Photo by Mitchel Lensink on Unsplash (https://pexafy.com/legal/licenses/#unsplash)"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
When an agent calls the tool, the `ToolMessage` content is that JSON list and its
|
|
56
|
+
`artifact` holds the full API records (five image sizes, blur hash, HTML credit…),
|
|
57
|
+
so your code can use them without spending the model's tokens.
|
|
58
|
+
|
|
59
|
+
## In an agent
|
|
60
|
+
|
|
61
|
+
`create_agent` runs on LangGraph:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from langchain.agents import create_agent
|
|
65
|
+
from langchain_pexafy import PexafyToolkit
|
|
66
|
+
|
|
67
|
+
agent = create_agent(
|
|
68
|
+
"anthropic:claude-haiku-4-5",
|
|
69
|
+
tools=PexafyToolkit(max_results=4).get_tools(),
|
|
70
|
+
system_prompt="You illustrate articles. For each section, pick one photo, "
|
|
71
|
+
"give its URL, alt text and the exact credit line.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
result = agent.invoke({"messages": [{
|
|
75
|
+
"role": "user",
|
|
76
|
+
"content": "Blog post 'A weekend in Lisbon': sections 'Trams', "
|
|
77
|
+
"'Pastéis de nata', 'Sunset at Miradouro'. One photo each.",
|
|
78
|
+
}]})
|
|
79
|
+
print(result["messages"][-1].content)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The tools work the same in a hand-built LangGraph graph (`ToolNode(tools)`) and
|
|
83
|
+
in any model's `bind_tools(...)`. Every tool has an async path (`ainvoke`).
|
|
84
|
+
|
|
85
|
+
## Writing queries
|
|
86
|
+
|
|
87
|
+
Search runs on meaning, not keywords: `two people hiking on a ridge at dawn` ranks
|
|
88
|
+
better than `hiking dawn people`. Describe what should be in the picture rather
|
|
89
|
+
than what it is for. Leave `orientation` unset unless a shape is really needed: it
|
|
90
|
+
removes every photo of another shape before ranking.
|
|
91
|
+
|
|
92
|
+
The tool descriptions already tell the model this, so an agent writes good queries
|
|
93
|
+
without help.
|
|
94
|
+
|
|
95
|
+
## Errors
|
|
96
|
+
|
|
97
|
+
Rate limits, exhausted quotas and unknown photo ids come back to the model as a
|
|
98
|
+
short message it can act on (`handle_tool_error=True`). A missing key fails at
|
|
99
|
+
construction, before any call. Set `handle_tool_error=False` to get the exception
|
|
100
|
+
instead.
|
|
101
|
+
|
|
102
|
+
## Credits and licences
|
|
103
|
+
|
|
104
|
+
Photos are free to use under their library's licence; `license` and `source_page_url`
|
|
105
|
+
say which. On the free plan, print the `credit` line next to each photo you
|
|
106
|
+
publish. Pexafy finds existing photographs: it does not generate images.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pip install -e ".[dev]"
|
|
112
|
+
pytest tests/unit_tests # offline
|
|
113
|
+
PEXAFY_API_KEY=... pytest tests/integration_tests # live API
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Both suites are LangChain's standard tool tests (`langchain-tests`).
|
|
117
|
+
|
|
118
|
+
## Links
|
|
119
|
+
|
|
120
|
+
- [Pexafy API docs](https://docs.pexafy.com) · [Python SDK](https://github.com/Pexafy/pexafy-python) (`pip install pexafy`)
|
|
121
|
+
- [Pexafy MCP server](https://github.com/Pexafy/pexafy-mcp) for MCP clients (`https://mcp.pexafy.com/mcp`)
|
|
122
|
+
|
|
123
|
+
MIT licence.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""An agent that illustrates a blog post outline with real, credited photos.
|
|
2
|
+
|
|
3
|
+
pip install langchain-pexafy langchain langchain-anthropic
|
|
4
|
+
export PEXAFY_API_KEY=... ANTHROPIC_API_KEY=...
|
|
5
|
+
python examples/agent.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from langchain.agents import create_agent
|
|
9
|
+
|
|
10
|
+
from langchain_pexafy import PexafyToolkit
|
|
11
|
+
|
|
12
|
+
agent = create_agent(
|
|
13
|
+
"anthropic:claude-haiku-4-5",
|
|
14
|
+
tools=PexafyToolkit(max_results=4).get_tools(),
|
|
15
|
+
system_prompt=(
|
|
16
|
+
"You illustrate articles. For each section, pick one photo, give its URL, "
|
|
17
|
+
"alt text and the exact credit line."
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
result = agent.invoke({
|
|
22
|
+
"messages": [{
|
|
23
|
+
"role": "user",
|
|
24
|
+
"content": "Blog post 'A weekend in Lisbon': sections 'Trams', 'Pastéis de nata', "
|
|
25
|
+
"'Sunset at Miradouro'. One photo each.",
|
|
26
|
+
}]
|
|
27
|
+
})
|
|
28
|
+
print(result["messages"][-1].content)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-pexafy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tools for Pexafy: semantic search over 9M+ free stock photos from Unsplash, Pexels, Pixabay and more"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Marouane Tijani", email = "marouane@pexafy.com"}]
|
|
13
|
+
keywords = ["langchain", "langgraph", "agents", "tools", "stock photos", "image search", "semantic search"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Multimedia :: Graphics",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["langchain-core>=0.3.0", "pexafy>=0.1.1"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=7", "pytest-asyncio>=0.21", "langchain-tests>=0.3", "ruff>=0.5"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://pexafy.com"
|
|
32
|
+
Documentation = "https://github.com/Pexafy/langchain-pexafy#readme"
|
|
33
|
+
Source = "https://github.com/Pexafy/langchain-pexafy"
|
|
34
|
+
Issues = "https://github.com/Pexafy/langchain-pexafy/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/langchain_pexafy"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
line-length = 100
|
|
41
|
+
target-version = "py310"
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
45
|
+
ignore = ["UP007", "UP045"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""LangChain tools for Pexafy: semantic search over free stock photos.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from langchain_pexafy import PexafySearchPhotos
|
|
5
|
+
|
|
6
|
+
PexafySearchPhotos().invoke({"query": "a quiet street in the rain"})
|
|
7
|
+
```
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .toolkit import PexafyToolkit
|
|
13
|
+
from .tools import PexafyFindSimilarPhotos, PexafyGetPhoto, PexafySearchPhotos
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PexafySearchPhotos",
|
|
17
|
+
"PexafyFindSimilarPhotos",
|
|
18
|
+
"PexafyGetPhoto",
|
|
19
|
+
"PexafyToolkit",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What the model reads of a photo.
|
|
2
|
+
|
|
3
|
+
The API returns some thirty fields per photo, most of them for a page that renders
|
|
4
|
+
the image (blur hash, five sizes, the long AI description). An agent needs far less:
|
|
5
|
+
what the photo shows, where to fetch it, and the credit line it has to print next to
|
|
6
|
+
it. Everything else stays in the tool's artifact, untouched.
|
|
7
|
+
|
|
8
|
+
Two fields come from third parties — the photo's alt text and the photographer's
|
|
9
|
+
name — and reach the model as text it will read, so they are flattened to one line,
|
|
10
|
+
stripped of control and invisible characters, and capped.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import unicodedata
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pexafy import Photo
|
|
20
|
+
|
|
21
|
+
FREE_TEXT_MAX_LENGTH = 300
|
|
22
|
+
|
|
23
|
+
# Words a source writes where it has no name: "Photo by Unknown on Pixabay", and a
|
|
24
|
+
# missing surname stringified, "nympha57 None".
|
|
25
|
+
_NO_NAME = {"unknown", "none", "null", "undefined", "n/a", "nan"}
|
|
26
|
+
_CREDIT_LINE = re.compile(r"^(Photo) by (.*?)( on .*)$", re.S)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clean_text(value: Any, limit: int = FREE_TEXT_MAX_LENGTH) -> str:
|
|
30
|
+
"""One line, no control or format character, at most `limit` characters."""
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
return ""
|
|
33
|
+
kept = [
|
|
34
|
+
" " if ch.isspace() else ch
|
|
35
|
+
for ch in value
|
|
36
|
+
if ch.isspace() or unicodedata.category(ch) not in {"Cc", "Cf", "Cs"}
|
|
37
|
+
]
|
|
38
|
+
text = re.sub(r" {2,}", " ", "".join(kept)).strip()
|
|
39
|
+
if len(text) > limit:
|
|
40
|
+
text = text[: limit - 1].rstrip() + "…"
|
|
41
|
+
return text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def clean_name(name: Any) -> str:
|
|
45
|
+
"""A photographer's name without placeholder words; "" when nothing real is left."""
|
|
46
|
+
words = clean_text(name).split()
|
|
47
|
+
kept = [w for w in words if w.lower() not in _NO_NAME]
|
|
48
|
+
if len(kept) < len(words) and [w.lower() for w in kept] == ["photographer"]:
|
|
49
|
+
kept = []
|
|
50
|
+
cleaned = " ".join(kept)
|
|
51
|
+
return "" if cleaned.isdigit() else cleaned
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clean_credit(line: str) -> str:
|
|
55
|
+
"""'Photo by nympha57 None on Pexels' -> 'Photo by nympha57 on Pexels'."""
|
|
56
|
+
line = clean_text(line, limit=400)
|
|
57
|
+
match = _CREDIT_LINE.match(line)
|
|
58
|
+
if not match:
|
|
59
|
+
return line
|
|
60
|
+
lead, who, rest = match.groups()
|
|
61
|
+
who = clean_name(who)
|
|
62
|
+
return f"{lead} by {who}{rest}" if who else f"{lead}{rest}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def summarize(photo: Photo, rank: int) -> dict[str, Any]:
|
|
66
|
+
"""The compact view of one photo that goes into the tool message."""
|
|
67
|
+
return {
|
|
68
|
+
"rank": rank,
|
|
69
|
+
"photo_id": photo.photo_id,
|
|
70
|
+
"alt_text": clean_text(photo.alt_text),
|
|
71
|
+
"url": photo.urls.regular or photo.image_url,
|
|
72
|
+
"thumbnail_url": photo.urls.small or photo.urls.thumb,
|
|
73
|
+
"width": photo.width,
|
|
74
|
+
"height": photo.height,
|
|
75
|
+
"orientation": photo.orientation,
|
|
76
|
+
"dominant_color": photo.color_hex,
|
|
77
|
+
"photographer": clean_name(photo.photographer_full_name)
|
|
78
|
+
or clean_name(photo.photographer_username),
|
|
79
|
+
"source": photo.source,
|
|
80
|
+
"source_page_url": photo.source_image_url,
|
|
81
|
+
"license": photo.license_type,
|
|
82
|
+
"credit": clean_credit(photo.attribution.plain),
|
|
83
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""All Pexafy tools at once, sharing one key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from langchain_core.tools import BaseTool, BaseToolkit
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from .tools import PexafyFindSimilarPhotos, PexafyGetPhoto, PexafySearchPhotos
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PexafyToolkit(BaseToolkit):
|
|
14
|
+
"""Search, find similar and get one photo.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from langchain_pexafy import PexafyToolkit
|
|
18
|
+
|
|
19
|
+
tools = PexafyToolkit().get_tools() # reads PEXAFY_API_KEY
|
|
20
|
+
```
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
api_key: Optional[SecretStr] = None
|
|
24
|
+
max_results: int = 6
|
|
25
|
+
|
|
26
|
+
def get_tools(self) -> list[BaseTool]:
|
|
27
|
+
kwargs = {"max_results": self.max_results}
|
|
28
|
+
if self.api_key is not None:
|
|
29
|
+
kwargs["api_key"] = self.api_key
|
|
30
|
+
return [
|
|
31
|
+
PexafySearchPhotos(**kwargs),
|
|
32
|
+
PexafyFindSimilarPhotos(**kwargs),
|
|
33
|
+
PexafyGetPhoto(**kwargs),
|
|
34
|
+
]
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""LangChain tools for the Pexafy photo search API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Literal, Optional
|
|
8
|
+
|
|
9
|
+
import pexafy
|
|
10
|
+
from langchain_core.callbacks import AsyncCallbackManagerForToolRun, CallbackManagerForToolRun
|
|
11
|
+
from langchain_core.tools import BaseTool, ToolException
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, SecretStr, model_validator
|
|
13
|
+
|
|
14
|
+
from ._format import summarize
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PexafySearchPhotos",
|
|
18
|
+
"PexafyFindSimilarPhotos",
|
|
19
|
+
"PexafyGetPhoto",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
Orientation = Literal["landscape", "portrait", "square"]
|
|
23
|
+
|
|
24
|
+
QUERY_MAX_LENGTH = 250
|
|
25
|
+
MAX_RESULTS = 20
|
|
26
|
+
|
|
27
|
+
_QUERY_DESCRIPTION = (
|
|
28
|
+
"The photograph you want, as one concise English sentence about its visible "
|
|
29
|
+
"subject and scene, e.g. 'two colleagues laughing in a bright open-plan office'. "
|
|
30
|
+
"Full sentences rank better than keyword lists: the search matches meaning. "
|
|
31
|
+
"Describe what should be in the picture, not what it is for."
|
|
32
|
+
)
|
|
33
|
+
_ORIENTATION_DESCRIPTION = (
|
|
34
|
+
"Leave unset unless a shape is actually required (a wide banner: landscape; a "
|
|
35
|
+
"phone wallpaper or a vertical story: portrait). It is a hard filter that drops "
|
|
36
|
+
"every photo of another shape before ranking, so setting it without need loses "
|
|
37
|
+
"the best matches. Several values may be combined."
|
|
38
|
+
)
|
|
39
|
+
_COUNT_DESCRIPTION = f"How many photos to return, 1 to {MAX_RESULTS}."
|
|
40
|
+
_PHOTO_ID_DESCRIPTION = (
|
|
41
|
+
"A Pexafy photo_id, as returned by a previous Pexafy search "
|
|
42
|
+
"(e.g. '019e1ecb-0039-7da6-b1ca-987ee4d337c0')."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SearchPhotosInput(BaseModel):
|
|
47
|
+
query: str = Field(min_length=1, max_length=QUERY_MAX_LENGTH, description=_QUERY_DESCRIPTION)
|
|
48
|
+
orientation: Optional[list[Orientation]] = Field(
|
|
49
|
+
default=None, description=_ORIENTATION_DESCRIPTION
|
|
50
|
+
)
|
|
51
|
+
count: Optional[int] = Field(default=None, ge=1, le=MAX_RESULTS, description=_COUNT_DESCRIPTION)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FindSimilarPhotosInput(BaseModel):
|
|
55
|
+
photo_id: str = Field(min_length=1, description=_PHOTO_ID_DESCRIPTION)
|
|
56
|
+
count: Optional[int] = Field(default=None, ge=1, le=MAX_RESULTS, description=_COUNT_DESCRIPTION)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GetPhotoInput(BaseModel):
|
|
60
|
+
photo_id: str = Field(min_length=1, description=_PHOTO_ID_DESCRIPTION)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _PexafyTool(BaseTool):
|
|
64
|
+
"""Holds the key and the clients; the subclasses only say what they call."""
|
|
65
|
+
|
|
66
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
67
|
+
|
|
68
|
+
api_key: Optional[SecretStr] = None
|
|
69
|
+
"""Pexafy API key. Falls back to the `PEXAFY_API_KEY` environment variable."""
|
|
70
|
+
|
|
71
|
+
base_url: str = pexafy.DEFAULT_BASE_URL
|
|
72
|
+
max_results: int = Field(default=6, ge=1, le=MAX_RESULTS)
|
|
73
|
+
"""How many photos a search returns when the model does not ask for a number."""
|
|
74
|
+
|
|
75
|
+
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
|
76
|
+
handle_tool_error: bool = True
|
|
77
|
+
"""Quota, rate-limit and not-found errors go back to the model as text it can act on."""
|
|
78
|
+
|
|
79
|
+
_client: Optional[pexafy.Client] = PrivateAttr(default=None)
|
|
80
|
+
|
|
81
|
+
@model_validator(mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _key_from_env(cls, values: Any) -> Any:
|
|
84
|
+
if isinstance(values, dict) and not values.get("api_key"):
|
|
85
|
+
key = os.environ.get("PEXAFY_API_KEY")
|
|
86
|
+
if key:
|
|
87
|
+
values = {**values, "api_key": key}
|
|
88
|
+
return values
|
|
89
|
+
|
|
90
|
+
@model_validator(mode="after")
|
|
91
|
+
def _require_key(self) -> _PexafyTool:
|
|
92
|
+
if self.api_key is None or not self.api_key.get_secret_value():
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"No Pexafy API key. Pass api_key= or set PEXAFY_API_KEY. "
|
|
95
|
+
"Keys are created at https://pexafy.com/dashboard/api-keys/ "
|
|
96
|
+
"(free plan, no card)."
|
|
97
|
+
)
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def _client_kwargs(self) -> dict[str, Any]:
|
|
101
|
+
assert self.api_key is not None
|
|
102
|
+
return {"api_key": self.api_key.get_secret_value(), "base_url": self.base_url}
|
|
103
|
+
|
|
104
|
+
def _tag(self, client: Any) -> Any:
|
|
105
|
+
# Lets the API tell requests made through this package from direct SDK use.
|
|
106
|
+
from . import __version__
|
|
107
|
+
|
|
108
|
+
client._http.headers["user-agent"] = (
|
|
109
|
+
f"langchain-pexafy/{__version__} pexafy-python/{pexafy.__version__}"
|
|
110
|
+
)
|
|
111
|
+
return client
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def client(self) -> pexafy.Client:
|
|
115
|
+
if self._client is None:
|
|
116
|
+
self._client = self._tag(pexafy.Client(**self._client_kwargs()))
|
|
117
|
+
return self._client
|
|
118
|
+
|
|
119
|
+
def _async_client(self) -> pexafy.AsyncClient:
|
|
120
|
+
# One per call: an httpx.AsyncClient is bound to the event loop that made it.
|
|
121
|
+
return self._tag(pexafy.AsyncClient(**self._client_kwargs()))
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _error(exc: pexafy.PexafyError) -> ToolException:
|
|
125
|
+
if isinstance(exc, pexafy.RateLimitError):
|
|
126
|
+
return ToolException(f"Pexafy rate limit or quota reached: {exc}")
|
|
127
|
+
if isinstance(exc, pexafy.NotFoundError):
|
|
128
|
+
return ToolException(f"No such Pexafy photo: {exc}")
|
|
129
|
+
return ToolException(f"Pexafy request failed: {exc}")
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _photos(result: pexafy.SearchResult) -> tuple[str, list[dict[str, Any]]]:
|
|
133
|
+
photos = list(result.photos)
|
|
134
|
+
summary = [summarize(p, rank) for rank, p in enumerate(photos, start=1)]
|
|
135
|
+
if not summary:
|
|
136
|
+
return "No photos matched. Try describing the scene differently.", []
|
|
137
|
+
return json.dumps(summary, ensure_ascii=False), [p.raw for p in photos]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PexafySearchPhotos(_PexafyTool):
|
|
141
|
+
"""Search free-to-use stock photos by describing the scene.
|
|
142
|
+
|
|
143
|
+
Setup:
|
|
144
|
+
```bash
|
|
145
|
+
pip install -U langchain-pexafy
|
|
146
|
+
export PEXAFY_API_KEY="pexafy_api_..."
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Instantiate:
|
|
150
|
+
```python
|
|
151
|
+
from langchain_pexafy import PexafySearchPhotos
|
|
152
|
+
|
|
153
|
+
tool = PexafySearchPhotos(max_results=5)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Invoke directly with args:
|
|
157
|
+
```python
|
|
158
|
+
tool.invoke({"query": "a red bicycle leaning against a white wall"})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Invoke with a ToolCall (what an agent does): the message content is a JSON list
|
|
162
|
+
of compact photo records; `artifact` holds the full API records.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
name: str = "pexafy_search_photos"
|
|
166
|
+
description: str = (
|
|
167
|
+
"Find real, free-to-use stock photographs (Unsplash, Pexels, Pixabay and other "
|
|
168
|
+
"libraries) by describing the scene in plain English. Use it whenever you need a "
|
|
169
|
+
"photo: a blog or article header, a hero image, an illustration for a section, a "
|
|
170
|
+
"slide or newsletter picture. It finds photographs that already exist; it does not "
|
|
171
|
+
"generate or edit images, and does not find illustrations, logos, icons or named "
|
|
172
|
+
"people. Each result has an image URL, alt text, its licence and the credit line "
|
|
173
|
+
"to print next to the photo."
|
|
174
|
+
)
|
|
175
|
+
args_schema: type[BaseModel] = SearchPhotosInput
|
|
176
|
+
|
|
177
|
+
def _filters(self, orientation: Optional[list[str]], count: Optional[int]) -> dict[str, Any]:
|
|
178
|
+
filters: dict[str, Any] = {"per_page": count or self.max_results}
|
|
179
|
+
if orientation:
|
|
180
|
+
filters["orientation"] = list(orientation)
|
|
181
|
+
return filters
|
|
182
|
+
|
|
183
|
+
def _run(
|
|
184
|
+
self,
|
|
185
|
+
query: str,
|
|
186
|
+
orientation: Optional[list[str]] = None,
|
|
187
|
+
count: Optional[int] = None,
|
|
188
|
+
run_manager: Optional[CallbackManagerForToolRun] = None,
|
|
189
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
190
|
+
try:
|
|
191
|
+
result = self.client.search(query, **self._filters(orientation, count))
|
|
192
|
+
except pexafy.PexafyError as exc:
|
|
193
|
+
raise self._error(exc) from exc
|
|
194
|
+
return self._photos(result)
|
|
195
|
+
|
|
196
|
+
async def _arun(
|
|
197
|
+
self,
|
|
198
|
+
query: str,
|
|
199
|
+
orientation: Optional[list[str]] = None,
|
|
200
|
+
count: Optional[int] = None,
|
|
201
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
202
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
203
|
+
try:
|
|
204
|
+
async with self._async_client() as client:
|
|
205
|
+
result = await client.search(query, **self._filters(orientation, count))
|
|
206
|
+
except pexafy.PexafyError as exc:
|
|
207
|
+
raise self._error(exc) from exc
|
|
208
|
+
return self._photos(result)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class PexafyFindSimilarPhotos(_PexafyTool):
|
|
212
|
+
"""Find photos that look like one already found.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from langchain_pexafy import PexafyFindSimilarPhotos
|
|
216
|
+
|
|
217
|
+
PexafyFindSimilarPhotos().invoke({"photo_id": "019e1ea7-2e82-7a34-a451-4c6c7c8250f4"})
|
|
218
|
+
```
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
name: str = "pexafy_find_similar_photos"
|
|
222
|
+
description: str = (
|
|
223
|
+
"Find stock photographs that look like a photo returned by an earlier Pexafy "
|
|
224
|
+
"search: same subject, composition and mood. Use it to offer alternatives to a "
|
|
225
|
+
"photo that is close but not quite right, or to build a consistent set. Takes the "
|
|
226
|
+
"photo_id of that photo."
|
|
227
|
+
)
|
|
228
|
+
args_schema: type[BaseModel] = FindSimilarPhotosInput
|
|
229
|
+
|
|
230
|
+
def _run(
|
|
231
|
+
self,
|
|
232
|
+
photo_id: str,
|
|
233
|
+
count: Optional[int] = None,
|
|
234
|
+
run_manager: Optional[CallbackManagerForToolRun] = None,
|
|
235
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
236
|
+
try:
|
|
237
|
+
result = self.client.similar(photo_id, per_page=count or self.max_results)
|
|
238
|
+
except pexafy.PexafyError as exc:
|
|
239
|
+
raise self._error(exc) from exc
|
|
240
|
+
return self._photos(result)
|
|
241
|
+
|
|
242
|
+
async def _arun(
|
|
243
|
+
self,
|
|
244
|
+
photo_id: str,
|
|
245
|
+
count: Optional[int] = None,
|
|
246
|
+
run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
|
|
247
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
248
|
+
try:
|
|
249
|
+
async with self._async_client() as client:
|
|
250
|
+
result = await client.similar(photo_id, per_page=count or self.max_results)
|
|
251
|
+
except pexafy.PexafyError as exc:
|
|
252
|
+
raise self._error(exc) from exc
|
|
253
|
+
return self._photos(result)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class PexafyGetPhoto(_PexafyTool):
|
|
257
|
+
"""Fetch one photo's details and credit line by its photo_id.
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from langchain_pexafy import PexafyGetPhoto
|
|
261
|
+
|
|
262
|
+
PexafyGetPhoto().invoke({"photo_id": "019e1ea7-2e82-7a34-a451-4c6c7c8250f4"})
|
|
263
|
+
```
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
name: str = "pexafy_get_photo"
|
|
267
|
+
description: str = (
|
|
268
|
+
"Get the details of one Pexafy photo by its photo_id: image URL, size, alt text, "
|
|
269
|
+
"licence and the credit line to print next to it."
|
|
270
|
+
)
|
|
271
|
+
args_schema: type[BaseModel] = GetPhotoInput
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _one(photo: pexafy.Photo) -> tuple[str, dict[str, Any]]:
|
|
275
|
+
return json.dumps(summarize(photo, 1), ensure_ascii=False), photo.raw
|
|
276
|
+
|
|
277
|
+
def _run(
|
|
278
|
+
self, photo_id: str, run_manager: Optional[CallbackManagerForToolRun] = None
|
|
279
|
+
) -> tuple[str, dict[str, Any]]:
|
|
280
|
+
try:
|
|
281
|
+
return self._one(self.client.get_photo(photo_id))
|
|
282
|
+
except pexafy.PexafyError as exc:
|
|
283
|
+
raise self._error(exc) from exc
|
|
284
|
+
|
|
285
|
+
async def _arun(
|
|
286
|
+
self, photo_id: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
|
|
287
|
+
) -> tuple[str, dict[str, Any]]:
|
|
288
|
+
try:
|
|
289
|
+
async with self._async_client() as client:
|
|
290
|
+
return self._one(await client.get_photo(photo_id))
|
|
291
|
+
except pexafy.PexafyError as exc:
|
|
292
|
+
raise self._error(exc) from exc
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""LangChain's standard integration tests, against the live API.
|
|
2
|
+
|
|
3
|
+
Needs PEXAFY_API_KEY; skipped without it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from langchain_tests.integration_tests import ToolsIntegrationTests
|
|
10
|
+
|
|
11
|
+
from langchain_pexafy import PexafyFindSimilarPhotos, PexafyGetPhoto, PexafySearchPhotos
|
|
12
|
+
|
|
13
|
+
pytestmark = pytest.mark.skipif(
|
|
14
|
+
not os.environ.get("PEXAFY_API_KEY"), reason="PEXAFY_API_KEY not set"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PHOTO_ID = "019e1ea7-2e82-7a34-a451-4c6c7c8250f4"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestSearchPhotosIntegration(ToolsIntegrationTests):
|
|
21
|
+
@property
|
|
22
|
+
def tool_constructor(self):
|
|
23
|
+
return PexafySearchPhotos
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def tool_invoke_params_example(self):
|
|
27
|
+
return {"query": "a red bicycle leaning against a white wall", "count": 2}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestFindSimilarPhotosIntegration(ToolsIntegrationTests):
|
|
31
|
+
@property
|
|
32
|
+
def tool_constructor(self):
|
|
33
|
+
return PexafyFindSimilarPhotos
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def tool_invoke_params_example(self):
|
|
37
|
+
return {"photo_id": PHOTO_ID, "count": 2}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestGetPhotoIntegration(ToolsIntegrationTests):
|
|
41
|
+
@property
|
|
42
|
+
def tool_constructor(self):
|
|
43
|
+
return PexafyGetPhoto
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def tool_invoke_params_example(self):
|
|
47
|
+
return {"photo_id": PHOTO_ID}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""LangChain's standard unit tests, for each tool."""
|
|
2
|
+
|
|
3
|
+
from langchain_tests.unit_tests import ToolsUnitTests
|
|
4
|
+
|
|
5
|
+
from langchain_pexafy import PexafyFindSimilarPhotos, PexafyGetPhoto, PexafySearchPhotos
|
|
6
|
+
|
|
7
|
+
PHOTO_ID = "019e1ea7-2e82-7a34-a451-4c6c7c8250f4"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestSearchPhotosUnit(ToolsUnitTests):
|
|
11
|
+
@property
|
|
12
|
+
def tool_constructor(self):
|
|
13
|
+
return PexafySearchPhotos
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def tool_constructor_params(self):
|
|
17
|
+
return {"api_key": "pexafy_api_test"}
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def tool_invoke_params_example(self):
|
|
21
|
+
return {"query": "a red bicycle leaning against a white wall", "count": 3}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def init_from_env_params(self):
|
|
25
|
+
return {"PEXAFY_API_KEY": "pexafy_api_env"}, {}, {"api_key": "pexafy_api_env"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestFindSimilarPhotosUnit(ToolsUnitTests):
|
|
29
|
+
@property
|
|
30
|
+
def tool_constructor(self):
|
|
31
|
+
return PexafyFindSimilarPhotos
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def tool_constructor_params(self):
|
|
35
|
+
return {"api_key": "pexafy_api_test"}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def tool_invoke_params_example(self):
|
|
39
|
+
return {"photo_id": PHOTO_ID, "count": 3}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestGetPhotoUnit(ToolsUnitTests):
|
|
43
|
+
@property
|
|
44
|
+
def tool_constructor(self):
|
|
45
|
+
return PexafyGetPhoto
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def tool_constructor_params(self):
|
|
49
|
+
return {"api_key": "pexafy_api_test"}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def tool_invoke_params_example(self):
|
|
53
|
+
return {"photo_id": PHOTO_ID}
|