langchain-brewpage 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_brewpage-0.1.0/.gitignore +7 -0
- langchain_brewpage-0.1.0/LICENSE +21 -0
- langchain_brewpage-0.1.0/PKG-INFO +100 -0
- langchain_brewpage-0.1.0/README.md +80 -0
- langchain_brewpage-0.1.0/langchain_brewpage/__init__.py +20 -0
- langchain_brewpage-0.1.0/langchain_brewpage/client.py +88 -0
- langchain_brewpage-0.1.0/langchain_brewpage/py.typed +0 -0
- langchain_brewpage-0.1.0/langchain_brewpage/tools.py +115 -0
- langchain_brewpage-0.1.0/pyproject.toml +41 -0
- langchain_brewpage-0.1.0/tests/test_tools.py +105 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maksim Kochetkov
|
|
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,100 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-brewpage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tools for BrewPage: publish HTML, Markdown, JSON, KV stores and files to brewpage.app and get a shareable URL.
|
|
5
|
+
Project-URL: Homepage, https://brewpage.app
|
|
6
|
+
Project-URL: Repository, https://github.com/kochetkov-ma/langchain-brewpage
|
|
7
|
+
Project-URL: Issues, https://github.com/kochetkov-ma/langchain-brewpage/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/kochetkov-ma/langchain-brewpage#readme
|
|
9
|
+
Author-email: Maksim Kochetkov <apmatypa88@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent,brewpage,hosting,langchain,publish,tools
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: <4.0,>=3.10
|
|
17
|
+
Requires-Dist: httpx==0.28.1
|
|
18
|
+
Requires-Dist: langchain-core==1.7.6
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# langchain-brewpage
|
|
22
|
+
|
|
23
|
+
LangChain tools for [BrewPage](https://brewpage.app): let an agent publish an HTML page, a Markdown document, a JSON document, a key-value store or a file and get back a shareable URL. No account and no API key are needed.
|
|
24
|
+
|
|
25
|
+
Each call returns the URL plus an owner token. The toolkit reuses that token for later publications, so everything the agent creates belongs to one owner. Publications are private and unlisted by default and expire after 15 days unless you set `ttl_days` (1-30).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install langchain-brewpage
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Tools
|
|
34
|
+
|
|
35
|
+
| Tool | API | Use |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `BrewPagePublishPageTool` (`brewpage_publish_page`) | `POST /api/html` | HTML or Markdown page -> URL |
|
|
38
|
+
| `BrewPagePublishJsonTool` (`brewpage_publish_json`) | `POST /api/json` | JSON document -> URL |
|
|
39
|
+
| `BrewPageCreateKvStoreTool` (`brewpage_create_kv_store`) | `POST /api/kv` | Key-value store with one initial key -> URL |
|
|
40
|
+
| `BrewPageUploadFileTool` (`brewpage_upload_file`) | `POST /api/files` | Local file -> download or page URL |
|
|
41
|
+
|
|
42
|
+
`BrewPageToolkit` returns all four tools, sharing one client.
|
|
43
|
+
|
|
44
|
+
Every tool also accepts `ttl_days`, `password` and `namespace`. Leave `namespace` empty for a private link. `public` lists pages in the public gallery and search engines, so never put secrets or personal data there.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from langchain_brewpage import BrewPageToolkit
|
|
50
|
+
|
|
51
|
+
tools = BrewPageToolkit().get_tools()
|
|
52
|
+
page = tools[0].invoke({"content": "# Weekly report\n\nAll green.", "format": "markdown"})
|
|
53
|
+
print(page["url"], page["owner_token"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
With an agent:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from langchain.agents import create_agent
|
|
60
|
+
from langchain_brewpage import BrewPageToolkit
|
|
61
|
+
|
|
62
|
+
agent = create_agent(model, tools=BrewPageToolkit().get_tools()) # any tool-calling chat model
|
|
63
|
+
agent.invoke({"messages": [{"role": "user", "content": "Write a one-page HTML changelog and publish it."}]})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The result of each tool is a dict: `url`, `id`, `namespace`, `owner_token`, `management_link`, `expires_at`.
|
|
67
|
+
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
| Setting | Constructor | Environment |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| API base URL | `BrewPageClient(base_url=...)` | `BREWPAGE_BASE_URL` (default `https://brewpage.app`) |
|
|
73
|
+
| Owner token to reuse | `BrewPageClient(owner_token=...)` | `BREWPAGE_OWNER_TOKEN` |
|
|
74
|
+
| Default namespace | `BrewPageClient(namespace=...)` | `BREWPAGE_NAMESPACE` |
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from langchain_brewpage import BrewPageClient, BrewPageToolkit
|
|
78
|
+
|
|
79
|
+
toolkit = BrewPageToolkit(client=BrewPageClient(owner_token="..."))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
API errors (rate limits, refused content) come back to the agent as tool messages instead of exceptions.
|
|
83
|
+
|
|
84
|
+
## Links
|
|
85
|
+
|
|
86
|
+
- API reference: https://kochetkov-ma.github.io/brewpage-openapi/
|
|
87
|
+
- OpenAPI contract: https://github.com/kochetkov-ma/brewpage-openapi
|
|
88
|
+
- Limits and terms: https://brewpage.app
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv sync --group test
|
|
94
|
+
uv run pytest
|
|
95
|
+
BREWPAGE_LIVE=1 uv run pytest -m live # one private page on production
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# langchain-brewpage
|
|
2
|
+
|
|
3
|
+
LangChain tools for [BrewPage](https://brewpage.app): let an agent publish an HTML page, a Markdown document, a JSON document, a key-value store or a file and get back a shareable URL. No account and no API key are needed.
|
|
4
|
+
|
|
5
|
+
Each call returns the URL plus an owner token. The toolkit reuses that token for later publications, so everything the agent creates belongs to one owner. Publications are private and unlisted by default and expire after 15 days unless you set `ttl_days` (1-30).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install langchain-brewpage
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
| Tool | API | Use |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `BrewPagePublishPageTool` (`brewpage_publish_page`) | `POST /api/html` | HTML or Markdown page -> URL |
|
|
18
|
+
| `BrewPagePublishJsonTool` (`brewpage_publish_json`) | `POST /api/json` | JSON document -> URL |
|
|
19
|
+
| `BrewPageCreateKvStoreTool` (`brewpage_create_kv_store`) | `POST /api/kv` | Key-value store with one initial key -> URL |
|
|
20
|
+
| `BrewPageUploadFileTool` (`brewpage_upload_file`) | `POST /api/files` | Local file -> download or page URL |
|
|
21
|
+
|
|
22
|
+
`BrewPageToolkit` returns all four tools, sharing one client.
|
|
23
|
+
|
|
24
|
+
Every tool also accepts `ttl_days`, `password` and `namespace`. Leave `namespace` empty for a private link. `public` lists pages in the public gallery and search engines, so never put secrets or personal data there.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from langchain_brewpage import BrewPageToolkit
|
|
30
|
+
|
|
31
|
+
tools = BrewPageToolkit().get_tools()
|
|
32
|
+
page = tools[0].invoke({"content": "# Weekly report\n\nAll green.", "format": "markdown"})
|
|
33
|
+
print(page["url"], page["owner_token"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
With an agent:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from langchain.agents import create_agent
|
|
40
|
+
from langchain_brewpage import BrewPageToolkit
|
|
41
|
+
|
|
42
|
+
agent = create_agent(model, tools=BrewPageToolkit().get_tools()) # any tool-calling chat model
|
|
43
|
+
agent.invoke({"messages": [{"role": "user", "content": "Write a one-page HTML changelog and publish it."}]})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The result of each tool is a dict: `url`, `id`, `namespace`, `owner_token`, `management_link`, `expires_at`.
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
| Setting | Constructor | Environment |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| API base URL | `BrewPageClient(base_url=...)` | `BREWPAGE_BASE_URL` (default `https://brewpage.app`) |
|
|
53
|
+
| Owner token to reuse | `BrewPageClient(owner_token=...)` | `BREWPAGE_OWNER_TOKEN` |
|
|
54
|
+
| Default namespace | `BrewPageClient(namespace=...)` | `BREWPAGE_NAMESPACE` |
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from langchain_brewpage import BrewPageClient, BrewPageToolkit
|
|
58
|
+
|
|
59
|
+
toolkit = BrewPageToolkit(client=BrewPageClient(owner_token="..."))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
API errors (rate limits, refused content) come back to the agent as tool messages instead of exceptions.
|
|
63
|
+
|
|
64
|
+
## Links
|
|
65
|
+
|
|
66
|
+
- API reference: https://kochetkov-ma.github.io/brewpage-openapi/
|
|
67
|
+
- OpenAPI contract: https://github.com/kochetkov-ma/brewpage-openapi
|
|
68
|
+
- Limits and terms: https://brewpage.app
|
|
69
|
+
|
|
70
|
+
## Development
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uv sync --group test
|
|
74
|
+
uv run pytest
|
|
75
|
+
BREWPAGE_LIVE=1 uv run pytest -m live # one private page on production
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""LangChain integration for BrewPage: publish HTML, Markdown, JSON, KV and files, get a URL."""
|
|
2
|
+
|
|
3
|
+
from langchain_brewpage.client import BrewPageClient, BrewPageError
|
|
4
|
+
from langchain_brewpage.tools import (
|
|
5
|
+
BrewPageCreateKvStoreTool,
|
|
6
|
+
BrewPagePublishJsonTool,
|
|
7
|
+
BrewPagePublishPageTool,
|
|
8
|
+
BrewPageToolkit,
|
|
9
|
+
BrewPageUploadFileTool,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BrewPageClient",
|
|
14
|
+
"BrewPageError",
|
|
15
|
+
"BrewPageCreateKvStoreTool",
|
|
16
|
+
"BrewPagePublishJsonTool",
|
|
17
|
+
"BrewPagePublishPageTool",
|
|
18
|
+
"BrewPageToolkit",
|
|
19
|
+
"BrewPageUploadFileTool",
|
|
20
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Minimal synchronous client for the public BrewPage API (https://brewpage.app)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://brewpage.app"
|
|
14
|
+
USER_AGENT = "langchain-brewpage/0.1.0"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BrewPageError(RuntimeError):
|
|
18
|
+
"""Raised when the BrewPage API answers with a non-2xx status."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BrewPageClient:
|
|
22
|
+
"""Publishes pages, JSON, KV stores and files; reuses one owner token for all of them."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: Optional[str] = None,
|
|
27
|
+
owner_token: Optional[str] = None,
|
|
28
|
+
namespace: Optional[str] = None,
|
|
29
|
+
timeout: float = 60.0,
|
|
30
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.base_url = (base_url or os.environ.get("BREWPAGE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
33
|
+
self.owner_token = owner_token or os.environ.get("BREWPAGE_OWNER_TOKEN") or None
|
|
34
|
+
self.namespace = namespace or os.environ.get("BREWPAGE_NAMESPACE") or None
|
|
35
|
+
self._http = httpx.Client(timeout=timeout, transport=transport, headers={"User-Agent": USER_AGENT})
|
|
36
|
+
|
|
37
|
+
def publish_page(self, content: str, format: str = "html", ttl_days: Optional[int] = None,
|
|
38
|
+
password: Optional[str] = None, namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
39
|
+
body = {"content": content, "format": format}
|
|
40
|
+
return self._post("/api/html", namespace, ttl_days, password, json_body=body)
|
|
41
|
+
|
|
42
|
+
def publish_json(self, data: Any, ttl_days: Optional[int] = None, password: Optional[str] = None,
|
|
43
|
+
namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
44
|
+
return self._post("/api/json", namespace, ttl_days, password, json_body=data)
|
|
45
|
+
|
|
46
|
+
def create_kv_store(self, key: str, value: str, ttl_days: Optional[int] = None,
|
|
47
|
+
password: Optional[str] = None, namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
48
|
+
return self._post("/api/kv", namespace, ttl_days, password, json_body={"key": key, "value": value})
|
|
49
|
+
|
|
50
|
+
def upload_file(self, path: str, ttl_days: Optional[int] = None, password: Optional[str] = None,
|
|
51
|
+
namespace: Optional[str] = None) -> Dict[str, Any]:
|
|
52
|
+
file = Path(path)
|
|
53
|
+
mime = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
|
|
54
|
+
files = {"file": (file.name, file.read_bytes(), mime)}
|
|
55
|
+
return self._post("/api/files", namespace, ttl_days, password, files=files)
|
|
56
|
+
|
|
57
|
+
def _post(self, path: str, namespace: Optional[str], ttl_days: Optional[int], password: Optional[str],
|
|
58
|
+
json_body: Any = None, files: Any = None) -> Dict[str, Any]:
|
|
59
|
+
params = {}
|
|
60
|
+
ns = namespace or self.namespace
|
|
61
|
+
if ns:
|
|
62
|
+
params["ns"] = ns
|
|
63
|
+
if ttl_days is not None:
|
|
64
|
+
params["ttl"] = str(ttl_days)
|
|
65
|
+
headers = {}
|
|
66
|
+
if self.owner_token:
|
|
67
|
+
headers["X-Owner-Token"] = self.owner_token
|
|
68
|
+
if password:
|
|
69
|
+
headers["X-Password"] = password
|
|
70
|
+
if files is None:
|
|
71
|
+
headers["Content-Type"] = "application/json"
|
|
72
|
+
response = self._http.post(self.base_url + path, params=params, headers=headers,
|
|
73
|
+
content=json.dumps(json_body).encode("utf-8"))
|
|
74
|
+
else:
|
|
75
|
+
response = self._http.post(self.base_url + path, params=params, headers=headers, files=files)
|
|
76
|
+
if response.status_code >= 300:
|
|
77
|
+
raise BrewPageError(f"BrewPage {path} failed with HTTP {response.status_code}: {response.text[:500]}")
|
|
78
|
+
payload = response.json()
|
|
79
|
+
if not self.owner_token and payload.get("ownerToken"):
|
|
80
|
+
self.owner_token = payload["ownerToken"]
|
|
81
|
+
return {
|
|
82
|
+
"url": payload.get("link"),
|
|
83
|
+
"id": payload.get("id"),
|
|
84
|
+
"namespace": payload.get("namespace"),
|
|
85
|
+
"owner_token": payload.get("ownerToken") or self.owner_token,
|
|
86
|
+
"management_link": payload.get("managementLink"),
|
|
87
|
+
"expires_at": payload.get("expiresAt"),
|
|
88
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""LangChain tools and toolkit for BrewPage publishing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Literal, Optional, Type
|
|
7
|
+
|
|
8
|
+
from langchain_core.tools import BaseTool, BaseToolkit, ToolException
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from langchain_brewpage.client import BrewPageClient, BrewPageError
|
|
12
|
+
|
|
13
|
+
_NS = ("Namespace: omit for a private unlisted link; 'public' lists pages in the public gallery and search. "
|
|
14
|
+
"Never put secrets or personal data in 'public'.")
|
|
15
|
+
_TTL = "Lifetime in days (1-30); omit for the 15-day default."
|
|
16
|
+
_PASSWORD = "Optional password readers must enter."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _CommonInput(BaseModel):
|
|
20
|
+
ttl_days: Optional[int] = Field(default=None, description=_TTL)
|
|
21
|
+
password: Optional[str] = Field(default=None, description=_PASSWORD)
|
|
22
|
+
namespace: Optional[str] = Field(default=None, description=_NS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PublishPageInput(_CommonInput):
|
|
26
|
+
content: str = Field(description="HTML document or Markdown text to publish.")
|
|
27
|
+
format: Literal["html", "markdown"] = Field(default="html", description="'html' or 'markdown'.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PublishJsonInput(_CommonInput):
|
|
31
|
+
json_text: str = Field(description="A valid JSON document as text.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CreateKvStoreInput(_CommonInput):
|
|
35
|
+
key: str = Field(description="Initial key name.")
|
|
36
|
+
value: str = Field(description="Value for the initial key (max 1 MB).")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UploadFileInput(_CommonInput):
|
|
40
|
+
path: str = Field(description="Local path of the file to upload.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _BrewPageTool(BaseTool):
|
|
44
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
45
|
+
client: BrewPageClient = Field(default_factory=BrewPageClient, exclude=True)
|
|
46
|
+
handle_tool_error: bool = True
|
|
47
|
+
|
|
48
|
+
def _call(self, method: str, **kwargs: Any) -> Dict[str, Any]:
|
|
49
|
+
try:
|
|
50
|
+
return getattr(self.client, method)(**kwargs)
|
|
51
|
+
except (BrewPageError, OSError, ValueError) as error:
|
|
52
|
+
raise ToolException(str(error)) from error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BrewPagePublishPageTool(_BrewPageTool):
|
|
56
|
+
name: str = "brewpage_publish_page"
|
|
57
|
+
description: str = ("Publish an HTML page or Markdown document on brewpage.app and get a shareable URL "
|
|
58
|
+
"plus an owner token for later edits. No account needed.")
|
|
59
|
+
args_schema: Type[BaseModel] = PublishPageInput
|
|
60
|
+
|
|
61
|
+
def _run(self, content: str, format: str = "html", ttl_days: Optional[int] = None,
|
|
62
|
+
password: Optional[str] = None, namespace: Optional[str] = None, **_: Any) -> Dict[str, Any]:
|
|
63
|
+
return self._call("publish_page", content=content, format=format, ttl_days=ttl_days,
|
|
64
|
+
password=password, namespace=namespace)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BrewPagePublishJsonTool(_BrewPageTool):
|
|
68
|
+
name: str = "brewpage_publish_json"
|
|
69
|
+
description: str = "Host a JSON document on brewpage.app and get a URL that serves it."
|
|
70
|
+
args_schema: Type[BaseModel] = PublishJsonInput
|
|
71
|
+
|
|
72
|
+
def _run(self, json_text: str, ttl_days: Optional[int] = None, password: Optional[str] = None,
|
|
73
|
+
namespace: Optional[str] = None, **_: Any) -> Dict[str, Any]:
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(json_text)
|
|
76
|
+
except ValueError as error:
|
|
77
|
+
raise ToolException(f"json_text is not valid JSON: {error}") from error
|
|
78
|
+
return self._call("publish_json", data=data, ttl_days=ttl_days, password=password, namespace=namespace)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BrewPageCreateKvStoreTool(_BrewPageTool):
|
|
82
|
+
name: str = "brewpage_create_kv_store"
|
|
83
|
+
description: str = "Create a key-value store on brewpage.app with one initial key; returns its URL."
|
|
84
|
+
args_schema: Type[BaseModel] = CreateKvStoreInput
|
|
85
|
+
|
|
86
|
+
def _run(self, key: str, value: str, ttl_days: Optional[int] = None, password: Optional[str] = None,
|
|
87
|
+
namespace: Optional[str] = None, **_: Any) -> Dict[str, Any]:
|
|
88
|
+
return self._call("create_kv_store", key=key, value=value, ttl_days=ttl_days,
|
|
89
|
+
password=password, namespace=namespace)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BrewPageUploadFileTool(_BrewPageTool):
|
|
93
|
+
name: str = "brewpage_upload_file"
|
|
94
|
+
description: str = ("Upload a local file (image, PDF, archive, document, HTML or Markdown) to brewpage.app "
|
|
95
|
+
"and get a download or page URL.")
|
|
96
|
+
args_schema: Type[BaseModel] = UploadFileInput
|
|
97
|
+
|
|
98
|
+
def _run(self, path: str, ttl_days: Optional[int] = None, password: Optional[str] = None,
|
|
99
|
+
namespace: Optional[str] = None, **_: Any) -> Dict[str, Any]:
|
|
100
|
+
return self._call("upload_file", path=path, ttl_days=ttl_days, password=password, namespace=namespace)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BrewPageToolkit(BaseToolkit):
|
|
104
|
+
"""All BrewPage tools sharing one client, so every publication groups under one owner token."""
|
|
105
|
+
|
|
106
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
107
|
+
client: BrewPageClient = Field(default_factory=BrewPageClient)
|
|
108
|
+
|
|
109
|
+
def get_tools(self) -> List[BaseTool]:
|
|
110
|
+
return [
|
|
111
|
+
BrewPagePublishPageTool(client=self.client),
|
|
112
|
+
BrewPagePublishJsonTool(client=self.client),
|
|
113
|
+
BrewPageCreateKvStoreTool(client=self.client),
|
|
114
|
+
BrewPageUploadFileTool(client=self.client),
|
|
115
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-brewpage"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain tools for BrewPage: publish HTML, Markdown, JSON, KV stores and files to brewpage.app and get a shareable URL."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Maksim Kochetkov", email = "apmatypa88@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.10,<4.0"
|
|
14
|
+
keywords = ["langchain", "brewpage", "hosting", "publish", "agent", "tools"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Software Development :: Libraries",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"langchain-core==1.7.6",
|
|
22
|
+
"httpx==0.28.1",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://brewpage.app"
|
|
27
|
+
Repository = "https://github.com/kochetkov-ma/langchain-brewpage"
|
|
28
|
+
Issues = "https://github.com/kochetkov-ma/langchain-brewpage/issues"
|
|
29
|
+
Documentation = "https://github.com/kochetkov-ma/langchain-brewpage#readme"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
test = ["pytest==9.2.0"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["langchain_brewpage"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["langchain_brewpage", "tests", "README.md", "LICENSE"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
markers = ["live: hits production brewpage.app (set BREWPAGE_LIVE=1)"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from langchain_brewpage import BrewPageClient, BrewPageToolkit
|
|
9
|
+
|
|
10
|
+
API_REPLY = {
|
|
11
|
+
"link": "https://brewpage.app/my/abc123",
|
|
12
|
+
"id": "abc123",
|
|
13
|
+
"namespace": "my",
|
|
14
|
+
"ownerToken": "tok-1",
|
|
15
|
+
"managementLink": "https://brewpage.app/my/abc123?manage=k1",
|
|
16
|
+
"expiresAt": "2026-11-20T00:00:00Z",
|
|
17
|
+
}
|
|
18
|
+
BOUNDARY = re.compile(r"[0-9a-f]{32}")
|
|
19
|
+
RESULT = {
|
|
20
|
+
"url": "https://brewpage.app/my/abc123",
|
|
21
|
+
"id": "abc123",
|
|
22
|
+
"namespace": "my",
|
|
23
|
+
"owner_token": "tok-1",
|
|
24
|
+
"management_link": "https://brewpage.app/my/abc123?manage=k1",
|
|
25
|
+
"expires_at": "2026-11-20T00:00:00Z",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _recording_client(status, reply, seen):
|
|
30
|
+
def handler(request):
|
|
31
|
+
seen.append({
|
|
32
|
+
"method": request.method,
|
|
33
|
+
"url": str(request.url),
|
|
34
|
+
"content_type": BOUNDARY.sub("B", request.headers["content-type"]),
|
|
35
|
+
"owner_token": request.headers.get("x-owner-token"),
|
|
36
|
+
"password": request.headers.get("x-password"),
|
|
37
|
+
"body": BOUNDARY.sub("B", request.content.decode("utf-8")),
|
|
38
|
+
})
|
|
39
|
+
return httpx.Response(status, json=reply)
|
|
40
|
+
|
|
41
|
+
return BrewPageClient(owner_token="tok-1", namespace="my", transport=httpx.MockTransport(handler))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
("tool_index", "schema", "args", "expected_request"),
|
|
46
|
+
[
|
|
47
|
+
(0, ("brewpage_publish_page", ["content"]), {"content": "# Hi", "format": "markdown", "ttl_days": 3},
|
|
48
|
+
{"method": "POST", "url": "https://brewpage.app/api/html?ns=my&ttl=3", "content_type": "application/json",
|
|
49
|
+
"owner_token": "tok-1", "password": None, "body": '{"content": "# Hi", "format": "markdown"}'}),
|
|
50
|
+
(1, ("brewpage_publish_json", ["json_text"]), {"json_text": '{"a": [1, 2]}', "password": "pw"},
|
|
51
|
+
{"method": "POST", "url": "https://brewpage.app/api/json?ns=my", "content_type": "application/json",
|
|
52
|
+
"owner_token": "tok-1", "password": "pw", "body": '{"a": [1, 2]}'}),
|
|
53
|
+
(2, ("brewpage_create_kv_store", ["key", "value"]), {"key": "status", "value": "green"},
|
|
54
|
+
{"method": "POST", "url": "https://brewpage.app/api/kv?ns=my", "content_type": "application/json",
|
|
55
|
+
"owner_token": "tok-1", "password": None, "body": '{"key": "status", "value": "green"}'}),
|
|
56
|
+
(3, ("brewpage_upload_file", ["path"]), {"path": "{tmp}/notes.txt"},
|
|
57
|
+
{"method": "POST", "url": "https://brewpage.app/api/files?ns=my", "content_type": "multipart/form-data; boundary=B",
|
|
58
|
+
"owner_token": "tok-1", "password": None,
|
|
59
|
+
"body": '--B\r\nContent-Disposition: form-data; name="file"; filename="notes.txt"\r\n'
|
|
60
|
+
'Content-Type: text/plain\r\n\r\nhello\r\n--B--\r\n'}),
|
|
61
|
+
],
|
|
62
|
+
ids=["page", "json", "kv", "file"],
|
|
63
|
+
)
|
|
64
|
+
def test_tool_posts_exact_request_and_returns_link(tmp_path, tool_index, schema, args, expected_request):
|
|
65
|
+
# GIVEN a toolkit whose client answers every POST with a fixed API reply
|
|
66
|
+
(tmp_path / "notes.txt").write_text("hello")
|
|
67
|
+
seen = []
|
|
68
|
+
tool = BrewPageToolkit(client=_recording_client(201, API_REPLY, seen)).get_tools()[tool_index]
|
|
69
|
+
resolved = json.loads(json.dumps(args).replace("{tmp}", str(tmp_path)))
|
|
70
|
+
|
|
71
|
+
# WHEN the agent invokes the tool
|
|
72
|
+
result = tool.invoke(resolved)
|
|
73
|
+
|
|
74
|
+
# THEN the tool schema, the single request and the mapped result match exactly
|
|
75
|
+
assert ((tool.name, tool.args_schema.model_json_schema()["required"]), seen, result) == (schema, [expected_request], RESULT), f"tool {tool.name} sent {seen} and returned {result}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_api_error_becomes_tool_message():
|
|
79
|
+
# GIVEN an API that rate-limits the caller
|
|
80
|
+
seen = []
|
|
81
|
+
tool = BrewPageToolkit(client=_recording_client(429, {"message": "slow down"}, seen)).get_tools()[2]
|
|
82
|
+
|
|
83
|
+
# WHEN the tool is invoked
|
|
84
|
+
result = tool.invoke({"key": "k", "value": "v"})
|
|
85
|
+
|
|
86
|
+
# THEN the agent receives the error text instead of an exception
|
|
87
|
+
assert result == 'BrewPage /api/kv failed with HTTP 429: {"message":"slow down"}', f"unexpected tool output {result!r}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.live
|
|
91
|
+
@pytest.mark.skipif(os.environ.get("BREWPAGE_LIVE") != "1", reason="set BREWPAGE_LIVE=1 to hit production")
|
|
92
|
+
def test_live_private_markdown_page():
|
|
93
|
+
# GIVEN the real API and the private 'my' namespace
|
|
94
|
+
tool = BrewPageToolkit(client=BrewPageClient(namespace="my")).get_tools()[0]
|
|
95
|
+
|
|
96
|
+
# WHEN a short Markdown page is published for one day
|
|
97
|
+
result = tool.invoke({"content": "# langchain-brewpage smoke\n\nPrivate test page.", "format": "markdown", "ttl_days": 1})
|
|
98
|
+
|
|
99
|
+
# THEN a private brewpage.app link and an owner token come back
|
|
100
|
+
shape = {
|
|
101
|
+
"namespace": result["namespace"],
|
|
102
|
+
"url": bool(re.fullmatch(r"https://([a-z0-9-]+\.)*brewpage\.app/\S+", result["url"])),
|
|
103
|
+
"owner_token": bool(re.fullmatch(r"\S{16,}", result["owner_token"])),
|
|
104
|
+
}
|
|
105
|
+
assert shape == {"namespace": "my", "url": True, "owner_token": True}, f"live result {json.dumps(result)}"
|