hydrafetch 0.1.1__tar.gz → 0.3.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.
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/.github/workflows/publish.yml +9 -1
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/PKG-INFO +19 -2
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/README.md +18 -1
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/pyproject.toml +8 -2
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/src/hydrafetch/_client.py +17 -5
- hydrafetch-0.3.0/src/hydrafetch/_schema.py +69 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/tests/test_client.py +57 -1
- hydrafetch-0.3.0/uv.lock +572 -0
- hydrafetch-0.1.1/uv.lock +0 -369
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/.github/workflows/ci.yml +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/.gitignore +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/LICENSE +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/src/hydrafetch/__init__.py +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/src/hydrafetch/errors.py +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.3.0}/src/hydrafetch/py.typed +0 -0
|
@@ -11,7 +11,8 @@ jobs:
|
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
environment: pypi
|
|
13
13
|
permissions:
|
|
14
|
-
contents: read
|
|
14
|
+
# contents: write is what lets the release be created; the publish itself needs only read.
|
|
15
|
+
contents: write
|
|
15
16
|
id-token: write
|
|
16
17
|
steps:
|
|
17
18
|
- uses: actions/checkout@v4
|
|
@@ -25,3 +26,10 @@ jobs:
|
|
|
25
26
|
[ "$TAG" = "$PKG" ] || { echo "tag $TAG != pyproject $PKG"; exit 1; }
|
|
26
27
|
- run: uv build
|
|
27
28
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
29
|
+
|
|
30
|
+
# After the publish, never before: a release pointing at a version that is not on PyPI is
|
|
31
|
+
# worse than no release at all.
|
|
32
|
+
- name: Create the GitHub release
|
|
33
|
+
uses: softprops/action-gh-release@v2
|
|
34
|
+
with:
|
|
35
|
+
generate_release_notes: true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hydrafetch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Official Python client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data.
|
|
5
5
|
Project-URL: Homepage, https://hydrafetch.com
|
|
6
6
|
Project-URL: Documentation, https://docs.hydrafetch.com
|
|
@@ -105,6 +105,22 @@ for item in out["results"]:
|
|
|
105
105
|
|
|
106
106
|
Pass `prompt=` instead of, or alongside, `schema=` to describe the fields in plain language.
|
|
107
107
|
|
|
108
|
+
### From a pydantic model
|
|
109
|
+
|
|
110
|
+
`schema=` also takes a pydantic model, so the shape you already parse into is the shape you ask for. A `Field` description is carried through and tells the model what the field means, which is worth more on an ambiguous page than the field name alone.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from pydantic import BaseModel, Field
|
|
114
|
+
|
|
115
|
+
class Product(BaseModel):
|
|
116
|
+
name: str = Field(description="The product being sold on this page, not a recommended one")
|
|
117
|
+
price_usd: float = Field(description="Current price in USD, after any discount")
|
|
118
|
+
|
|
119
|
+
out = hf.extract("https://example.com/product/1", schema=Product)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Nested models work and are flattened before sending. pydantic is not a dependency and is imported only if you pass a model. A model that refers to itself cannot be flattened and is refused rather than sent.
|
|
123
|
+
|
|
108
124
|
## Discovery and bulk work
|
|
109
125
|
|
|
110
126
|
`map` lists a site's URLs for one credit without fetching any page.
|
|
@@ -155,6 +171,7 @@ for r in res["results"]:
|
|
|
155
171
|
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
156
172
|
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
157
173
|
hf.styleguide("stripe.com") # computed design system
|
|
174
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
158
175
|
```
|
|
159
176
|
|
|
160
177
|
For logos in a browser use [`@hydrafetch/client-sdk`](https://github.com/Hydrafetch/client-sdk) with a publishable key. Those bill against logo pulls rather than credits.
|
|
@@ -238,7 +255,7 @@ hf = Hydrafetch(
|
|
|
238
255
|
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
239
256
|
| `brand(domain)` | brand dict | 5 |
|
|
240
257
|
| `logo(domain, **options)` | logo dict | 1 |
|
|
241
|
-
| `styleguide(
|
|
258
|
+
| `styleguide(domain_or_url, **options)` | design system dict | 10 |
|
|
242
259
|
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
243
260
|
| `images(url)`, `links(url)` | page assets | 1 |
|
|
244
261
|
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
@@ -82,6 +82,22 @@ for item in out["results"]:
|
|
|
82
82
|
|
|
83
83
|
Pass `prompt=` instead of, or alongside, `schema=` to describe the fields in plain language.
|
|
84
84
|
|
|
85
|
+
### From a pydantic model
|
|
86
|
+
|
|
87
|
+
`schema=` also takes a pydantic model, so the shape you already parse into is the shape you ask for. A `Field` description is carried through and tells the model what the field means, which is worth more on an ambiguous page than the field name alone.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from pydantic import BaseModel, Field
|
|
91
|
+
|
|
92
|
+
class Product(BaseModel):
|
|
93
|
+
name: str = Field(description="The product being sold on this page, not a recommended one")
|
|
94
|
+
price_usd: float = Field(description="Current price in USD, after any discount")
|
|
95
|
+
|
|
96
|
+
out = hf.extract("https://example.com/product/1", schema=Product)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Nested models work and are flattened before sending. pydantic is not a dependency and is imported only if you pass a model. A model that refers to itself cannot be flattened and is refused rather than sent.
|
|
100
|
+
|
|
85
101
|
## Discovery and bulk work
|
|
86
102
|
|
|
87
103
|
`map` lists a site's URLs for one credit without fetching any page.
|
|
@@ -132,6 +148,7 @@ for r in res["results"]:
|
|
|
132
148
|
hf.brand("stripe.com") # logos, colours, fonts, socials
|
|
133
149
|
hf.logo("stripe.com", theme="dark", type="icon") # one asset
|
|
134
150
|
hf.styleguide("stripe.com") # computed design system
|
|
151
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0) # one page, read fresh
|
|
135
152
|
```
|
|
136
153
|
|
|
137
154
|
For logos in a browser use [`@hydrafetch/client-sdk`](https://github.com/Hydrafetch/client-sdk) with a publishable key. Those bill against logo pulls rather than credits.
|
|
@@ -215,7 +232,7 @@ hf = Hydrafetch(
|
|
|
215
232
|
| `extract(urls, **options)` | dict with `"results"` | 5 per URL |
|
|
216
233
|
| `brand(domain)` | brand dict | 5 |
|
|
217
234
|
| `logo(domain, **options)` | logo dict | 1 |
|
|
218
|
-
| `styleguide(
|
|
235
|
+
| `styleguide(domain_or_url, **options)` | design system dict | 10 |
|
|
219
236
|
| `screenshot(url, **options)` | screenshot dict | 5 |
|
|
220
237
|
| `images(url)`, `links(url)` | page assets | 1 |
|
|
221
238
|
| `crawl(url, **options)` | job dict, polled to completion | 1 per page |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hydrafetch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Official Python client for the Hydrafetch web data API. Turn any URL into clean Markdown and structured data."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -32,7 +32,7 @@ build-backend = "hatchling.build"
|
|
|
32
32
|
packages = ["src/hydrafetch"]
|
|
33
33
|
|
|
34
34
|
[dependency-groups]
|
|
35
|
-
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6"]
|
|
35
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.6", "pydantic>=2"]
|
|
36
36
|
|
|
37
37
|
[tool.pytest.ini_options]
|
|
38
38
|
asyncio_mode = "auto"
|
|
@@ -46,3 +46,9 @@ target-version = "py39"
|
|
|
46
46
|
# PYI019 wants typing.Self on the context managers. Self landed in 3.11 and this
|
|
47
47
|
# package supports 3.9, so the bound TypeVars stay until the floor moves.
|
|
48
48
|
ignore = ["PYI019"]
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.pyupgrade]
|
|
51
|
+
# `from __future__ import annotations` normally makes `X | None` safe on 3.9, which is why ruff
|
|
52
|
+
# offers to rewrite Optional. Pydantic evaluates annotations at runtime, so on 3.9 the rewrite
|
|
53
|
+
# turns a working model into a TypeError at import. Only the 3.9 CI leg catches it.
|
|
54
|
+
keep-runtime-typing = true
|
|
@@ -8,6 +8,7 @@ from typing import Any, Callable, Literal, TypeVar
|
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
10
10
|
|
|
11
|
+
from ._schema import to_json_schema
|
|
11
12
|
from .errors import HydrafetchError, HydrafetchTimeout
|
|
12
13
|
|
|
13
14
|
DEFAULT_BASE_URL = "https://api.hydrafetch.com"
|
|
@@ -91,11 +92,18 @@ class _Base:
|
|
|
91
92
|
def _scrape_body(self, url: str, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
92
93
|
return {"url": url, **_drop_none(options)}
|
|
93
94
|
|
|
95
|
+
def _styleguide_params(self, target: str, options: Mapping[str, Any]) -> dict[str, Any]:
|
|
96
|
+
key = "url" if target.lower().startswith(("http://", "https://")) else "domain"
|
|
97
|
+
return {key: target, **_drop_none(options)}
|
|
98
|
+
|
|
94
99
|
def _extract_body(
|
|
95
100
|
self, urls: str | Iterable[str], options: Mapping[str, Any]
|
|
96
101
|
) -> dict[str, Any]:
|
|
97
102
|
url_list = [urls] if isinstance(urls, str) else list(urls)
|
|
98
|
-
|
|
103
|
+
body = _drop_none(options)
|
|
104
|
+
if "schema" in body:
|
|
105
|
+
body["schema"] = to_json_schema(body["schema"])
|
|
106
|
+
return {"urls": url_list, **body}
|
|
99
107
|
|
|
100
108
|
def _job_id(self, payload: Any, *keys: str) -> str:
|
|
101
109
|
if isinstance(payload, dict):
|
|
@@ -169,8 +177,10 @@ class Hydrafetch(_Base):
|
|
|
169
177
|
def logo(self, domain: str, **options: Any) -> dict[str, Any]:
|
|
170
178
|
return self._request("GET", "/v1/web/brand/logo", params={"domain": domain, **options})
|
|
171
179
|
|
|
172
|
-
def styleguide(self,
|
|
173
|
-
return self._request(
|
|
180
|
+
def styleguide(self, domain_or_url: str, **options: Any) -> dict[str, Any]:
|
|
181
|
+
return self._request(
|
|
182
|
+
"GET", "/v1/web/styleguide", params=self._styleguide_params(domain_or_url, options)
|
|
183
|
+
)
|
|
174
184
|
|
|
175
185
|
def screenshot(self, url: str, **options: Any) -> dict[str, Any]:
|
|
176
186
|
return self._request("POST", "/v1/web/screenshot", json=self._scrape_body(url, options))
|
|
@@ -318,8 +328,10 @@ class AsyncHydrafetch(_Base):
|
|
|
318
328
|
"GET", "/v1/web/brand/logo", params={"domain": domain, **options}
|
|
319
329
|
)
|
|
320
330
|
|
|
321
|
-
async def styleguide(self,
|
|
322
|
-
return await self._request(
|
|
331
|
+
async def styleguide(self, domain_or_url: str, **options: Any) -> dict[str, Any]:
|
|
332
|
+
return await self._request(
|
|
333
|
+
"GET", "/v1/web/styleguide", params=self._styleguide_params(domain_or_url, options)
|
|
334
|
+
)
|
|
323
335
|
|
|
324
336
|
async def screenshot(self, url: str, **options: Any) -> dict[str, Any]:
|
|
325
337
|
return await self._request(
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Turn whatever the caller has into the JSON Schema the API expects.
|
|
2
|
+
|
|
3
|
+
Pydantic is not a dependency and is never imported. A model is recognised by the method it carries,
|
|
4
|
+
so any object that can describe itself as JSON Schema works, and someone who has no use for
|
|
5
|
+
pydantic still installs this package with one dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .errors import HydrafetchError
|
|
14
|
+
|
|
15
|
+
MAX_REF_DEPTH = 12
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def to_json_schema(schema: Any) -> dict[str, Any]:
|
|
19
|
+
if isinstance(schema, Mapping):
|
|
20
|
+
return inline_refs(dict(schema))
|
|
21
|
+
|
|
22
|
+
for method in ("model_json_schema", "schema"):
|
|
23
|
+
describe = getattr(schema, method, None)
|
|
24
|
+
if callable(describe):
|
|
25
|
+
return inline_refs(describe())
|
|
26
|
+
|
|
27
|
+
raise HydrafetchError(
|
|
28
|
+
"schema must be a dict of JSON Schema or a pydantic model.",
|
|
29
|
+
code="SCHEMA_CONVERSION_FAILED",
|
|
30
|
+
status=0,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""Replace every local $ref with the thing it points at, and drop the definitions block.
|
|
36
|
+
|
|
37
|
+
A nested model is the ordinary case, not an exotic one, and pydantic always describes it as a
|
|
38
|
+
$ref into $defs. That indirection does not survive the trip to a model's tool schema, so
|
|
39
|
+
forwarding it turns a normal two-model extraction into a confusing failure from somewhere the
|
|
40
|
+
caller cannot see. Resolving it here costs nothing and keeps the surface honest.
|
|
41
|
+
"""
|
|
42
|
+
defs = {**schema.get("$defs", {}), **schema.get("definitions", {})}
|
|
43
|
+
if not defs:
|
|
44
|
+
return schema
|
|
45
|
+
|
|
46
|
+
def resolve(node: Any, depth: int) -> Any:
|
|
47
|
+
if isinstance(node, list):
|
|
48
|
+
return [resolve(item, depth) for item in node]
|
|
49
|
+
if not isinstance(node, dict):
|
|
50
|
+
return node
|
|
51
|
+
|
|
52
|
+
target = node.get("$ref")
|
|
53
|
+
if isinstance(target, str) and target.startswith("#/"):
|
|
54
|
+
name = target.rsplit("/", 1)[-1]
|
|
55
|
+
# A self-referential model has no finite inlining. Leaving the $ref in place is worse
|
|
56
|
+
# than saying so, because the failure would surface as an opaque error from the API.
|
|
57
|
+
if depth >= MAX_REF_DEPTH or name not in defs:
|
|
58
|
+
raise HydrafetchError(
|
|
59
|
+
f"schema reference {target} is recursive or missing, so it cannot be inlined. "
|
|
60
|
+
"Flatten the model or pass JSON Schema directly.",
|
|
61
|
+
code="SCHEMA_CONVERSION_FAILED",
|
|
62
|
+
status=0,
|
|
63
|
+
)
|
|
64
|
+
merged = {**defs[name], **{k: v for k, v in node.items() if k != "$ref"}}
|
|
65
|
+
return resolve(merged, depth + 1)
|
|
66
|
+
|
|
67
|
+
return {k: resolve(v, depth) for k, v in node.items() if k not in ("$defs", "definitions")}
|
|
68
|
+
|
|
69
|
+
return resolve(schema, 0)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
import httpx
|
|
7
7
|
import pytest
|
|
8
|
+
from pydantic import BaseModel
|
|
8
9
|
|
|
9
10
|
from hydrafetch import AsyncHydrafetch, Hydrafetch, HydrafetchError, HydrafetchTimeout
|
|
10
11
|
|
|
@@ -20,6 +21,16 @@ def make_client(handler, **kwargs: Any) -> Hydrafetch:
|
|
|
20
21
|
return client
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def _recording_client() -> tuple[Hydrafetch, list[dict[str, Any]]]:
|
|
25
|
+
seen: list[dict[str, Any]] = []
|
|
26
|
+
|
|
27
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
28
|
+
seen.append(json.loads(request.content))
|
|
29
|
+
return httpx.Response(200, json={"success": True, "data": {"results": []}})
|
|
30
|
+
|
|
31
|
+
return make_client(handler), seen
|
|
32
|
+
|
|
33
|
+
|
|
23
34
|
def ok(body: Any, status: int = 200) -> httpx.Response:
|
|
24
35
|
return httpx.Response(status, json=body)
|
|
25
36
|
|
|
@@ -91,6 +102,16 @@ class TestRequestShaping:
|
|
|
91
102
|
assert rec.last.url.params["theme"] == "dark"
|
|
92
103
|
assert not rec.last.content
|
|
93
104
|
|
|
105
|
+
def test_styleguide_reads_a_domain_or_one_page(self) -> None:
|
|
106
|
+
rec = Recorder({"success": True, "data": {}})
|
|
107
|
+
hf = make_client(rec)
|
|
108
|
+
hf.styleguide("stripe.com")
|
|
109
|
+
assert rec.last.url.params["domain"] == "stripe.com"
|
|
110
|
+
hf.styleguide("https://stripe.com/pricing", maxAge=0)
|
|
111
|
+
assert rec.last.url.params["url"] == "https://stripe.com/pricing"
|
|
112
|
+
assert rec.last.url.params["maxAge"] == "0"
|
|
113
|
+
assert "domain" not in rec.last.url.params
|
|
114
|
+
|
|
94
115
|
|
|
95
116
|
class TestErrors:
|
|
96
117
|
def test_surfaces_code_status_and_request_id(self) -> None:
|
|
@@ -200,3 +221,38 @@ class TestAsyncClient:
|
|
|
200
221
|
out = await client.scrape("https://example.com")
|
|
201
222
|
assert out["markdown"] == "# async"
|
|
202
223
|
assert rec.last.headers["x-api-key"] == "hf_test"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_extract_accepts_a_pydantic_model_and_inlines_nested_ones():
|
|
227
|
+
class Address(BaseModel):
|
|
228
|
+
city: str
|
|
229
|
+
|
|
230
|
+
class Person(BaseModel):
|
|
231
|
+
name: str
|
|
232
|
+
home: Address
|
|
233
|
+
|
|
234
|
+
client, seen = _recording_client()
|
|
235
|
+
client.extract("https://example.com", schema=Person)
|
|
236
|
+
|
|
237
|
+
schema = seen[0]["schema"]
|
|
238
|
+
assert schema["properties"]["name"]["type"] == "string"
|
|
239
|
+
assert schema["properties"]["home"]["properties"]["city"]["type"] == "string"
|
|
240
|
+
assert "$defs" not in schema and "$ref" not in str(schema)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_extract_still_takes_plain_json_schema():
|
|
244
|
+
client, seen = _recording_client()
|
|
245
|
+
plain = {"type": "object", "properties": {"name": {"type": "string"}}}
|
|
246
|
+
client.extract("https://example.com", schema=plain)
|
|
247
|
+
assert seen[0]["schema"] == plain
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Node(BaseModel):
|
|
251
|
+
child: Optional[Node] = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_extract_rejects_a_recursive_model_rather_than_sending_a_ref():
|
|
255
|
+
client, _ = _recording_client()
|
|
256
|
+
with pytest.raises(HydrafetchError) as err:
|
|
257
|
+
client.extract("https://example.com", schema=Node)
|
|
258
|
+
assert err.value.code == "SCHEMA_CONVERSION_FAILED"
|