Skip to main content

phrona/
lib.rs

1//! Python bindings for the phrona library.
2//!
3//! ```python
4//! import phrona
5//! phrona.search("rust programming", engines=["bing", "brave"])
6//! phrona.suggest("rus")
7//! phrona.extract("https://doc.rust-lang.org/book/")
8//! ```
9
10use std::sync::LazyLock;
11use std::time::Duration;
12
13use pyo3::exceptions::PyValueError;
14use pyo3::prelude::*;
15use pyo3::types::{PyDict, PyList};
16
17use phrona_core::{Category, Profile, SearchClient, SearchOptions};
18
19/// Dedicated multi-threaded runtime for all blocking calls. Network I/O runs
20/// on this runtime with the Python GIL released, so Python threads and
21/// asyncio loops are never blocked.
22static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
23    tokio::runtime::Builder::new_multi_thread()
24        .enable_all()
25        .build()
26        .expect("tokio runtime")
27});
28
29fn parse_profile(s: &str) -> PyResult<Profile> {
30    let name = s.trim().to_ascii_lowercase();
31    let p = match name.as_str() {
32        "chrome" | "chrome148" => Profile::Chrome,
33        "chrome100" => Profile::Chrome100,
34        "chrome120" => Profile::Chrome120,
35        "chrome131" => Profile::Chrome131,
36        "chrome140" => Profile::Chrome140,
37        "chrome149" => Profile::Chrome149,
38        "firefox" | "firefox148" => Profile::Firefox,
39        "firefox139" => Profile::Firefox139,
40        "edge" | "edge148" => Profile::Edge,
41        "safari" | "safari26" => Profile::Safari,
42        "opera" | "opera131" => Profile::Opera,
43        "okhttp" => Profile::OkHttp,
44        "random" => Profile::Random,
45        _ => return Err(PyValueError::new_err(format!("unknown profile '{s}'"))),
46    };
47    Ok(p)
48}
49
50fn parse_category(s: &str) -> PyResult<Category> {
51    s.parse::<Category>().map_err(|_| {
52        PyValueError::new_err("category must be one of: web, images, news, videos, books")
53    })
54}
55
56fn to_py(py: Python<'_>, v: &impl serde::Serialize) -> PyResult<Py<PyAny>> {
57    let j =
58        serde_json::to_value(v).map_err(|e| PyValueError::new_err(format!("serialize: {e}")))?;
59    json_to_py(py, &j)
60}
61
62/// Convert a serde_json::Value into the matching Python object.
63fn json_to_py(py: Python<'_>, v: &serde_json::Value) -> PyResult<Py<PyAny>> {
64    let o: Py<PyAny> = match v {
65        serde_json::Value::Null => py.None(),
66        serde_json::Value::Bool(b) => (*b).into_pyobject(py)?.to_owned().into_any().unbind(),
67        serde_json::Value::Number(n) => {
68            if let Some(i) = n.as_i64() {
69                i.into_pyobject(py)?.into_any().unbind()
70            } else {
71                n.as_f64()
72                    .unwrap_or(0.0)
73                    .into_pyobject(py)?
74                    .into_any()
75                    .unbind()
76            }
77        }
78        serde_json::Value::String(s) => s.into_pyobject(py)?.into_any().unbind(),
79        serde_json::Value::Array(items) => {
80            let list = PyList::empty(py);
81            for item in items {
82                list.append(json_to_py(py, item)?)?;
83            }
84            list.into_any().unbind()
85        }
86        serde_json::Value::Object(map) => {
87            let d = PyDict::new(py);
88            for (k, val) in map {
89                d.set_item(k, json_to_py(py, val)?)?;
90            }
91            d.into_any().unbind()
92        }
93    };
94    Ok(o)
95}
96
97/// A metasearch client. Safe to share across threads.
98#[pyclass]
99struct Client {
100    client: SearchClient,
101}
102
103#[pymethods]
104impl Client {
105    #[new]
106    #[pyo3(signature = (profile="chrome", timeout=15.0))]
107    fn new(profile: &str, timeout: f64) -> PyResult<Self> {
108        let client = SearchClient::with_options(
109            parse_profile(profile)?,
110            Some(Duration::from_secs_f64(timeout.max(1.0))),
111            None,
112            phrona_core::TargetPolicy::default(),
113        )
114        .map_err(|e| PyValueError::new_err(e.to_string()))?;
115        Ok(Self { client })
116    }
117
118    /// Search all engines for a query. Returns a dict with results, engines
119    /// report, suggestions and elapsed time.
120    #[pyo3(signature = (query, category="web", engines=None, page=1, max_results=20,
121                        safesearch="moderate", region=None, language=None,
122                        time_range=None, filters=None))]
123    #[allow(clippy::too_many_arguments)]
124    fn search(
125        &self,
126        py: Python<'_>,
127        query: &str,
128        category: &str,
129        engines: Option<Vec<String>>,
130        page: u32,
131        max_results: usize,
132        safesearch: &str,
133        region: Option<String>,
134        language: Option<String>,
135        time_range: Option<String>,
136        filters: Option<String>,
137    ) -> PyResult<Py<PyAny>> {
138        let mut opts = SearchOptions::new(query);
139        opts.category = parse_category(category)?;
140        opts.engines = engines.unwrap_or_default();
141        opts.page = page.max(1);
142        opts.max_results = max_results.clamp(1, 200);
143        opts.safesearch = safesearch.parse::<phrona_core::SafeSearch>().map_err(|_| {
144            PyValueError::new_err("safesearch must be one of: off, moderate, strict")
145        })?;
146        opts.region = region;
147        opts.language = language;
148        opts.time_range = time_range
149            .map(|t| {
150                t.parse::<phrona_core::TimeRange>().map_err(|_| {
151                    PyValueError::new_err("time_range must be one of: day, week, month, year")
152                })
153            })
154            .transpose()?;
155        opts.filters = filters;
156        let resp = py
157            .detach(|| {
158                RUNTIME
159                    .block_on(self.client.search(opts))
160                    .map_err(|e| e.to_string())
161            })
162            .map_err(PyValueError::new_err)?;
163        to_py(py, &resp)
164    }
165
166    /// Query suggestions. source: duckduckgo, google, bing, brave, startpage,
167    /// qwant or wikipedia. None returns all sources.
168    #[pyo3(signature = (query, source=None, region="us-en"))]
169    fn suggest(
170        &self,
171        py: Python<'_>,
172        query: &str,
173        source: Option<String>,
174        region: &str,
175    ) -> PyResult<Py<PyAny>> {
176        let http = self.client.http();
177        let value = py
178            .detach(|| -> Result<serde_json::Value, String> {
179                match source {
180                    Some(name) => {
181                        let s = phrona_core::SuggestSource::from_name(&name).ok_or_else(|| {
182                            format!(
183                                "unknown source '{name}', expected one of: {}",
184                                phrona_core::SuggestSource::ALL
185                                    .iter()
186                                    .map(|s| s.name())
187                                    .collect::<Vec<_>>()
188                                    .join(", ")
189                            )
190                        })?;
191                        let list = RUNTIME
192                            .block_on(phrona_core::suggest(http, s, query, region))
193                            .map_err(|e| e.to_string())?;
194                        Ok(serde_json::json!({"query": query, "source": name, "suggestions": list}))
195                    }
196                    None => {
197                        let all = RUNTIME.block_on(phrona_core::suggest_all(http, query, region));
198                        let map: serde_json::Map<String, serde_json::Value> = all
199                            .into_iter()
200                            .map(|(s, list)| (s.name().to_string(), serde_json::json!(list)))
201                            .collect();
202                        Ok(serde_json::json!({"query": query, "suggestions": map}))
203                    }
204                }
205            })
206            .map_err(PyValueError::new_err)?;
207        to_py(py, &value)
208    }
209
210    /// Fetch a URL and extract its readable main content (AI grounding).
211    #[pyo3(signature = (url, max_chars=8000, query=None))]
212    fn extract(
213        &self,
214        py: Python<'_>,
215        url: &str,
216        max_chars: usize,
217        query: Option<&str>,
218    ) -> PyResult<Py<PyAny>> {
219        let page = py
220            .detach(|| {
221                RUNTIME
222                    .block_on(phrona_core::extract(
223                        self.client.http(),
224                        url,
225                        max_chars,
226                        query,
227                    ))
228                    .map_err(|e| e.to_string())
229            })
230            .map_err(PyValueError::new_err)?;
231        to_py(py, &page)
232    }
233
234    /// List available engines per category.
235    #[pyo3(signature = (category=None))]
236    fn engines(&self, py: Python<'_>, category: Option<String>) -> PyResult<Py<PyAny>> {
237        let out = py.detach(|| {
238            RUNTIME.block_on(async {
239                let mut out = serde_json::Map::new();
240                let cats: Vec<Category> = match category {
241                    Some(c) => vec![parse_category(&c)?],
242                    None => Category::ALL.to_vec(),
243                };
244                for cat in cats {
245                    let names: Vec<String> = phrona_core::available_engines(cat)
246                        .iter()
247                        .map(|e| e.name.clone())
248                        .collect();
249                    out.insert(cat.as_str().to_string(), serde_json::json!(names));
250                }
251                Ok::<_, PyErr>(serde_json::Value::Object(out))
252            })
253        });
254        to_py(py, &out?)
255    }
256}
257
258fn build_client(profile: &str, timeout: f64) -> PyResult<Client> {
259    Client::new(profile, timeout)
260}
261
262/// One-shot search with a default client. Same parameters as Client.search.
263#[pyfunction]
264#[pyo3(signature = (query, category="web", engines=None, page=1, max_results=20,
265                    safesearch="moderate", region=None, language=None,
266                    time_range=None, filters=None, profile="chrome", timeout=15.0))]
267#[allow(clippy::too_many_arguments)]
268fn search(
269    py: Python<'_>,
270    query: &str,
271    category: &str,
272    engines: Option<Vec<String>>,
273    page: u32,
274    max_results: usize,
275    safesearch: &str,
276    region: Option<String>,
277    language: Option<String>,
278    time_range: Option<String>,
279    filters: Option<String>,
280    profile: &str,
281    timeout: f64,
282) -> PyResult<Py<PyAny>> {
283    let client = build_client(profile, timeout)?;
284    client.search(
285        py,
286        query,
287        category,
288        engines,
289        page,
290        max_results,
291        safesearch,
292        region,
293        language,
294        time_range,
295        filters,
296    )
297}
298
299/// One-shot suggestions with a default client.
300#[pyfunction]
301#[pyo3(signature = (query, source=None, region="us-en"))]
302fn suggest(
303    py: Python<'_>,
304    query: &str,
305    source: Option<String>,
306    region: &str,
307) -> PyResult<Py<PyAny>> {
308    build_client("chrome", 15.0)?.suggest(py, query, source, region)
309}
310
311/// One-shot page extraction with a default client.
312#[pyfunction]
313#[pyo3(signature = (url, max_chars=8000, query=None))]
314fn extract(
315    py: Python<'_>,
316    url: &str,
317    max_chars: usize,
318    query: Option<&str>,
319) -> PyResult<Py<PyAny>> {
320    build_client("chrome", 15.0)?.extract(py, url, max_chars, query)
321}
322
323/// One-shot engines listing with a default client.
324#[pyfunction]
325#[pyo3(signature = (category=None))]
326fn engines(py: Python<'_>, category: Option<String>) -> PyResult<Py<PyAny>> {
327    build_client("chrome", 15.0)?.engines(py, category)
328}
329
330#[pyfunction]
331fn version() -> String {
332    phrona_core::version().to_string()
333}
334
335#[pymodule]
336fn phrona(m: &Bound<'_, PyModule>) -> PyResult<()> {
337    m.add_class::<Client>()?;
338    m.add_function(wrap_pyfunction!(search, m)?)?;
339    m.add_function(wrap_pyfunction!(suggest, m)?)?;
340    m.add_function(wrap_pyfunction!(extract, m)?)?;
341    m.add_function(wrap_pyfunction!(engines, m)?)?;
342    m.add_function(wrap_pyfunction!(version, m)?)?;
343    Ok(())
344}