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.
@@ -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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .env
@@ -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}