brave-api-client 0.0.4__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.
@@ -0,0 +1,50 @@
1
+ name: Build and test python application
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ pull_request:
7
+ branches: [ "main" ]
8
+
9
+ concurrency:
10
+ group: ${{ github.head_ref || github.run_id }}
11
+ cancel-in-progress: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ jobs:
17
+
18
+ lint:
19
+ name: Lint
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: extractions/setup-just@v3
24
+ - uses: astral-sh/setup-uv@v6
25
+ with:
26
+ cache-dependency-glob: "**/pyproject.toml"
27
+ - run: uv python install 3.10
28
+ - run: just install
29
+ - run: just lint
30
+
31
+ test:
32
+ name: Test
33
+ runs-on: ubuntu-latest
34
+ strategy:
35
+ fail-fast: false
36
+ matrix:
37
+ python-version:
38
+ - "3.10"
39
+ - "3.11"
40
+ - "3.12"
41
+ - "3.13"
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: extractions/setup-just@v3
45
+ - uses: astral-sh/setup-uv@v6
46
+ with:
47
+ cache-dependency-glob: "**/pyproject.toml"
48
+ - run: uv python install ${{ matrix.python-version }}
49
+ - run: just install
50
+ - run: just test . --cov=. --cov-report xml
@@ -0,0 +1,19 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ release:
5
+ types:
6
+ - published
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: extractions/setup-just@v2
14
+ - uses: astral-sh/setup-uv@v6
15
+ with:
16
+ cache-dependency-glob: "**/pyproject.toml"
17
+ - run: just publish
18
+ env:
19
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,174 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ .idea/
169
+
170
+ # Ruff stuff:
171
+ .ruff_cache/
172
+
173
+ # PyPI configuration file
174
+ .pypirc
@@ -0,0 +1,36 @@
1
+
2
+ repos:
3
+
4
+ - repo: https://github.com/codespell-project/codespell
5
+ rev: v2.3.0
6
+ hooks:
7
+ - id: codespell
8
+ additional_dependencies:
9
+ - tomli
10
+ - repo: https://github.com/astral-sh/ruff-pre-commit
11
+ rev: "v0.11.11"
12
+ hooks:
13
+ - id: ruff
14
+ name: ruff
15
+ files: "^brave_api|^tests|^examples|^docs"
16
+ args: [ --fix ]
17
+ - id: ruff-format
18
+ files: "^brave_api|^tests|^examples|^docs"
19
+ - repo: https://github.com/pre-commit/pre-commit-hooks
20
+ rev: v5.0.0
21
+ hooks:
22
+ - id: end-of-file-fixer
23
+ - id: mixed-line-ending
24
+ - repo: https://github.com/pre-commit/mirrors-mypy
25
+ rev: 'v1.15.0'
26
+ hooks:
27
+ - id: mypy
28
+ additional_dependencies: [pydantic]
29
+ - repo: local
30
+ hooks:
31
+ - id: tests
32
+ name: tests
33
+ entry: uv run pytest tests
34
+ language: system
35
+ types: [ python ]
36
+ pass_filenames: false
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: brave-api-client
3
+ Version: 0.0.4
4
+ Summary: Brave API client for Python
5
+ License-File: LICENSE
6
+ Classifier: Programming Language :: Python :: 3.10
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: <4,>=3.10
12
+ Requires-Dist: httpx>=0.28.1
13
+ Requires-Dist: pydantic>=2.11.5
14
+ Description-Content-Type: text/markdown
15
+
16
+ # brave-api
@@ -0,0 +1 @@
1
+ # brave-api
File without changes
@@ -0,0 +1,98 @@
1
+ import abc
2
+ import os
3
+ import typing
4
+ from types import TracebackType
5
+ from typing import Optional
6
+
7
+ from brave_api.constants import BRAVE_API_KEY_ENV_VAR
8
+ from brave_api.web_search.models import WebSearchApiResponse, WebSearchQueryParams
9
+ from contextlib import AbstractAsyncContextManager
10
+ import httpx
11
+
12
+
13
+ def _get_api_key_from_env() -> str | None:
14
+ return os.getenv(BRAVE_API_KEY_ENV_VAR)
15
+
16
+
17
+ class _BraveAPIClientBase(abc.ABC):
18
+ def __init__(
19
+ self,
20
+ base_url: str | None = None,
21
+ api_key: str | None = None,
22
+ proxy: str | None = None,
23
+ ):
24
+ self.base_url = base_url or "https://api.search.brave.com/res/v1"
25
+ self._provided_api_key = api_key
26
+ self._proxy = proxy
27
+
28
+ def _build_search_url(self) -> str:
29
+ return f"{self.base_url}/web/search"
30
+
31
+ def _build_search_params(
32
+ self, query_params: WebSearchQueryParams
33
+ ) -> tuple[str, dict[str, str], dict[str, typing.Any]]:
34
+ url = self._build_search_url()
35
+ api_key = self._provided_api_key or _get_api_key_from_env()
36
+ if not api_key:
37
+ raise ValueError(
38
+ "API key is required. Set it via environment variable BRAVE_API_KEY or pass it to the client."
39
+ )
40
+ headers = {
41
+ "X-Subscription-Token": api_key,
42
+ }
43
+ return url, headers, query_params.model_dump(exclude_unset=True)
44
+
45
+
46
+ class AsyncBraveAPIClient(
47
+ _BraveAPIClientBase, AbstractAsyncContextManager["AsyncBraveAPIClient"]
48
+ ):
49
+ def __init__(
50
+ self,
51
+ client: Optional[httpx.AsyncClient] = None,
52
+ *args: typing.Any,
53
+ **kwargs: typing.Any,
54
+ ):
55
+ super().__init__(*args, **kwargs)
56
+ self._client = client if client else httpx.AsyncClient()
57
+ self._transport: Optional[httpx.AsyncClient] = None
58
+
59
+ async def __aenter__(self) -> "AsyncBraveAPIClient":
60
+ self._transport = await self._client.__aenter__()
61
+ return self
62
+
63
+ async def __aexit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc_value: BaseException | None,
67
+ traceback: TracebackType | None,
68
+ /,
69
+ ) -> None:
70
+ self._transport = None
71
+ await self._client.aclose()
72
+
73
+ def _verify_transport(self) -> None:
74
+ if self._transport is None:
75
+ raise RuntimeError("Use async with `AsyncBraveAPIClient()`")
76
+
77
+ async def search(self, query: WebSearchQueryParams) -> WebSearchApiResponse:
78
+ self._verify_transport()
79
+ url, headers, query_params = self._build_search_params(query)
80
+ response = await self._transport.get( # type: ignore[union-attr]
81
+ url, headers=headers, params=query_params
82
+ )
83
+
84
+ response.raise_for_status()
85
+ return WebSearchApiResponse.model_validate(response.json())
86
+
87
+
88
+ class BraveAPIClient(_BraveAPIClientBase):
89
+ """
90
+ A client for interacting with the Brave API.
91
+ """
92
+
93
+ def search(self, query: WebSearchQueryParams) -> WebSearchApiResponse:
94
+ url, headers, query_params = self._build_search_params(query)
95
+ response = httpx.get(url=url, headers=headers, params=query_params)
96
+ response.raise_for_status()
97
+
98
+ return WebSearchApiResponse.model_validate(response.json())
@@ -0,0 +1,3 @@
1
+ from typing import Final
2
+
3
+ BRAVE_API_KEY_ENV_VAR: Final[str] = "BRAVE_API_KEY"
@@ -0,0 +1,692 @@
1
+ from typing import List, Optional, Union, Literal, Any
2
+ from pydantic import BaseModel, Field
3
+
4
+ # Base and utility models
5
+
6
+
7
+ class Thumbnail(BaseModel):
8
+ src: str
9
+ original: Optional[str] = None
10
+
11
+
12
+ class Profile(BaseModel):
13
+ name: str
14
+ long_name: str
15
+ url: Optional[str] = None
16
+ img: Optional[str] = None
17
+
18
+
19
+ class Rating(BaseModel):
20
+ ratingValue: float
21
+ bestRating: float
22
+ reviewCount: Optional[int] = None
23
+ profile: Optional[Profile] = None
24
+ is_tripadvisor: bool = True
25
+
26
+
27
+ class Thing(BaseModel):
28
+ type: str
29
+
30
+
31
+ class Person(BaseModel):
32
+ type: str
33
+ email: Optional[str] = None
34
+
35
+
36
+ class MetaUrl(BaseModel):
37
+ scheme: str
38
+ netloc: str
39
+ hostname: Optional[str] = None
40
+ favicon: str
41
+ path: str
42
+
43
+
44
+ class Contact(BaseModel):
45
+ email: Optional[str] = None
46
+ telephone: Optional[str] = None
47
+
48
+
49
+ class ContactPoint(Thing):
50
+ type: Literal["contact_point"] = "contact_point"
51
+ telephone: Optional[str] = None
52
+ email: Optional[str] = None
53
+
54
+
55
+ class DataProvider(BaseModel):
56
+ type: Literal["external"] = "external"
57
+ name: str
58
+ url: str
59
+ long_name: Optional[str] = None
60
+ img: Optional[str] = None
61
+
62
+
63
+ class Unit(BaseModel):
64
+ value: float
65
+ units: str
66
+
67
+
68
+ class Answer(BaseModel):
69
+ text: str
70
+ author: Optional[str] = None
71
+ upvoteCount: Optional[int] = None
72
+ downvoteCount: Optional[int] = None
73
+
74
+
75
+ class QAPage(BaseModel):
76
+ question: str
77
+ answer: Answer
78
+
79
+
80
+ class QA(BaseModel):
81
+ question: str
82
+ answer: str
83
+ title: str
84
+ url: str
85
+ meta_url: Optional[MetaUrl] = None
86
+
87
+
88
+ class FAQ(BaseModel):
89
+ type: Literal["faq"] = "faq"
90
+ results: List[QA]
91
+
92
+
93
+ class ForumData(BaseModel):
94
+ forum_name: str
95
+ num_answers: Optional[int] = None
96
+ score: Optional[str] = None
97
+ title: Optional[str] = None
98
+ question: Optional[str] = None
99
+ top_comment: Optional[str] = None
100
+
101
+
102
+ class DiscussionResult(BaseModel):
103
+ type: Literal["discussion"] = "discussion"
104
+ data: Optional[ForumData] = None
105
+
106
+
107
+ class Discussions(BaseModel):
108
+ type: Literal["search"] = "search"
109
+ results: List[DiscussionResult]
110
+ mutated_by_goggles: bool = False
111
+
112
+
113
+ class SearchResult(BaseModel):
114
+ type: Literal["search_result"] = "search_result"
115
+ subtype: str = "generic"
116
+ is_live: bool = False
117
+ deep_results: Optional["DeepResult"] = None
118
+ schemas: Optional[List[List[Any]]] = None
119
+ meta_url: Optional[MetaUrl] = None
120
+ thumbnail: Optional[Thumbnail] = None
121
+ age: Optional[str] = None
122
+ language: str
123
+ location: Optional["LocationResult"] = None
124
+ video: Optional["VideoData"] = None
125
+ movie: Optional["MovieData"] = None
126
+ faq: Optional[FAQ] = None
127
+ qa: Optional[QAPage] = None
128
+ book: Optional["Book"] = None
129
+ rating: Optional[Rating] = None
130
+ article: Optional["Article"] = None
131
+ product: Optional[Union["Product", "Review"]] = None
132
+ product_cluster: Optional[List[Union["Product", "Review"]]] = None
133
+ cluster_type: Optional[str] = None
134
+ cluster: Optional[List["Result"]] = None
135
+ creative_work: Optional["CreativeWork"] = None
136
+ music_recording: Optional["MusicRecording"] = None
137
+ review: Optional["Review"] = None
138
+ software: Optional["Software"] = None
139
+ recipe: Optional["Recipe"] = None
140
+ organization: Optional["Organization"] = None
141
+ content_type: Optional[str] = None
142
+ extra_snippets: Optional[List[str]] = None
143
+
144
+
145
+ class Result(BaseModel):
146
+ title: str
147
+ url: str
148
+ is_source_local: bool = True
149
+ is_source_both: bool = True
150
+ description: Optional[str] = None
151
+ page_age: Optional[str] = None
152
+ page_fetched: Optional[str] = None
153
+ profile: Optional[Profile] = None
154
+ language: Optional[str] = None
155
+ family_friendly: bool = True
156
+
157
+
158
+ class LocationWebResult(Result):
159
+ meta_url: MetaUrl
160
+
161
+
162
+ class LocationResult(Result):
163
+ type: Literal["location_result"] = "location_result"
164
+ id: Optional[str] = None
165
+ provider_url: str
166
+ coordinates: Optional[List[float]] = None
167
+ zoom_level: int
168
+ thumbnail: Optional[Thumbnail] = None
169
+ postal_address: Optional["PostalAddress"] = None
170
+ opening_hours: Optional["OpeningHours"] = None
171
+ contact: Optional[Contact] = None
172
+ price_range: Optional[str] = None
173
+ rating: Optional[Rating] = None
174
+ distance: Optional[Unit] = None
175
+ profiles: Optional[List[DataProvider]] = None
176
+ reviews: Optional["Reviews"] = None
177
+ pictures: Optional["PictureResults"] = None
178
+ action: Optional["Action"] = None
179
+ serves_cuisine: Optional[List[str]] = None
180
+ categories: Optional[List[str]] = None
181
+ icon_category: Optional[str] = None
182
+ results: Optional[LocationWebResult] = None
183
+ timezone: Optional[str] = None
184
+ timezone_offset: Optional[str] = None
185
+
186
+
187
+ class Locations(BaseModel):
188
+ type: Literal["locations"] = "locations"
189
+ results: List[LocationResult]
190
+
191
+
192
+ class Summarizer(BaseModel):
193
+ type: Literal["summarizer"] = "summarizer"
194
+ key: str
195
+
196
+
197
+ class RichCallbackHint(BaseModel):
198
+ vertical: str
199
+ callback_key: str
200
+
201
+
202
+ class RichCallbackInfo(BaseModel):
203
+ type: Literal["rich"] = "rich"
204
+ hint: Optional[RichCallbackHint] = None
205
+
206
+
207
+ class ResultReference(BaseModel):
208
+ type: str
209
+ index: Optional[int] = None
210
+ all: bool
211
+
212
+
213
+ class MixedResponse(BaseModel):
214
+ type: Literal["mixed"] = "mixed"
215
+ main: Optional[List[ResultReference]] = None
216
+ top: Optional[List[ResultReference]] = None
217
+ side: Optional[List[ResultReference]] = None
218
+
219
+
220
+ class LocalPoiSearchApiResponse(BaseModel):
221
+ type: Literal["local_pois"] = "local_pois"
222
+ results: Optional[List[LocationResult]] = None
223
+
224
+
225
+ class LocalDescriptionsSearchApiResponse(BaseModel):
226
+ type: Literal["local_descriptions"] = "local_descriptions"
227
+ results: Optional[List["LocationDescription"]] = None
228
+
229
+
230
+ class LocationDescription(BaseModel):
231
+ type: Literal["local_description"] = "local_description"
232
+ id: str
233
+ description: Optional[str] = None
234
+
235
+
236
+ class WebSearchApiResponse(BaseModel):
237
+ type: Literal["search"]
238
+ discussions: Optional[Discussions] = None
239
+ faq: Optional[FAQ] = None
240
+ infobox: Optional["GraphInfobox"] = None
241
+ locations: Optional[Locations] = None
242
+ mixed: Optional[MixedResponse] = None
243
+ news: Optional["News"] = None
244
+ query: Optional["Query"] = None
245
+ videos: Optional["Videos"] = None
246
+ web: Optional["Search"] = None
247
+ summarizer: Optional[Summarizer] = None
248
+ rich: Optional[RichCallbackInfo] = None
249
+
250
+
251
+ class Search(BaseModel):
252
+ type: Literal["search"] = "search"
253
+ results: List[SearchResult]
254
+ family_friendly: bool
255
+
256
+
257
+ class Query(BaseModel):
258
+ original: str
259
+ show_strict_warning: Optional[bool] = None
260
+ altered: Optional[str] = None
261
+ safesearch: Optional[bool] = None
262
+ is_navigational: Optional[bool] = None
263
+ is_geolocal: Optional[bool] = None
264
+ local_decision: Optional[str] = None
265
+ local_locations_idx: Optional[int] = None
266
+ is_trending: Optional[bool] = None
267
+ is_news_breaking: Optional[bool] = None
268
+ ask_for_location: Optional[bool] = None
269
+ language: Optional["Language"] = None
270
+ spellcheck_off: Optional[bool] = None
271
+ country: Optional[str] = None
272
+ bad_results: Optional[bool] = None
273
+ should_fallback: Optional[bool] = None
274
+ lat: Optional[str] = None
275
+ long: Optional[str] = None
276
+ postal_code: Optional[str] = None
277
+ city: Optional[str] = None
278
+ state: Optional[str] = None
279
+ header_country: Optional[str] = None
280
+ more_results_available: Optional[bool] = None
281
+ custom_location_label: Optional[str] = None
282
+ reddit_cluster: Optional[str] = None
283
+
284
+
285
+ class Language(BaseModel):
286
+ main: str
287
+
288
+
289
+ class NewsResult(Result):
290
+ meta_url: Optional[MetaUrl] = None
291
+ source: Optional[str] = None
292
+ breaking: bool
293
+ is_live: bool
294
+ thumbnail: Optional[Thumbnail] = None
295
+ age: Optional[str] = None
296
+ extra_snippets: Optional[List[str]] = None
297
+
298
+
299
+ class News(BaseModel):
300
+ type: Literal["news"] = "news"
301
+ results: List[NewsResult]
302
+ mutated_by_goggles: bool = False
303
+
304
+
305
+ class VideoData(BaseModel):
306
+ duration: Optional[str] = None
307
+ views: Optional[str] = None
308
+ creator: Optional[str] = None
309
+ publisher: Optional[str] = None
310
+ thumbnail: Optional[Thumbnail] = None
311
+ tags: Optional[List[str]] = None
312
+ author: Optional[Profile] = None
313
+ requires_subscription: Optional[bool] = None
314
+
315
+
316
+ class VideoResult(Result):
317
+ type: Literal["video_result"] = "video_result"
318
+ video: VideoData
319
+ meta_url: Optional[MetaUrl] = None
320
+ thumbnail: Optional[Thumbnail] = None
321
+ age: Optional[str] = None
322
+
323
+
324
+ class Videos(BaseModel):
325
+ type: Literal["videos"] = "videos"
326
+ results: List[VideoResult]
327
+ mutated_by_goggles: Optional[bool] = False
328
+
329
+
330
+ class DeepResult(BaseModel):
331
+ news: Optional[List[NewsResult]] = None
332
+ buttons: Optional[List["ButtonResult"]] = None
333
+ videos: Optional[List[VideoResult]] = None
334
+ images: Optional[List["Image"]] = None
335
+
336
+
337
+ class ButtonResult(BaseModel):
338
+ type: Literal["button_result"] = "button_result"
339
+ title: str
340
+ url: str
341
+
342
+
343
+ class ImageProperties(BaseModel):
344
+ url: str
345
+ resized: str
346
+ placeholder: str
347
+ height: Optional[int] = None
348
+ width: Optional[int] = None
349
+ format: Optional[str] = None
350
+ content_size: Optional[str] = None
351
+
352
+
353
+ class Image(BaseModel):
354
+ thumbnail: Thumbnail
355
+ url: Optional[str] = None
356
+ properties: Optional[ImageProperties] = None
357
+
358
+
359
+ class Review(BaseModel):
360
+ type: Literal["review"] = "review"
361
+ name: str
362
+ thumbnail: Thumbnail
363
+ description: str
364
+ rating: Rating
365
+
366
+
367
+ class Product(BaseModel):
368
+ type: Literal["product"] = "product"
369
+ name: str
370
+ category: Optional[str] = None
371
+ price: str
372
+ thumbnail: Thumbnail
373
+ description: Optional[str] = None
374
+ offers: Optional[List["Offer"]] = None
375
+ rating: Optional[Rating] = None
376
+
377
+
378
+ class Offer(BaseModel):
379
+ url: str
380
+ priceCurrency: str
381
+ price: str
382
+
383
+
384
+ class Book(BaseModel):
385
+ title: str
386
+ author: List[Person]
387
+ date: Optional[str] = None
388
+ price: Optional["Price"] = None
389
+ pages: Optional[int] = None
390
+ publisher: Optional[Person] = None
391
+ rating: Optional[Rating] = None
392
+
393
+
394
+ class Price(BaseModel):
395
+ price: str
396
+ price_currency: str
397
+
398
+
399
+ class Article(BaseModel):
400
+ author: Optional[List[Person]] = None
401
+ date: Optional[str] = None
402
+ publisher: Optional["Organization"] = None
403
+ thumbnail: Optional[Thumbnail] = None
404
+ isAccessibleForFree: Optional[bool] = None
405
+
406
+
407
+ class Organization(BaseModel):
408
+ type: Literal["organization"] = "organization"
409
+ contact_points: Optional[List[ContactPoint]] = None
410
+
411
+
412
+ class Software(BaseModel):
413
+ name: Optional[str] = None
414
+ author: Optional[str] = None
415
+ version: Optional[str] = None
416
+ codeRepository: Optional[str] = None
417
+ homepage: Optional[str] = None
418
+ datePublisher: Optional[str] = None
419
+ is_npm: Optional[bool] = None
420
+ is_pypi: Optional[bool] = None
421
+ stars: Optional[int] = None
422
+ forks: Optional[int] = None
423
+ ProgrammingLanguage: Optional[str] = None
424
+
425
+
426
+ class MusicRecording(BaseModel):
427
+ name: str
428
+ thumbnail: Optional[Thumbnail] = None
429
+ rating: Optional[Rating] = None
430
+
431
+
432
+ class MovieData(BaseModel):
433
+ name: Optional[str] = None
434
+ description: Optional[str] = None
435
+ url: Optional[str] = None
436
+ thumbnail: Optional[Thumbnail] = None
437
+ release: Optional[str] = None
438
+ directors: Optional[List[Person]] = None
439
+ actors: Optional[List[Person]] = None
440
+ rating: Optional[Rating] = None
441
+ duration: Optional[str] = None
442
+ genre: Optional[List[str]] = None
443
+ query: Optional[str] = None
444
+
445
+
446
+ class PostalAddress(BaseModel):
447
+ type: Literal["PostalAddress"] = "PostalAddress"
448
+ country: Optional[str] = None
449
+ postalCode: Optional[str] = None
450
+ streetAddress: Optional[str] = None
451
+ addressRegion: Optional[str] = None
452
+ addressLocality: Optional[str] = None
453
+ displayAddress: str
454
+
455
+
456
+ class DayOpeningHours(BaseModel):
457
+ abbr_name: str
458
+ full_name: str
459
+ opens: str
460
+ closes: str
461
+
462
+
463
+ class OpeningHours(BaseModel):
464
+ current_day: Optional[List[DayOpeningHours]] = None
465
+ days: Optional[List[List[DayOpeningHours]]] = None
466
+
467
+
468
+ class Reviews(BaseModel):
469
+ results: List["TripAdvisorReview"] # TripAdvisorReview
470
+ viewMoreUrl: str
471
+ reviews_in_foreign_language: bool
472
+
473
+
474
+ class TripAdvisorReview(BaseModel):
475
+ title: str
476
+ description: str
477
+ date: str
478
+ rating: Rating
479
+ author: Person
480
+ review_url: str
481
+ language: str
482
+
483
+
484
+ class Action(BaseModel):
485
+ type: str
486
+ url: str
487
+
488
+
489
+ # Infobox / Graph section
490
+
491
+
492
+ class AbstractGraphInfobox(Result):
493
+ type: str = "infobox"
494
+ position: int
495
+ label: Optional[str] = None
496
+ category: Optional[str] = None
497
+ long_desc: Optional[str] = None
498
+ thumbnail: Optional[Thumbnail] = None
499
+ attributes: Optional[List[List[str]]] = None
500
+ profiles: Optional[Union[List[Profile], List[DataProvider]]] = None
501
+ website_url: Optional[str] = None
502
+ ratings: Optional[List[Rating]] = None
503
+ providers: Optional[List[DataProvider]] = None
504
+ distance: Optional[Unit] = None
505
+ images: Optional[List[Thumbnail]] = None
506
+ movie: Optional[MovieData] = None
507
+
508
+
509
+ class GenericInfobox(AbstractGraphInfobox):
510
+ subtype: str = "generic"
511
+ found_in_urls: Optional[List[str]] = None
512
+
513
+
514
+ class EntityInfobox(AbstractGraphInfobox):
515
+ subtype: Literal["entity"] = "entity"
516
+
517
+
518
+ class QAInfobox(AbstractGraphInfobox):
519
+ subtype: Literal["code"] = "code"
520
+ data: QAPage
521
+ meta_url: Optional[MetaUrl] = None
522
+
523
+
524
+ class InfoboxWithLocation(AbstractGraphInfobox):
525
+ subtype: Literal["location"] = "location"
526
+ is_location: bool
527
+ coordinates: Optional[List[float]] = None
528
+ zoom_level: int
529
+ location: Optional[LocationResult] = None
530
+
531
+
532
+ class InfoboxPlace(AbstractGraphInfobox):
533
+ subtype: Literal["place"] = "place"
534
+ location: LocationResult
535
+
536
+
537
+ class GraphInfobox(BaseModel):
538
+ type: Literal["graph"] = "graph"
539
+ results: List[
540
+ Union[
541
+ GenericInfobox,
542
+ QAInfobox,
543
+ InfoboxPlace,
544
+ InfoboxWithLocation,
545
+ EntityInfobox,
546
+ ]
547
+ ]
548
+
549
+
550
+ class PictureResults(BaseModel):
551
+ viewMoreUrl: Optional[str] = None
552
+ results: List[Thumbnail]
553
+
554
+
555
+ class Recipe(BaseModel):
556
+ title: str
557
+ description: str
558
+ thumbnail: Thumbnail
559
+ url: str
560
+ domain: str
561
+ favicon: str
562
+ time: Optional[str] = None
563
+ prep_time: Optional[str] = None
564
+ cook_time: Optional[str] = None
565
+ ingredients: Optional[str] = None
566
+ instructions: Optional[List["HowTo"]] = None
567
+ servings: Optional[int] = None
568
+ calories: Optional[int] = None
569
+ rating: Optional[Rating] = None
570
+ recipeCategory: Optional[str] = None
571
+ recipeCuisine: Optional[str] = None
572
+ video: Optional[VideoData] = None
573
+
574
+
575
+ class HowTo(BaseModel):
576
+ text: str
577
+ name: Optional[str] = None
578
+ url: Optional[str] = None
579
+ image: Optional[List[str]] = None
580
+
581
+
582
+ class CreativeWork(BaseModel):
583
+ name: str
584
+ thumbnail: Thumbnail
585
+ rating: Optional[Rating] = None
586
+
587
+
588
+ # Resolve forward references for all relevant models
589
+ SearchResult.model_rebuild()
590
+ DeepResult.model_rebuild()
591
+ LocationResult.model_rebuild()
592
+ Product.model_rebuild()
593
+ Book.model_rebuild()
594
+ Article.model_rebuild()
595
+ Reviews.model_rebuild()
596
+ TripAdvisorReview.model_rebuild()
597
+ WebSearchApiResponse.model_rebuild()
598
+ LocalDescriptionsSearchApiResponse.model_rebuild()
599
+ Recipe.model_rebuild()
600
+ HowTo.model_rebuild()
601
+ CreativeWork.model_rebuild()
602
+
603
+
604
+ class WebSearchQueryParams(BaseModel):
605
+ q: str = Field(
606
+ ...,
607
+ description="The user’s search query term. Can not be empty. Max 400 chars and 50 words.",
608
+ )
609
+ country: Optional[str] = Field(
610
+ "US",
611
+ max_length=2,
612
+ description="2 character country code. See country codes list.",
613
+ )
614
+ search_lang: Optional[str] = Field(
615
+ "en",
616
+ min_length=2,
617
+ description="2+ character language code. See language codes list.",
618
+ )
619
+ ui_lang: Optional[str] = Field(
620
+ "en-US",
621
+ description="Format <language_code>-<country_code> (see RFC9110 and UI language codes list).",
622
+ )
623
+ count: Optional[int] = Field(
624
+ 20, ge=1, le=20, description="Number of search results to return (max 20)."
625
+ )
626
+ offset: Optional[int] = Field(
627
+ 0, ge=0, le=9, description="Zero-based page offset (max 9)."
628
+ )
629
+ safesearch: Optional[str] = Field(
630
+ "moderate",
631
+ pattern="^(off|moderate|strict)$",
632
+ description="Adult content filter: off, moderate, strict.",
633
+ )
634
+ freshness: Optional[str] = Field(
635
+ None,
636
+ description=(
637
+ "Limits discovery to a time window. One of: pd (24h), pw (7d), pm (31d), py (365d), "
638
+ "or range YYYY-MM-DDtoYYYY-MM-DD."
639
+ ),
640
+ )
641
+ text_decorations: Optional[bool] = Field(
642
+ True,
643
+ description="Whether display strings include decoration markers (highlighting).",
644
+ )
645
+ spellcheck: Optional[bool] = Field(True, description="Spellcheck provided query.")
646
+ result_filter: Optional[str] = Field(
647
+ None,
648
+ description=(
649
+ "Comma delimited result types to include. E.g. discussions,faq,news,web,infobox,query,summarizer,videos,locations."
650
+ ),
651
+ )
652
+ goggles_id: Optional[str] = Field(
653
+ None,
654
+ description="(Deprecated) Goggle for custom re-ranking (use `goggles` instead).",
655
+ )
656
+ goggles: Optional[List[str]] = Field(
657
+ None, description="List of goggle URLs/definitions for custom re-ranking."
658
+ )
659
+ units: Optional[str] = Field(
660
+ None,
661
+ pattern="^(metric|imperial)$",
662
+ description="Measurement units: metric, imperial.",
663
+ )
664
+ extra_snippets: Optional[bool] = Field(
665
+ None, description="Get up to 5 additional, alternative result snippets."
666
+ )
667
+ summary: Optional[bool] = Field(
668
+ None, description="Enable summary key generation in web search results."
669
+ )
670
+
671
+
672
+ class LocalSearchQueryParams(BaseModel):
673
+ ids: List[str] = Field(
674
+ ...,
675
+ min_length=1,
676
+ max_length=20,
677
+ description="List of 1 to 20 non-empty unique location IDs.",
678
+ )
679
+ search_lang: Optional[str] = Field(
680
+ "en",
681
+ min_length=2,
682
+ description="2+ character language code. See language codes list.",
683
+ )
684
+ ui_lang: Optional[str] = Field(
685
+ "en-US",
686
+ description="Format <language_code>-<country_code> (see RFC9110 and UI language codes list).",
687
+ )
688
+ units: Optional[str] = Field(
689
+ None,
690
+ pattern="^(metric|imperial)$",
691
+ description="Measurement units: metric, imperial.",
692
+ )
@@ -0,0 +1,33 @@
1
+ default: install lint test
2
+
3
+ install:
4
+ uv lock --upgrade
5
+ uv sync --all-extras --frozen
6
+ @just hook
7
+
8
+ lint:
9
+ uv run pre-commit run ruff --all-files
10
+ uv run pre-commit run ruff-format --all-files
11
+ uv run pre-commit run mypy --all-files
12
+ uv run pre-commit run end-of-file-fixer --all-files
13
+ uv run pre-commit run mixed-line-ending --all-files
14
+ uv run pre-commit run codespell --all-files
15
+
16
+
17
+ test *args:
18
+ uv run --no-sync pytest {{ args }}
19
+
20
+ publish:
21
+ rm -rf dist
22
+ uv build
23
+ uv publish --token $PYPI_TOKEN
24
+
25
+ hook:
26
+ uv run pre-commit install --install-hooks --overwrite
27
+
28
+ unhook:
29
+ uv run pre-commit uninstall
30
+
31
+ docs:
32
+ uv pip install -r docs/requirements.txt
33
+ uv run mkdocs serve
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "brave-api-client"
3
+ description = "Brave API client for Python"
4
+ readme = "README.md"
5
+ requires-python = ">=3.10,<4"
6
+ dependencies = [
7
+ "httpx>=0.28.1",
8
+ "pydantic>=2.11.5",
9
+ ]
10
+ dynamic = ["version"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3.10",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Typing :: Typed",
17
+ ]
18
+
19
+ packages = [
20
+ { include = "brave_api" },
21
+ ]
22
+
23
+
24
+ [build-system]
25
+ requires = ["hatchling", "hatch-vcs"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.mypy]
29
+ plugins = ["pydantic.mypy"]
30
+ python_version = "3.10"
31
+ strict = true
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["brave_api"]
38
+
39
+ [dependency-groups]
40
+ dev = [
41
+ "mkdocs>=1.6.1",
42
+ "pre-commit>=4.2.0",
43
+ "pytest>=8.3.5",
44
+ "pytest-asyncio>=0.26.0",
45
+ "pytest-cov>=6.1.1",
46
+ ]
File without changes
File without changes
@@ -0,0 +1,9 @@
1
+ import pytest
2
+
3
+ from brave_api.web_search.models import WebSearchApiResponse
4
+ from pydantic import ValidationError
5
+
6
+
7
+ def test_search_response_does_not_validate() -> None:
8
+ with pytest.raises(ValidationError):
9
+ WebSearchApiResponse() # type: ignore[call-arg]