bsqb 0.1.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.
- bsqb-0.1.0/.gitignore +15 -0
- bsqb-0.1.0/LICENSE +21 -0
- bsqb-0.1.0/PKG-INFO +332 -0
- bsqb-0.1.0/README.md +300 -0
- bsqb-0.1.0/pyproject.toml +78 -0
- bsqb-0.1.0/src/bsqb/__init__.py +53 -0
- bsqb-0.1.0/src/bsqb/builder.py +274 -0
- bsqb-0.1.0/src/bsqb/exceptions.py +25 -0
- bsqb-0.1.0/src/bsqb/nodes.py +150 -0
- bsqb-0.1.0/src/bsqb/operators.py +33 -0
- bsqb-0.1.0/src/bsqb/py.typed +0 -0
- bsqb-0.1.0/src/bsqb/validation.py +47 -0
bsqb-0.1.0/.gitignore
ADDED
bsqb-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bsqb contributors
|
|
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.
|
bsqb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bsqb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Type-safe query builder for Brave Search operators
|
|
5
|
+
Project-URL: Homepage, https://github.com/kodzghly/bsqb
|
|
6
|
+
Project-URL: Documentation, https://github.com/kodzghly/bsqb#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/kodzghly/bsqb
|
|
8
|
+
Project-URL: Issues, https://github.com/kodzghly/bsqb/issues
|
|
9
|
+
Author: bsqb contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: brave,brave-search,query-builder,search,web-search
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# bsqb
|
|
34
|
+
|
|
35
|
+
[](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/bsqb/)
|
|
37
|
+
[](https://pypi.org/project/bsqb/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
**bsqb** (Brave Search Query Builder) is a type-safe, zero-dependency Python library for constructing [Brave Search](https://search.brave.com) query strings using the official search operators.
|
|
41
|
+
|
|
42
|
+
It helps you build valid `q` parameters for the [Brave Search API](https://api-dashboard.search.brave.com/app/documentation/web-search) with a fluent API, automatic quoting, and validation against API limits.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Complete operator coverage** — All operators from the [official Brave Search documentation](https://api-dashboard.search.brave.com/documentation/resources/search-operators)
|
|
47
|
+
- **Fluent API** — Chain methods for readable, composable queries
|
|
48
|
+
- **Type-safe** — Full type hints and `py.typed` marker for mypy/Pylance
|
|
49
|
+
- **Zero dependencies** — Lightweight core with no runtime requirements
|
|
50
|
+
- **API limit validation** — Enforces the 400 character / 50 word limits on `q`
|
|
51
|
+
- **Logical operators** — `AND`, `OR`, `NOT` with Python operators (`&`, `|`, `~`)
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install bsqb
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from bsqb import Query
|
|
63
|
+
|
|
64
|
+
# Basic query with field operators
|
|
65
|
+
query = Query("machine learning").filetype("pdf").lang("en")
|
|
66
|
+
print(query.build())
|
|
67
|
+
# machine learning filetype:pdf lang:en
|
|
68
|
+
|
|
69
|
+
# Use with the Brave Search API
|
|
70
|
+
import urllib.parse
|
|
71
|
+
import urllib.request
|
|
72
|
+
|
|
73
|
+
params = urllib.parse.urlencode({"q": query.build()})
|
|
74
|
+
url = f"https://api.search.brave.com/res/v1/web/search?{params}"
|
|
75
|
+
request = urllib.request.Request(
|
|
76
|
+
url,
|
|
77
|
+
headers={"X-Subscription-Token": "YOUR_API_KEY"},
|
|
78
|
+
)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Supported operators
|
|
82
|
+
|
|
83
|
+
| Operator | Method | Example output |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| Plain term | `Query("term")` | `term` |
|
|
86
|
+
| Exact phrase | `.phrase("exact phrase")` | `"exact phrase"` |
|
|
87
|
+
| Force include | `.include("term")` | `+term` |
|
|
88
|
+
| Exclude | `.exclude("term")` | `-term` |
|
|
89
|
+
| File extension | `.ext("pdf")` | `ext:pdf` |
|
|
90
|
+
| File type | `.filetype("pdf")` | `filetype:pdf` |
|
|
91
|
+
| In title | `.intitle("2023")` | `intitle:2023` |
|
|
92
|
+
| In body | `.inbody("keyword")` | `inbody:keyword` |
|
|
93
|
+
| In page | `.inpage("keyword")` | `inpage:keyword` |
|
|
94
|
+
| Language (ISO 639-1) | `.lang("es")` | `lang:es` |
|
|
95
|
+
| Language alias | `.language("es")` | `language:es` |
|
|
96
|
+
| Location (ISO 3166-1) | `.loc("gb")` | `loc:gb` |
|
|
97
|
+
| Location alias | `.location("gb")` | `location:gb` |
|
|
98
|
+
| Site / domain | `.site("example.com")` | `site:example.com` |
|
|
99
|
+
| Logical AND | `.and_(other)` or `&` | `term1 AND term2` |
|
|
100
|
+
| Logical OR | `.or_(other)` or `\|` | `term1 OR term2` |
|
|
101
|
+
| Logical NOT | `.not_(other)` or `~` | `NOT term` |
|
|
102
|
+
|
|
103
|
+
Operators can be placed anywhere in the query string, matching Brave Search behavior.
|
|
104
|
+
|
|
105
|
+
## Examples
|
|
106
|
+
|
|
107
|
+
### Official documentation examples
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from bsqb import Query
|
|
111
|
+
|
|
112
|
+
# Academic research
|
|
113
|
+
Query("climate change").filetype("pdf").site("edu").intitle("2024").build()
|
|
114
|
+
# climate change filetype:pdf site:edu intitle:2024
|
|
115
|
+
|
|
116
|
+
# Multilingual content
|
|
117
|
+
Query("recettes cuisine").loc("ca").lang("fr").build()
|
|
118
|
+
# recettes cuisine loc:ca lang:fr
|
|
119
|
+
|
|
120
|
+
# Competitive analysis
|
|
121
|
+
(
|
|
122
|
+
Query("AI startup")
|
|
123
|
+
.exclude("google")
|
|
124
|
+
.exclude("microsoft")
|
|
125
|
+
.exclude("amazon")
|
|
126
|
+
.exclude("meta")
|
|
127
|
+
.build()
|
|
128
|
+
)
|
|
129
|
+
# AI startup -google -microsoft -amazon -meta
|
|
130
|
+
|
|
131
|
+
# Technical documentation
|
|
132
|
+
(
|
|
133
|
+
Query("python")
|
|
134
|
+
.phrase("asyncio")
|
|
135
|
+
.intitle("documentation")
|
|
136
|
+
.site("docs.python.org")
|
|
137
|
+
.build()
|
|
138
|
+
)
|
|
139
|
+
# python "asyncio" intitle:documentation site:docs.python.org
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Logical operators
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from bsqb import Query, combine_and, combine_or
|
|
146
|
+
|
|
147
|
+
# AND — visa info in English from UK sites
|
|
148
|
+
Query("visa").loc("gb").and_(Query().lang("en")).build()
|
|
149
|
+
# visa loc:gb AND lang:en
|
|
150
|
+
|
|
151
|
+
# OR — travel requirements for Australia or New Zealand
|
|
152
|
+
(
|
|
153
|
+
Query("travel requirements")
|
|
154
|
+
.inpage("australia")
|
|
155
|
+
.or_(Query().inpage("new zealand"))
|
|
156
|
+
.build()
|
|
157
|
+
)
|
|
158
|
+
# travel requirements inpage:australia OR inpage:"new zealand"
|
|
159
|
+
|
|
160
|
+
# NOT — exclude a domain
|
|
161
|
+
Query("brave search").not_(Query().site("brave.com")).build()
|
|
162
|
+
# brave search NOT site:brave.com
|
|
163
|
+
|
|
164
|
+
# Python operators
|
|
165
|
+
(Query("coffee") | Query("tea")).exclude("starbucks").build()
|
|
166
|
+
# coffee OR tea -starbucks
|
|
167
|
+
|
|
168
|
+
# Combine multiple queries
|
|
169
|
+
combine_and(Query("visa").loc("gb"), Query().lang("en")).build()
|
|
170
|
+
combine_or(Query().site("reuters.com"), Query().site("bloomberg.com")).build()
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Advanced usage
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
from bsqb import Query, phrase, raw, term
|
|
177
|
+
|
|
178
|
+
# Build from AST nodes
|
|
179
|
+
Query.from_nodes(term("python"), phrase("asyncio"), raw("site:docs.python.org"))
|
|
180
|
+
|
|
181
|
+
# Wrap an existing query string
|
|
182
|
+
Query.parse("machine learning filetype:pdf lang:en").build()
|
|
183
|
+
|
|
184
|
+
# Skip validation for edge cases
|
|
185
|
+
Query.parse("...").build(validate=False)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Validation
|
|
189
|
+
|
|
190
|
+
The Brave Search API enforces these limits on the `q` parameter:
|
|
191
|
+
|
|
192
|
+
- Maximum **400 characters**
|
|
193
|
+
- Maximum **50 words**
|
|
194
|
+
- Query cannot be empty
|
|
195
|
+
|
|
196
|
+
Call `.build()` to validate (default), or `.render()` / `str()` to get the string without validation:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from bsqb import Query, QueryValidationError, EmptyQueryError
|
|
200
|
+
|
|
201
|
+
query = Query("hello world")
|
|
202
|
+
|
|
203
|
+
query.render() # "hello world" — no validation
|
|
204
|
+
query.build() # "hello world" — validates limits
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
Query().build()
|
|
208
|
+
except EmptyQueryError:
|
|
209
|
+
...
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
Query.parse(" ".join(["word"] * 51)).build()
|
|
213
|
+
except QueryValidationError as exc:
|
|
214
|
+
print(exc.query)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Integration with Brave Search API
|
|
218
|
+
|
|
219
|
+
Search operators are included in the `q` parameter. Set `operators=true` (the default) in API requests:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
import urllib.parse
|
|
223
|
+
import urllib.request
|
|
224
|
+
|
|
225
|
+
from bsqb import Query
|
|
226
|
+
|
|
227
|
+
query = Query("python").phrase("asyncio").filetype("pdf").lang("en")
|
|
228
|
+
|
|
229
|
+
params = {
|
|
230
|
+
"q": query.build(),
|
|
231
|
+
"count": "10",
|
|
232
|
+
"operators": "true",
|
|
233
|
+
}
|
|
234
|
+
url = "https://api.search.brave.com/res/v1/web/search?" + urllib.parse.urlencode(params)
|
|
235
|
+
|
|
236
|
+
request = urllib.request.Request(
|
|
237
|
+
url,
|
|
238
|
+
headers={
|
|
239
|
+
"Accept": "application/json",
|
|
240
|
+
"X-Subscription-Token": "YOUR_API_KEY",
|
|
241
|
+
},
|
|
242
|
+
)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
For POST requests with long queries, pass the built string as the `q` field in the request body.
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
git clone https://github.com/kodzghly/bsqb.git
|
|
251
|
+
cd bsqb
|
|
252
|
+
python -m pip install -e ".[dev]"
|
|
253
|
+
|
|
254
|
+
# Run tests
|
|
255
|
+
pytest
|
|
256
|
+
|
|
257
|
+
# Lint and type check
|
|
258
|
+
ruff check src tests
|
|
259
|
+
mypy src
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Publishing to PyPI
|
|
263
|
+
|
|
264
|
+
This package uses [Hatchling](https://hatch.pypa.io/) as the build backend.
|
|
265
|
+
|
|
266
|
+
### First-time setup
|
|
267
|
+
|
|
268
|
+
1. Create accounts on [PyPI](https://pypi.org/account/register/) and [TestPyPI](https://test.pypi.org/account/register/)
|
|
269
|
+
2. Enable [2FA](https://pypi.org/help/#twofa) on both accounts
|
|
270
|
+
3. Create an API token at [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
|
|
271
|
+
|
|
272
|
+
### Test on TestPyPI
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
python -m pip install --upgrade build twine
|
|
276
|
+
|
|
277
|
+
# Bump version in pyproject.toml and src/bsqb/__init__.py
|
|
278
|
+
python -m build
|
|
279
|
+
|
|
280
|
+
# Upload to TestPyPI
|
|
281
|
+
twine upload --repository testpypi dist/*
|
|
282
|
+
|
|
283
|
+
# Verify installation
|
|
284
|
+
pip install --index-url https://test.pypi.org/simple/ bsqb
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Publish to PyPI
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
python -m build
|
|
291
|
+
twine check dist/*
|
|
292
|
+
twine upload dist/*
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Recommended: publish via GitHub Actions
|
|
296
|
+
|
|
297
|
+
Add trusted publishing on PyPI for your repository, then create a release workflow triggered by git tags:
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
# .github/workflows/publish.yml
|
|
301
|
+
name: Publish
|
|
302
|
+
|
|
303
|
+
on:
|
|
304
|
+
release:
|
|
305
|
+
types: [published]
|
|
306
|
+
|
|
307
|
+
jobs:
|
|
308
|
+
publish:
|
|
309
|
+
runs-on: ubuntu-latest
|
|
310
|
+
permissions:
|
|
311
|
+
id-token: write
|
|
312
|
+
steps:
|
|
313
|
+
- uses: actions/checkout@v4
|
|
314
|
+
- uses: actions/setup-python@v5
|
|
315
|
+
with:
|
|
316
|
+
python-version: "3.12"
|
|
317
|
+
- run: pip install build
|
|
318
|
+
- run: python -m build
|
|
319
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Create a GitHub release with tag `v0.1.0` to trigger publication.
|
|
323
|
+
|
|
324
|
+
## References
|
|
325
|
+
|
|
326
|
+
- [Brave Search Operators (API docs)](https://api-dashboard.search.brave.com/documentation/resources/search-operators)
|
|
327
|
+
- [Brave Search Operators (help page)](https://search.brave.com/help/operators)
|
|
328
|
+
- [Brave Web Search API](https://api-dashboard.search.brave.com/app/documentation/web-search)
|
|
329
|
+
|
|
330
|
+
## License
|
|
331
|
+
|
|
332
|
+
MIT — see [LICENSE](LICENSE).
|
bsqb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# bsqb
|
|
2
|
+
|
|
3
|
+
[](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/bsqb/)
|
|
5
|
+
[](https://pypi.org/project/bsqb/)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
**bsqb** (Brave Search Query Builder) is a type-safe, zero-dependency Python library for constructing [Brave Search](https://search.brave.com) query strings using the official search operators.
|
|
9
|
+
|
|
10
|
+
It helps you build valid `q` parameters for the [Brave Search API](https://api-dashboard.search.brave.com/app/documentation/web-search) with a fluent API, automatic quoting, and validation against API limits.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **Complete operator coverage** — All operators from the [official Brave Search documentation](https://api-dashboard.search.brave.com/documentation/resources/search-operators)
|
|
15
|
+
- **Fluent API** — Chain methods for readable, composable queries
|
|
16
|
+
- **Type-safe** — Full type hints and `py.typed` marker for mypy/Pylance
|
|
17
|
+
- **Zero dependencies** — Lightweight core with no runtime requirements
|
|
18
|
+
- **API limit validation** — Enforces the 400 character / 50 word limits on `q`
|
|
19
|
+
- **Logical operators** — `AND`, `OR`, `NOT` with Python operators (`&`, `|`, `~`)
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install bsqb
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from bsqb import Query
|
|
31
|
+
|
|
32
|
+
# Basic query with field operators
|
|
33
|
+
query = Query("machine learning").filetype("pdf").lang("en")
|
|
34
|
+
print(query.build())
|
|
35
|
+
# machine learning filetype:pdf lang:en
|
|
36
|
+
|
|
37
|
+
# Use with the Brave Search API
|
|
38
|
+
import urllib.parse
|
|
39
|
+
import urllib.request
|
|
40
|
+
|
|
41
|
+
params = urllib.parse.urlencode({"q": query.build()})
|
|
42
|
+
url = f"https://api.search.brave.com/res/v1/web/search?{params}"
|
|
43
|
+
request = urllib.request.Request(
|
|
44
|
+
url,
|
|
45
|
+
headers={"X-Subscription-Token": "YOUR_API_KEY"},
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Supported operators
|
|
50
|
+
|
|
51
|
+
| Operator | Method | Example output |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| Plain term | `Query("term")` | `term` |
|
|
54
|
+
| Exact phrase | `.phrase("exact phrase")` | `"exact phrase"` |
|
|
55
|
+
| Force include | `.include("term")` | `+term` |
|
|
56
|
+
| Exclude | `.exclude("term")` | `-term` |
|
|
57
|
+
| File extension | `.ext("pdf")` | `ext:pdf` |
|
|
58
|
+
| File type | `.filetype("pdf")` | `filetype:pdf` |
|
|
59
|
+
| In title | `.intitle("2023")` | `intitle:2023` |
|
|
60
|
+
| In body | `.inbody("keyword")` | `inbody:keyword` |
|
|
61
|
+
| In page | `.inpage("keyword")` | `inpage:keyword` |
|
|
62
|
+
| Language (ISO 639-1) | `.lang("es")` | `lang:es` |
|
|
63
|
+
| Language alias | `.language("es")` | `language:es` |
|
|
64
|
+
| Location (ISO 3166-1) | `.loc("gb")` | `loc:gb` |
|
|
65
|
+
| Location alias | `.location("gb")` | `location:gb` |
|
|
66
|
+
| Site / domain | `.site("example.com")` | `site:example.com` |
|
|
67
|
+
| Logical AND | `.and_(other)` or `&` | `term1 AND term2` |
|
|
68
|
+
| Logical OR | `.or_(other)` or `\|` | `term1 OR term2` |
|
|
69
|
+
| Logical NOT | `.not_(other)` or `~` | `NOT term` |
|
|
70
|
+
|
|
71
|
+
Operators can be placed anywhere in the query string, matching Brave Search behavior.
|
|
72
|
+
|
|
73
|
+
## Examples
|
|
74
|
+
|
|
75
|
+
### Official documentation examples
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from bsqb import Query
|
|
79
|
+
|
|
80
|
+
# Academic research
|
|
81
|
+
Query("climate change").filetype("pdf").site("edu").intitle("2024").build()
|
|
82
|
+
# climate change filetype:pdf site:edu intitle:2024
|
|
83
|
+
|
|
84
|
+
# Multilingual content
|
|
85
|
+
Query("recettes cuisine").loc("ca").lang("fr").build()
|
|
86
|
+
# recettes cuisine loc:ca lang:fr
|
|
87
|
+
|
|
88
|
+
# Competitive analysis
|
|
89
|
+
(
|
|
90
|
+
Query("AI startup")
|
|
91
|
+
.exclude("google")
|
|
92
|
+
.exclude("microsoft")
|
|
93
|
+
.exclude("amazon")
|
|
94
|
+
.exclude("meta")
|
|
95
|
+
.build()
|
|
96
|
+
)
|
|
97
|
+
# AI startup -google -microsoft -amazon -meta
|
|
98
|
+
|
|
99
|
+
# Technical documentation
|
|
100
|
+
(
|
|
101
|
+
Query("python")
|
|
102
|
+
.phrase("asyncio")
|
|
103
|
+
.intitle("documentation")
|
|
104
|
+
.site("docs.python.org")
|
|
105
|
+
.build()
|
|
106
|
+
)
|
|
107
|
+
# python "asyncio" intitle:documentation site:docs.python.org
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Logical operators
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from bsqb import Query, combine_and, combine_or
|
|
114
|
+
|
|
115
|
+
# AND — visa info in English from UK sites
|
|
116
|
+
Query("visa").loc("gb").and_(Query().lang("en")).build()
|
|
117
|
+
# visa loc:gb AND lang:en
|
|
118
|
+
|
|
119
|
+
# OR — travel requirements for Australia or New Zealand
|
|
120
|
+
(
|
|
121
|
+
Query("travel requirements")
|
|
122
|
+
.inpage("australia")
|
|
123
|
+
.or_(Query().inpage("new zealand"))
|
|
124
|
+
.build()
|
|
125
|
+
)
|
|
126
|
+
# travel requirements inpage:australia OR inpage:"new zealand"
|
|
127
|
+
|
|
128
|
+
# NOT — exclude a domain
|
|
129
|
+
Query("brave search").not_(Query().site("brave.com")).build()
|
|
130
|
+
# brave search NOT site:brave.com
|
|
131
|
+
|
|
132
|
+
# Python operators
|
|
133
|
+
(Query("coffee") | Query("tea")).exclude("starbucks").build()
|
|
134
|
+
# coffee OR tea -starbucks
|
|
135
|
+
|
|
136
|
+
# Combine multiple queries
|
|
137
|
+
combine_and(Query("visa").loc("gb"), Query().lang("en")).build()
|
|
138
|
+
combine_or(Query().site("reuters.com"), Query().site("bloomberg.com")).build()
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Advanced usage
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from bsqb import Query, phrase, raw, term
|
|
145
|
+
|
|
146
|
+
# Build from AST nodes
|
|
147
|
+
Query.from_nodes(term("python"), phrase("asyncio"), raw("site:docs.python.org"))
|
|
148
|
+
|
|
149
|
+
# Wrap an existing query string
|
|
150
|
+
Query.parse("machine learning filetype:pdf lang:en").build()
|
|
151
|
+
|
|
152
|
+
# Skip validation for edge cases
|
|
153
|
+
Query.parse("...").build(validate=False)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Validation
|
|
157
|
+
|
|
158
|
+
The Brave Search API enforces these limits on the `q` parameter:
|
|
159
|
+
|
|
160
|
+
- Maximum **400 characters**
|
|
161
|
+
- Maximum **50 words**
|
|
162
|
+
- Query cannot be empty
|
|
163
|
+
|
|
164
|
+
Call `.build()` to validate (default), or `.render()` / `str()` to get the string without validation:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from bsqb import Query, QueryValidationError, EmptyQueryError
|
|
168
|
+
|
|
169
|
+
query = Query("hello world")
|
|
170
|
+
|
|
171
|
+
query.render() # "hello world" — no validation
|
|
172
|
+
query.build() # "hello world" — validates limits
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
Query().build()
|
|
176
|
+
except EmptyQueryError:
|
|
177
|
+
...
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
Query.parse(" ".join(["word"] * 51)).build()
|
|
181
|
+
except QueryValidationError as exc:
|
|
182
|
+
print(exc.query)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Integration with Brave Search API
|
|
186
|
+
|
|
187
|
+
Search operators are included in the `q` parameter. Set `operators=true` (the default) in API requests:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
import urllib.parse
|
|
191
|
+
import urllib.request
|
|
192
|
+
|
|
193
|
+
from bsqb import Query
|
|
194
|
+
|
|
195
|
+
query = Query("python").phrase("asyncio").filetype("pdf").lang("en")
|
|
196
|
+
|
|
197
|
+
params = {
|
|
198
|
+
"q": query.build(),
|
|
199
|
+
"count": "10",
|
|
200
|
+
"operators": "true",
|
|
201
|
+
}
|
|
202
|
+
url = "https://api.search.brave.com/res/v1/web/search?" + urllib.parse.urlencode(params)
|
|
203
|
+
|
|
204
|
+
request = urllib.request.Request(
|
|
205
|
+
url,
|
|
206
|
+
headers={
|
|
207
|
+
"Accept": "application/json",
|
|
208
|
+
"X-Subscription-Token": "YOUR_API_KEY",
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
For POST requests with long queries, pass the built string as the `q` field in the request body.
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
git clone https://github.com/kodzghly/bsqb.git
|
|
219
|
+
cd bsqb
|
|
220
|
+
python -m pip install -e ".[dev]"
|
|
221
|
+
|
|
222
|
+
# Run tests
|
|
223
|
+
pytest
|
|
224
|
+
|
|
225
|
+
# Lint and type check
|
|
226
|
+
ruff check src tests
|
|
227
|
+
mypy src
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Publishing to PyPI
|
|
231
|
+
|
|
232
|
+
This package uses [Hatchling](https://hatch.pypa.io/) as the build backend.
|
|
233
|
+
|
|
234
|
+
### First-time setup
|
|
235
|
+
|
|
236
|
+
1. Create accounts on [PyPI](https://pypi.org/account/register/) and [TestPyPI](https://test.pypi.org/account/register/)
|
|
237
|
+
2. Enable [2FA](https://pypi.org/help/#twofa) on both accounts
|
|
238
|
+
3. Create an API token at [pypi.org/manage/account/token/](https://pypi.org/manage/account/token/)
|
|
239
|
+
|
|
240
|
+
### Test on TestPyPI
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
python -m pip install --upgrade build twine
|
|
244
|
+
|
|
245
|
+
# Bump version in pyproject.toml and src/bsqb/__init__.py
|
|
246
|
+
python -m build
|
|
247
|
+
|
|
248
|
+
# Upload to TestPyPI
|
|
249
|
+
twine upload --repository testpypi dist/*
|
|
250
|
+
|
|
251
|
+
# Verify installation
|
|
252
|
+
pip install --index-url https://test.pypi.org/simple/ bsqb
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Publish to PyPI
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
python -m build
|
|
259
|
+
twine check dist/*
|
|
260
|
+
twine upload dist/*
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Recommended: publish via GitHub Actions
|
|
264
|
+
|
|
265
|
+
Add trusted publishing on PyPI for your repository, then create a release workflow triggered by git tags:
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
# .github/workflows/publish.yml
|
|
269
|
+
name: Publish
|
|
270
|
+
|
|
271
|
+
on:
|
|
272
|
+
release:
|
|
273
|
+
types: [published]
|
|
274
|
+
|
|
275
|
+
jobs:
|
|
276
|
+
publish:
|
|
277
|
+
runs-on: ubuntu-latest
|
|
278
|
+
permissions:
|
|
279
|
+
id-token: write
|
|
280
|
+
steps:
|
|
281
|
+
- uses: actions/checkout@v4
|
|
282
|
+
- uses: actions/setup-python@v5
|
|
283
|
+
with:
|
|
284
|
+
python-version: "3.12"
|
|
285
|
+
- run: pip install build
|
|
286
|
+
- run: python -m build
|
|
287
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Create a GitHub release with tag `v0.1.0` to trigger publication.
|
|
291
|
+
|
|
292
|
+
## References
|
|
293
|
+
|
|
294
|
+
- [Brave Search Operators (API docs)](https://api-dashboard.search.brave.com/documentation/resources/search-operators)
|
|
295
|
+
- [Brave Search Operators (help page)](https://search.brave.com/help/operators)
|
|
296
|
+
- [Brave Web Search API](https://api-dashboard.search.brave.com/app/documentation/web-search)
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bsqb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Type-safe query builder for Brave Search operators"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "bsqb contributors" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"brave",
|
|
15
|
+
"brave-search",
|
|
16
|
+
"search",
|
|
17
|
+
"query-builder",
|
|
18
|
+
"web-search",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"License :: OSI Approved :: MIT License",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
dependencies = []
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
dev = [
|
|
38
|
+
"mypy>=1.8",
|
|
39
|
+
"pytest>=8.0",
|
|
40
|
+
"pytest-cov>=4.1",
|
|
41
|
+
"ruff>=0.4",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/kodzghly/bsqb"
|
|
46
|
+
Documentation = "https://github.com/kodzghly/bsqb#readme"
|
|
47
|
+
Repository = "https://github.com/kodzghly/bsqb"
|
|
48
|
+
Issues = "https://github.com/kodzghly/bsqb/issues"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/bsqb"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
only-include = ["src/bsqb", "README.md", "LICENSE", "pyproject.toml"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
addopts = "-ra --strict-markers"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
target-version = "py310"
|
|
62
|
+
line-length = 88
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.10"
|
|
69
|
+
strict = true
|
|
70
|
+
packages = ["bsqb"]
|
|
71
|
+
|
|
72
|
+
[tool.coverage.run]
|
|
73
|
+
source = ["bsqb"]
|
|
74
|
+
branch = true
|
|
75
|
+
|
|
76
|
+
[tool.coverage.report]
|
|
77
|
+
fail_under = 95
|
|
78
|
+
show_missing = true
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Brave Search query builder (bsqb)."""
|
|
2
|
+
|
|
3
|
+
from bsqb.builder import Query, combine_and, combine_or, phrase, raw, term
|
|
4
|
+
from bsqb.exceptions import BsqbError, EmptyQueryError, QueryValidationError
|
|
5
|
+
from bsqb.nodes import (
|
|
6
|
+
BinaryLogical,
|
|
7
|
+
Exclude,
|
|
8
|
+
Field,
|
|
9
|
+
Include,
|
|
10
|
+
Node,
|
|
11
|
+
Not,
|
|
12
|
+
Phrase,
|
|
13
|
+
Raw,
|
|
14
|
+
Sequence,
|
|
15
|
+
Term,
|
|
16
|
+
)
|
|
17
|
+
from bsqb.operators import (
|
|
18
|
+
MAX_QUERY_CHARACTERS,
|
|
19
|
+
MAX_QUERY_WORDS,
|
|
20
|
+
FieldOperator,
|
|
21
|
+
LogicalOperator,
|
|
22
|
+
)
|
|
23
|
+
from bsqb.validation import count_words, validate_query
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"MAX_QUERY_CHARACTERS",
|
|
27
|
+
"MAX_QUERY_WORDS",
|
|
28
|
+
"BinaryLogical",
|
|
29
|
+
"BsqbError",
|
|
30
|
+
"EmptyQueryError",
|
|
31
|
+
"Exclude",
|
|
32
|
+
"Field",
|
|
33
|
+
"FieldOperator",
|
|
34
|
+
"Include",
|
|
35
|
+
"LogicalOperator",
|
|
36
|
+
"Node",
|
|
37
|
+
"Not",
|
|
38
|
+
"Phrase",
|
|
39
|
+
"Query",
|
|
40
|
+
"QueryValidationError",
|
|
41
|
+
"Raw",
|
|
42
|
+
"Sequence",
|
|
43
|
+
"Term",
|
|
44
|
+
"combine_and",
|
|
45
|
+
"combine_or",
|
|
46
|
+
"count_words",
|
|
47
|
+
"phrase",
|
|
48
|
+
"raw",
|
|
49
|
+
"term",
|
|
50
|
+
"validate_query",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Fluent query builder for Brave Search operators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bsqb.exceptions import EmptyQueryError
|
|
6
|
+
from bsqb.nodes import (
|
|
7
|
+
BinaryLogical,
|
|
8
|
+
Exclude,
|
|
9
|
+
Field,
|
|
10
|
+
Include,
|
|
11
|
+
Node,
|
|
12
|
+
Not,
|
|
13
|
+
Phrase,
|
|
14
|
+
Raw,
|
|
15
|
+
Sequence,
|
|
16
|
+
Term,
|
|
17
|
+
empty_node,
|
|
18
|
+
)
|
|
19
|
+
from bsqb.operators import FieldOperator, LogicalOperator
|
|
20
|
+
from bsqb.validation import validate_query
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Query",
|
|
24
|
+
"phrase",
|
|
25
|
+
"raw",
|
|
26
|
+
"term",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def term(value: str) -> Term:
|
|
31
|
+
"""Create a plain search term node."""
|
|
32
|
+
return Term(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def phrase(value: str) -> Phrase:
|
|
36
|
+
"""Create an exact phrase match node."""
|
|
37
|
+
return Phrase(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def raw(value: str) -> Raw:
|
|
41
|
+
"""Create a raw query fragment node."""
|
|
42
|
+
return Raw(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Query:
|
|
46
|
+
"""
|
|
47
|
+
Fluent builder for Brave Search query strings.
|
|
48
|
+
|
|
49
|
+
Supports all operators documented at:
|
|
50
|
+
https://api-dashboard.search.brave.com/documentation/resources/search-operators
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
>>> str(Query("machine learning").filetype("pdf").lang("en"))
|
|
54
|
+
'machine learning filetype:pdf lang:en'
|
|
55
|
+
|
|
56
|
+
>>> str(Query("visa").loc("gb").and_(Query().lang("en")))
|
|
57
|
+
'visa loc:gb AND lang:en'
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
__slots__ = ("_node",)
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*parts: str | Node,
|
|
65
|
+
_node: Node | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
if _node is not None:
|
|
68
|
+
self._node = _node
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
nodes: list[Node] = []
|
|
72
|
+
for part in parts:
|
|
73
|
+
if isinstance(part, Node):
|
|
74
|
+
nodes.append(part)
|
|
75
|
+
elif isinstance(part, str) and part:
|
|
76
|
+
nodes.append(Term(part))
|
|
77
|
+
|
|
78
|
+
if not nodes:
|
|
79
|
+
self._node = empty_node()
|
|
80
|
+
elif len(nodes) == 1:
|
|
81
|
+
self._node = nodes[0]
|
|
82
|
+
else:
|
|
83
|
+
self._node = Sequence(tuple(nodes))
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_nodes(cls, *nodes: Node) -> Query:
|
|
87
|
+
"""Create a query from explicit AST nodes."""
|
|
88
|
+
if not nodes:
|
|
89
|
+
return cls(_node=empty_node())
|
|
90
|
+
if len(nodes) == 1:
|
|
91
|
+
return cls(_node=nodes[0])
|
|
92
|
+
return cls(_node=Sequence(tuple(nodes)))
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def parse(cls, value: str) -> Query:
|
|
96
|
+
"""
|
|
97
|
+
Wrap a pre-built query string without parsing.
|
|
98
|
+
|
|
99
|
+
Use this when you already have a valid Brave Search query string.
|
|
100
|
+
"""
|
|
101
|
+
stripped = value.strip()
|
|
102
|
+
if not stripped:
|
|
103
|
+
return cls()
|
|
104
|
+
return cls(_node=Raw(stripped))
|
|
105
|
+
|
|
106
|
+
def _append(self, node: Node) -> Query:
|
|
107
|
+
current = self._node
|
|
108
|
+
if isinstance(current, Sequence):
|
|
109
|
+
return Query(_node=Sequence((*current.parts, node)))
|
|
110
|
+
if isinstance(current, (BinaryLogical, Not)) or not current:
|
|
111
|
+
return Query(_node=Sequence((current, node)) if current else node)
|
|
112
|
+
return Query(_node=Sequence((current, node)))
|
|
113
|
+
|
|
114
|
+
def _logical(self, operator: LogicalOperator, other: Query) -> Query:
|
|
115
|
+
return Query(_node=BinaryLogical(operator, self._node, other._node))
|
|
116
|
+
|
|
117
|
+
# --- Content terms -------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def term(self, value: str) -> Query:
|
|
120
|
+
"""Append a plain search term."""
|
|
121
|
+
return self._append(Term(value))
|
|
122
|
+
|
|
123
|
+
def phrase(self, value: str) -> Query:
|
|
124
|
+
"""Append an exact phrase match."""
|
|
125
|
+
return self._append(Phrase(value))
|
|
126
|
+
|
|
127
|
+
def include(self, value: str) -> Query:
|
|
128
|
+
"""Force inclusion of a term (+term)."""
|
|
129
|
+
return self._append(Include(value))
|
|
130
|
+
|
|
131
|
+
def exclude(self, value: str) -> Query:
|
|
132
|
+
"""Exclude a term (-term)."""
|
|
133
|
+
return self._append(Exclude(value))
|
|
134
|
+
|
|
135
|
+
def raw(self, value: str) -> Query:
|
|
136
|
+
"""Append a raw query fragment."""
|
|
137
|
+
return self._append(Raw(value))
|
|
138
|
+
|
|
139
|
+
# --- Field operators -----------------------------------------------
|
|
140
|
+
|
|
141
|
+
def ext(self, extension: str) -> Query:
|
|
142
|
+
"""Filter by file extension (ext:)."""
|
|
143
|
+
return self._append(Field(FieldOperator.EXT, extension.lstrip(".")))
|
|
144
|
+
|
|
145
|
+
def filetype(self, filetype: str) -> Query:
|
|
146
|
+
"""Filter by file type (filetype:)."""
|
|
147
|
+
return self._append(Field(FieldOperator.FILETYPE, filetype.lstrip(".")))
|
|
148
|
+
|
|
149
|
+
def intitle(self, value: str) -> Query:
|
|
150
|
+
"""Search in page title (intitle:)."""
|
|
151
|
+
return self._append(Field(FieldOperator.INTITLE, value))
|
|
152
|
+
|
|
153
|
+
def inbody(self, value: str) -> Query:
|
|
154
|
+
"""Search in page body (inbody:)."""
|
|
155
|
+
return self._append(Field(FieldOperator.INBODY, value))
|
|
156
|
+
|
|
157
|
+
def inpage(self, value: str) -> Query:
|
|
158
|
+
"""Search in title or body (inpage:)."""
|
|
159
|
+
return self._append(Field(FieldOperator.INPAGE, value))
|
|
160
|
+
|
|
161
|
+
def lang(self, code: str) -> Query:
|
|
162
|
+
"""Filter by language ISO 639-1 code (lang:)."""
|
|
163
|
+
return self._append(Field(FieldOperator.LANG, code.lower()))
|
|
164
|
+
|
|
165
|
+
def language(self, code: str) -> Query:
|
|
166
|
+
"""Alias for lang() using the language: operator."""
|
|
167
|
+
return self._append(Field(FieldOperator.LANGUAGE, code.lower()))
|
|
168
|
+
|
|
169
|
+
def loc(self, code: str) -> Query:
|
|
170
|
+
"""Filter by country ISO 3166-1 alpha-2 code (loc:)."""
|
|
171
|
+
return self._append(Field(FieldOperator.LOC, code.lower()))
|
|
172
|
+
|
|
173
|
+
def location(self, code: str) -> Query:
|
|
174
|
+
"""Alias for loc() using the location: operator."""
|
|
175
|
+
return self._append(Field(FieldOperator.LOCATION, code.lower()))
|
|
176
|
+
|
|
177
|
+
def site(self, domain: str) -> Query:
|
|
178
|
+
"""Limit results to a domain (site:)."""
|
|
179
|
+
normalized = domain.removeprefix("https://").removeprefix("http://")
|
|
180
|
+
normalized = normalized.removeprefix("www.").rstrip("/")
|
|
181
|
+
return self._append(Field(FieldOperator.SITE, normalized))
|
|
182
|
+
|
|
183
|
+
# --- Logical operators ---------------------------------------------
|
|
184
|
+
|
|
185
|
+
def and_(self, other: Query) -> Query:
|
|
186
|
+
"""Combine with AND (both conditions required)."""
|
|
187
|
+
return self._logical(LogicalOperator.AND, other)
|
|
188
|
+
|
|
189
|
+
def or_(self, other: Query) -> Query:
|
|
190
|
+
"""Combine with OR (either condition sufficient)."""
|
|
191
|
+
return self._logical(LogicalOperator.OR, other)
|
|
192
|
+
|
|
193
|
+
def not_(self, other: Query | None = None) -> Query:
|
|
194
|
+
"""
|
|
195
|
+
Negate a condition with NOT.
|
|
196
|
+
|
|
197
|
+
When called with another query, appends ``NOT`` to the current query.
|
|
198
|
+
When called without an argument, negates the current query.
|
|
199
|
+
"""
|
|
200
|
+
if other is None:
|
|
201
|
+
return Query(_node=Not(self._node))
|
|
202
|
+
return Query(_node=Sequence((self._node, Not(other._node))))
|
|
203
|
+
|
|
204
|
+
# --- Python operators for ergonomics --------------------------------
|
|
205
|
+
|
|
206
|
+
def __and__(self, other: Query) -> Query:
|
|
207
|
+
return self.and_(other)
|
|
208
|
+
|
|
209
|
+
def __or__(self, other: Query) -> Query:
|
|
210
|
+
return self.or_(other)
|
|
211
|
+
|
|
212
|
+
def __invert__(self) -> Query:
|
|
213
|
+
return self.not_()
|
|
214
|
+
|
|
215
|
+
# --- Output --------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def render(self) -> str:
|
|
218
|
+
"""Render the query string without validation."""
|
|
219
|
+
return self._node.render()
|
|
220
|
+
|
|
221
|
+
def build(self, *, validate: bool = True) -> str:
|
|
222
|
+
"""
|
|
223
|
+
Build and optionally validate the query string.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
validate: When True (default), enforce Brave Search API limits.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
EmptyQueryError: When the query is empty.
|
|
230
|
+
QueryValidationError: When limits are exceeded.
|
|
231
|
+
"""
|
|
232
|
+
rendered = self.render()
|
|
233
|
+
if not rendered:
|
|
234
|
+
raise EmptyQueryError()
|
|
235
|
+
if validate:
|
|
236
|
+
return validate_query(rendered)
|
|
237
|
+
return rendered
|
|
238
|
+
|
|
239
|
+
def __str__(self) -> str:
|
|
240
|
+
return self.render()
|
|
241
|
+
|
|
242
|
+
def __repr__(self) -> str:
|
|
243
|
+
return f"Query({self.render()!r})"
|
|
244
|
+
|
|
245
|
+
def __eq__(self, other: object) -> bool:
|
|
246
|
+
if not isinstance(other, Query):
|
|
247
|
+
return NotImplemented
|
|
248
|
+
return self.render() == other.render()
|
|
249
|
+
|
|
250
|
+
def __hash__(self) -> int:
|
|
251
|
+
return hash(self.render())
|
|
252
|
+
|
|
253
|
+
def __bool__(self) -> bool:
|
|
254
|
+
return bool(self.render())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def combine_and(*queries: Query) -> Query:
|
|
258
|
+
"""Combine multiple queries with AND."""
|
|
259
|
+
if not queries:
|
|
260
|
+
return Query()
|
|
261
|
+
result = queries[0]
|
|
262
|
+
for query in queries[1:]:
|
|
263
|
+
result = result.and_(query)
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def combine_or(*queries: Query) -> Query:
|
|
268
|
+
"""Combine multiple queries with OR."""
|
|
269
|
+
if not queries:
|
|
270
|
+
return Query()
|
|
271
|
+
result = queries[0]
|
|
272
|
+
for query in queries[1:]:
|
|
273
|
+
result = result.or_(query)
|
|
274
|
+
return result
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exceptions for bsqb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BsqbError(Exception):
|
|
7
|
+
"""Base exception for all bsqb errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QueryValidationError(BsqbError):
|
|
11
|
+
"""Raised when a built query violates Brave Search API limits."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, query: str | None = None) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.query = query
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmptyQueryError(QueryValidationError):
|
|
19
|
+
"""Raised when attempting to build an empty query."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__(
|
|
23
|
+
"Query cannot be empty. "
|
|
24
|
+
"The Brave Search API requires a non-empty q parameter."
|
|
25
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Abstract syntax tree nodes for Brave Search queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from bsqb.operators import FieldOperator, LogicalOperator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_operand(value: str, *, quote: bool = False) -> str:
|
|
12
|
+
"""Format an operator operand, quoting when it contains whitespace."""
|
|
13
|
+
if quote or any(char.isspace() for char in value):
|
|
14
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
15
|
+
return f'"{escaped}"'
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _format_term(value: str) -> str:
|
|
20
|
+
"""Format a plain search term."""
|
|
21
|
+
return value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Node(ABC):
|
|
25
|
+
"""Base class for all query AST nodes."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def render(self) -> str:
|
|
29
|
+
"""Render this node to a Brave Search query string."""
|
|
30
|
+
|
|
31
|
+
def __bool__(self) -> bool:
|
|
32
|
+
return bool(self.render())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Term(Node):
|
|
37
|
+
"""A plain search term."""
|
|
38
|
+
|
|
39
|
+
value: str
|
|
40
|
+
|
|
41
|
+
def render(self) -> str:
|
|
42
|
+
return _format_term(self.value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Phrase(Node):
|
|
47
|
+
"""An exact phrase match wrapped in double quotes."""
|
|
48
|
+
|
|
49
|
+
value: str
|
|
50
|
+
|
|
51
|
+
def render(self) -> str:
|
|
52
|
+
value = self.value
|
|
53
|
+
escaped = (
|
|
54
|
+
""
|
|
55
|
+
if not value
|
|
56
|
+
else value.replace("\\", "\\\\").replace('"', '\\"')
|
|
57
|
+
)
|
|
58
|
+
return f'"{escaped}"'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Include(Node):
|
|
63
|
+
"""Force inclusion of a term (+term)."""
|
|
64
|
+
|
|
65
|
+
value: str
|
|
66
|
+
|
|
67
|
+
def render(self) -> str:
|
|
68
|
+
return f"+{_format_term(self.value)}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class Exclude(Node):
|
|
73
|
+
"""Exclude a term (-term)."""
|
|
74
|
+
|
|
75
|
+
value: str
|
|
76
|
+
|
|
77
|
+
def render(self) -> str:
|
|
78
|
+
return f"-{_format_term(self.value)}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class Field(Node):
|
|
83
|
+
"""A field operator such as site:, lang:, or filetype:."""
|
|
84
|
+
|
|
85
|
+
operator: FieldOperator
|
|
86
|
+
value: str
|
|
87
|
+
|
|
88
|
+
def render(self) -> str:
|
|
89
|
+
return f"{self.operator.value}:{_format_operand(self.value)}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Raw(Node):
|
|
94
|
+
"""A pre-formatted query fragment for advanced use cases."""
|
|
95
|
+
|
|
96
|
+
value: str
|
|
97
|
+
|
|
98
|
+
def render(self) -> str:
|
|
99
|
+
return self.value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class Sequence(Node):
|
|
104
|
+
"""A space-separated sequence of query parts."""
|
|
105
|
+
|
|
106
|
+
parts: tuple[Node, ...]
|
|
107
|
+
|
|
108
|
+
def render(self) -> str:
|
|
109
|
+
rendered = [part.render() for part in self.parts if part.render()]
|
|
110
|
+
return " ".join(rendered)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class BinaryLogical(Node):
|
|
115
|
+
"""A binary logical expression (AND / OR)."""
|
|
116
|
+
|
|
117
|
+
operator: LogicalOperator
|
|
118
|
+
left: Node
|
|
119
|
+
right: Node
|
|
120
|
+
|
|
121
|
+
def render(self) -> str:
|
|
122
|
+
left = self.left.render()
|
|
123
|
+
right = self.right.render()
|
|
124
|
+
op = self.operator.value
|
|
125
|
+
if isinstance(self.left, BinaryLogical) and self.left.operator != self.operator:
|
|
126
|
+
left = f"({left})"
|
|
127
|
+
if (
|
|
128
|
+
isinstance(self.right, BinaryLogical)
|
|
129
|
+
and self.right.operator != self.operator
|
|
130
|
+
):
|
|
131
|
+
right = f"({right})"
|
|
132
|
+
return f"{left} {op} {right}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True, slots=True)
|
|
136
|
+
class Not(Node):
|
|
137
|
+
"""A NOT logical expression."""
|
|
138
|
+
|
|
139
|
+
operand: Node
|
|
140
|
+
|
|
141
|
+
def render(self) -> str:
|
|
142
|
+
operand = self.operand.render()
|
|
143
|
+
if isinstance(self.operand, BinaryLogical):
|
|
144
|
+
operand = f"({operand})"
|
|
145
|
+
return f"{LogicalOperator.NOT.value} {operand}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def empty_node() -> Sequence:
|
|
149
|
+
"""Return an empty sequence node."""
|
|
150
|
+
return Sequence(())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Brave Search operator definitions based on official documentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FieldOperator(str, Enum):
|
|
9
|
+
"""Field operators supported by Brave Search."""
|
|
10
|
+
|
|
11
|
+
EXT = "ext"
|
|
12
|
+
FILETYPE = "filetype"
|
|
13
|
+
INTITLE = "intitle"
|
|
14
|
+
INBODY = "inbody"
|
|
15
|
+
INPAGE = "inpage"
|
|
16
|
+
LANG = "lang"
|
|
17
|
+
LANGUAGE = "language"
|
|
18
|
+
LOC = "loc"
|
|
19
|
+
LOCATION = "location"
|
|
20
|
+
SITE = "site"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LogicalOperator(str, Enum):
|
|
24
|
+
"""Logical operators supported by Brave Search (must be uppercase)."""
|
|
25
|
+
|
|
26
|
+
AND = "AND"
|
|
27
|
+
OR = "OR"
|
|
28
|
+
NOT = "NOT"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Brave Search API query limits.
|
|
32
|
+
MAX_QUERY_CHARACTERS = 400
|
|
33
|
+
MAX_QUERY_WORDS = 50
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Validation helpers for Brave Search query limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from bsqb.exceptions import EmptyQueryError, QueryValidationError
|
|
8
|
+
from bsqb.operators import MAX_QUERY_CHARACTERS, MAX_QUERY_WORDS
|
|
9
|
+
|
|
10
|
+
_WORD_PATTERN = re.compile(r"\S+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def count_words(query: str) -> int:
|
|
14
|
+
"""Count words in a query string."""
|
|
15
|
+
return len(_WORD_PATTERN.findall(query))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_query(query: str) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Validate a query against Brave Search API limits.
|
|
21
|
+
|
|
22
|
+
Returns the query unchanged when valid.
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
EmptyQueryError: When the query is empty or whitespace-only.
|
|
26
|
+
QueryValidationError: When the query exceeds character or word limits.
|
|
27
|
+
"""
|
|
28
|
+
normalized = query.strip()
|
|
29
|
+
if not normalized:
|
|
30
|
+
raise EmptyQueryError()
|
|
31
|
+
|
|
32
|
+
if len(normalized) > MAX_QUERY_CHARACTERS:
|
|
33
|
+
raise QueryValidationError(
|
|
34
|
+
f"Query exceeds the maximum of {MAX_QUERY_CHARACTERS} characters "
|
|
35
|
+
f"({len(normalized)} given).",
|
|
36
|
+
query=normalized,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
word_count = count_words(normalized)
|
|
40
|
+
if word_count > MAX_QUERY_WORDS:
|
|
41
|
+
raise QueryValidationError(
|
|
42
|
+
f"Query exceeds the maximum of {MAX_QUERY_WORDS} words "
|
|
43
|
+
f"({word_count} given).",
|
|
44
|
+
query=normalized,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return normalized
|