hydrafetch 0.1.1__tar.gz → 0.2.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.2.0}/.github/workflows/publish.yml +9 -1
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/PKG-INFO +17 -1
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/README.md +16 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/pyproject.toml +2 -2
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/src/hydrafetch/_client.py +5 -1
- hydrafetch-0.2.0/src/hydrafetch/_schema.py +69 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/tests/test_client.py +46 -0
- hydrafetch-0.2.0/uv.lock +572 -0
- hydrafetch-0.1.1/uv.lock +0 -369
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/.github/workflows/ci.yml +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/.gitignore +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/LICENSE +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/src/hydrafetch/__init__.py +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.0}/src/hydrafetch/errors.py +0 -0
- {hydrafetch-0.1.1 → hydrafetch-0.2.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.2.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.
|
|
@@ -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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hydrafetch"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.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"
|
|
@@ -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"
|
|
@@ -95,7 +96,10 @@ class _Base:
|
|
|
95
96
|
self, urls: str | Iterable[str], options: Mapping[str, Any]
|
|
96
97
|
) -> dict[str, Any]:
|
|
97
98
|
url_list = [urls] if isinstance(urls, str) else list(urls)
|
|
98
|
-
|
|
99
|
+
body = _drop_none(options)
|
|
100
|
+
if "schema" in body:
|
|
101
|
+
body["schema"] = to_json_schema(body["schema"])
|
|
102
|
+
return {"urls": url_list, **body}
|
|
99
103
|
|
|
100
104
|
def _job_id(self, payload: Any, *keys: str) -> str:
|
|
101
105
|
if isinstance(payload, dict):
|
|
@@ -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)
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
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
|
|
|
@@ -200,3 +211,38 @@ class TestAsyncClient:
|
|
|
200
211
|
out = await client.scrape("https://example.com")
|
|
201
212
|
assert out["markdown"] == "# async"
|
|
202
213
|
assert rec.last.headers["x-api-key"] == "hf_test"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_extract_accepts_a_pydantic_model_and_inlines_nested_ones():
|
|
217
|
+
class Address(BaseModel):
|
|
218
|
+
city: str
|
|
219
|
+
|
|
220
|
+
class Person(BaseModel):
|
|
221
|
+
name: str
|
|
222
|
+
home: Address
|
|
223
|
+
|
|
224
|
+
client, seen = _recording_client()
|
|
225
|
+
client.extract("https://example.com", schema=Person)
|
|
226
|
+
|
|
227
|
+
schema = seen[0]["schema"]
|
|
228
|
+
assert schema["properties"]["name"]["type"] == "string"
|
|
229
|
+
assert schema["properties"]["home"]["properties"]["city"]["type"] == "string"
|
|
230
|
+
assert "$defs" not in schema and "$ref" not in str(schema)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_extract_still_takes_plain_json_schema():
|
|
234
|
+
client, seen = _recording_client()
|
|
235
|
+
plain = {"type": "object", "properties": {"name": {"type": "string"}}}
|
|
236
|
+
client.extract("https://example.com", schema=plain)
|
|
237
|
+
assert seen[0]["schema"] == plain
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class Node(BaseModel):
|
|
241
|
+
child: Node | None = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_extract_rejects_a_recursive_model_rather_than_sending_a_ref():
|
|
245
|
+
client, _ = _recording_client()
|
|
246
|
+
with pytest.raises(HydrafetchError) as err:
|
|
247
|
+
client.extract("https://example.com", schema=Node)
|
|
248
|
+
assert err.value.code == "SCHEMA_CONVERSION_FAILED"
|