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.
@@ -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.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(domain)` | design system dict | 10 |
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(domain)` | design system dict | 10 |
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.1.1"
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
- return {"urls": url_list, **_drop_none(options)}
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, domain: str) -> dict[str, Any]:
173
- return self._request("GET", "/v1/web/styleguide", params={"domain": domain})
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, domain: str) -> dict[str, Any]:
322
- return await self._request("GET", "/v1/web/styleguide", params={"domain": domain})
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"