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.
@@ -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.1.1
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.1.1"
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
- return {"urls": url_list, **_drop_none(options)}
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"