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 ADDED
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ .eggs/
6
+ dist/
7
+ build/
8
+ .coverage
9
+ htmlcov/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .venv/
14
+ venv/
15
+ *.egg
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
+ [![CI](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml/badge.svg)](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml)
36
+ [![PyPI version](https://img.shields.io/pypi/v/bsqb.svg)](https://pypi.org/project/bsqb/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/bsqb.svg)](https://pypi.org/project/bsqb/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![CI](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml/badge.svg)](https://github.com/kodzghly/bsqb/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/bsqb.svg)](https://pypi.org/project/bsqb/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/bsqb.svg)](https://pypi.org/project/bsqb/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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