diffbot 3.0.0__py3-none-any.whl

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.
diffbot/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """
2
+ diffbot - Python client library for the Diffbot APIs.
3
+ """
4
+
5
+ import warnings as _warnings
6
+ from importlib.metadata import PackageNotFoundError, version as _version
7
+
8
+ def _installed_version(dist: str):
9
+ try:
10
+ return _version(dist)
11
+ except PackageNotFoundError:
12
+ return None
13
+
14
+
15
+ # The distribution was renamed from diffbot-python to diffbot. The final
16
+ # diffbot-python release ships this same code (see legacy/diffbot-python), so
17
+ # fall back to its version when that's what is installed.
18
+ _legacy_version = _installed_version("diffbot-python")
19
+ # "0.0.0" when not installed (e.g. running from a source tree).
20
+ __version__ = _installed_version("diffbot") or _legacy_version or "0.0.0"
21
+
22
+ # Both distributions ship this module, so a diffbot-python install (alone, or
23
+ # left over next to diffbot) should migrate. FutureWarning, unlike
24
+ # DeprecationWarning, is shown by default wherever the import happens.
25
+ if _legacy_version is not None:
26
+ _warnings.warn(
27
+ "diffbot-python has been renamed to diffbot and will receive no further "
28
+ "updates. Run `pip uninstall diffbot-python && pip install "
29
+ "--force-reinstall diffbot` and replace diffbot-python with diffbot in "
30
+ "your requirements.",
31
+ FutureWarning,
32
+ stacklevel=2,
33
+ )
34
+
35
+ from ._auth import resolve_token
36
+ from .ask import json_schema_format
37
+ from .client import Diffbot, DiffbotAsync
38
+ from .crawl import CrawlEvent, CrawlEventType
39
+ from .errors import (
40
+ APIError,
41
+ AuthError,
42
+ DiffbotError,
43
+ ExtractionError,
44
+ RateLimitError,
45
+ ValidationError,
46
+ )
47
+ from .ontology import Ontology
48
+
49
+ __all__ = [
50
+ "Diffbot",
51
+ "DiffbotAsync",
52
+ "resolve_token",
53
+ "json_schema_format",
54
+ "CrawlEvent",
55
+ "CrawlEventType",
56
+ "Ontology",
57
+ "DiffbotError",
58
+ "AuthError",
59
+ "ExtractionError",
60
+ "RateLimitError",
61
+ "APIError",
62
+ "ValidationError",
63
+ ]
diffbot/_auth.py ADDED
@@ -0,0 +1,41 @@
1
+ """Shared Diffbot credential resolution for both the library and the CLI.
2
+
3
+ The same lookup chain is used everywhere so a single credential works for the
4
+ ``db`` CLI and any Python script that constructs a client:
5
+
6
+ 1. An explicit token passed to the client / function.
7
+ 2. The ``DIFFBOT_API_TOKEN`` environment variable.
8
+ 3. A ``DIFFBOT_API_TOKEN=...`` line in ``~/.diffbot/credentials``.
9
+ """
10
+
11
+ import os
12
+ import pathlib
13
+ from typing import Optional
14
+
15
+ TOKEN_ENV_VAR = "DIFFBOT_API_TOKEN"
16
+ CREDENTIALS_PATH = pathlib.Path.home() / ".diffbot" / "credentials"
17
+
18
+
19
+ def _read_credentials_file() -> str:
20
+ if not CREDENTIALS_PATH.exists():
21
+ return ""
22
+ for line in CREDENTIALS_PATH.read_text().splitlines():
23
+ line = line.strip()
24
+ if line.startswith(f"{TOKEN_ENV_VAR}="):
25
+ return line[len(TOKEN_ENV_VAR) + 1:].strip()
26
+ return ""
27
+
28
+
29
+ def resolve_token(token: Optional[str] = None) -> str:
30
+ """Resolve a Diffbot API token from the explicit argument, env var, or file.
31
+
32
+ Returns an empty string if no token can be found.
33
+ """
34
+ if token and token.strip():
35
+ return token.strip()
36
+
37
+ env_token = os.environ.get(TOKEN_ENV_VAR, "").strip()
38
+ if env_token:
39
+ return env_token
40
+
41
+ return _read_credentials_file()
diffbot/ask.py ADDED
@@ -0,0 +1,195 @@
1
+ """Diffbot LLM RAG API: stream a chat completion."""
2
+
3
+ import json
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, Optional
6
+
7
+ from .errors import ValidationError
8
+
9
+ if TYPE_CHECKING:
10
+ from .client import Diffbot, DiffbotAsync
11
+
12
+ MODEL = "diffbot-small-xl"
13
+
14
+ #: `response_format` types accepted by the Diffbot LLM endpoint.
15
+ RESPONSE_FORMAT_TYPES = ("text", "json_object", "json_schema")
16
+
17
+ # The RAG loop may prefix its final answer with a think block, and the model
18
+ # occasionally wraps JSON in a markdown fence despite being told not to.
19
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
20
+ _JSON_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
21
+
22
+
23
+ def json_schema_format(schema: Dict[str, Any], *, name: str = "response") -> Dict[str, Any]:
24
+ """Build a ``response_format`` value that constrains output to ``schema``.
25
+
26
+ The endpoint requires the schema nested under ``json_schema.schema``; passing
27
+ it anywhere else is ignored server-side without an error, so prefer this
28
+ helper over hand-building the dict.
29
+
30
+ Example:
31
+ >>> json_schema_format({"type": "object", "properties": {"city": {"type": "string"}}})
32
+ {'type': 'json_schema', 'json_schema': {'name': 'response', 'schema': {...}}}
33
+ """
34
+ if not isinstance(schema, dict):
35
+ raise ValidationError("schema must be a JSON Schema dict")
36
+ return {"type": "json_schema", "json_schema": {"name": name, "schema": schema}}
37
+
38
+
39
+ def _validate_response_format(response_format: Optional[Dict[str, Any]]) -> None:
40
+ """Reject shapes the endpoint would accept but silently not enforce."""
41
+ if response_format is None:
42
+ return
43
+ if not isinstance(response_format, dict):
44
+ raise ValidationError("response_format must be a dict")
45
+
46
+ fmt_type = response_format.get("type", "text")
47
+ if fmt_type not in RESPONSE_FORMAT_TYPES:
48
+ raise ValidationError(
49
+ f"response_format type must be one of {', '.join(RESPONSE_FORMAT_TYPES)}; got {fmt_type!r}"
50
+ )
51
+
52
+ if fmt_type == "json_schema":
53
+ json_schema = response_format.get("json_schema")
54
+ if not isinstance(json_schema, dict) or "schema" not in json_schema:
55
+ raise ValidationError(
56
+ 'response_format {"type": "json_schema"} requires the schema nested as '
57
+ '{"json_schema": {"schema": {...}}}. Without it the server returns 200 and '
58
+ "ignores the constraint. Use diffbot.json_schema_format(schema) to build it."
59
+ )
60
+
61
+
62
+ def _build_payload(
63
+ client: Any,
64
+ messages: List[Dict[str, str]],
65
+ *,
66
+ response_format: Optional[Dict[str, Any]] = None,
67
+ ) -> tuple:
68
+ _validate_response_format(response_format)
69
+ headers = {"Authorization": f"Bearer {client.token}"}
70
+ payload: Dict[str, Any] = {"model": MODEL, "messages": messages, "stream": True}
71
+ if response_format is not None:
72
+ payload["response_format"] = response_format
73
+ return headers, payload
74
+
75
+
76
+ def _parse_chunk(line: str):
77
+ try:
78
+ chunk = json.loads(line.replace("data: ", ""))
79
+ except json.JSONDecodeError:
80
+ return None
81
+ choices = chunk.get("choices")
82
+ if choices and choices[0].get("delta", {}).get("content"):
83
+ return choices[0]["delta"]["content"]
84
+ return None
85
+
86
+
87
+ def _extract_json(text: str) -> Any:
88
+ """Parse the model's final answer as JSON, tolerating think blocks and fences."""
89
+ cleaned = _THINK_BLOCK.sub("", text).strip()
90
+ cleaned = _JSON_FENCE.sub("", cleaned).strip()
91
+
92
+ try:
93
+ return json.loads(cleaned)
94
+ except json.JSONDecodeError:
95
+ pass
96
+
97
+ # Fall back to the outermost object or array span in the response.
98
+ spans = []
99
+ for opener, closer in (("{", "}"), ("[", "]")):
100
+ start, end = cleaned.find(opener), cleaned.rfind(closer)
101
+ if start != -1 and end > start:
102
+ spans.append((start, cleaned[start : end + 1]))
103
+ for _, span in sorted(spans):
104
+ try:
105
+ return json.loads(span)
106
+ except json.JSONDecodeError:
107
+ continue
108
+
109
+ raise ValidationError(f"could not parse JSON from the model response: {text[:200]!r}")
110
+
111
+
112
+ #: Schema used when the caller wants JSON but has no shape in mind. This goes
113
+ #: through the json_schema path rather than {"type": "json_object"} on purpose:
114
+ #: json_object applies no server-side grammar, so the RAG loop's internal
115
+ #: <functioncall> JSON satisfies it and gets returned as the final answer. That
116
+ #: is reproducible whenever the request carries a system message.
117
+ ANY_OBJECT_SCHEMA = {"type": "object"}
118
+
119
+
120
+ def _resolve_format(
121
+ schema: Optional[Dict[str, Any]],
122
+ response_format: Optional[Dict[str, Any]],
123
+ ) -> Dict[str, Any]:
124
+ if schema is not None and response_format is not None:
125
+ raise ValidationError("pass either schema or response_format, not both")
126
+ if response_format is not None:
127
+ return response_format
128
+ return json_schema_format(schema if schema is not None else ANY_OBJECT_SCHEMA)
129
+
130
+
131
+ def _check_tool_call_leak(parsed: Any) -> Any:
132
+ """Catch the internal tool call surfacing as the answer (see ANY_OBJECT_SCHEMA)."""
133
+ if isinstance(parsed, dict) and parsed.get("name") == "functioncall" and "arguments" in parsed:
134
+ raise ValidationError(
135
+ "the model returned its internal tool call instead of an answer; this happens with "
136
+ 'response_format {"type": "json_object"} because the server applies no grammar to it. '
137
+ "Pass a schema instead."
138
+ )
139
+ return parsed
140
+
141
+
142
+ def ask(
143
+ client: "Diffbot",
144
+ messages: List[Dict[str, str]],
145
+ *,
146
+ response_format: Optional[Dict[str, Any]] = None,
147
+ ) -> Iterator[str]:
148
+ headers, payload = _build_payload(client, messages, response_format=response_format)
149
+ with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response:
150
+ client._raise_for_status(response)
151
+ for line in response.iter_lines():
152
+ if line:
153
+ content = _parse_chunk(line)
154
+ if content:
155
+ yield content
156
+
157
+
158
+ async def ask_async(
159
+ client: "DiffbotAsync",
160
+ messages: List[Dict[str, str]],
161
+ *,
162
+ response_format: Optional[Dict[str, Any]] = None,
163
+ ) -> AsyncIterator[str]:
164
+ headers, payload = _build_payload(client, messages, response_format=response_format)
165
+ async with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response:
166
+ client._raise_for_status(response)
167
+ async for line in response.aiter_lines():
168
+ if line:
169
+ content = _parse_chunk(line)
170
+ if content:
171
+ yield content
172
+
173
+
174
+ def ask_json(
175
+ client: "Diffbot",
176
+ messages: List[Dict[str, str]],
177
+ schema: Optional[Dict[str, Any]] = None,
178
+ *,
179
+ response_format: Optional[Dict[str, Any]] = None,
180
+ ) -> Any:
181
+ fmt = _resolve_format(schema, response_format)
182
+ text = "".join(ask(client, messages, response_format=fmt))
183
+ return _check_tool_call_leak(_extract_json(text))
184
+
185
+
186
+ async def ask_json_async(
187
+ client: "DiffbotAsync",
188
+ messages: List[Dict[str, str]],
189
+ schema: Optional[Dict[str, Any]] = None,
190
+ *,
191
+ response_format: Optional[Dict[str, Any]] = None,
192
+ ) -> Any:
193
+ fmt = _resolve_format(schema, response_format)
194
+ chunks = [chunk async for chunk in ask_async(client, messages, response_format=fmt)]
195
+ return _check_tool_call_leak(_extract_json("".join(chunks)))