Skip to main content

_formatparse/
lib.rs

1//! formatparse-pyo3: PyO3 bindings for formatparse
2//!
3//! formatparse-pyo3 provides Python bindings for the formatparse-core library.
4
5use pyo3::exceptions::PyNotImplementedError;
6use pyo3::prelude::*;
7use pyo3::types::{PyDict, PyList};
8use pyo3::IntoPyObjectExt;
9use std::collections::HashMap;
10
11mod datetime;
12mod error;
13mod match_rs;
14mod parser;
15mod pattern_cache;
16mod pattern_normalize;
17mod result;
18mod results;
19mod types;
20
21pub(crate) use pattern_cache::extract_extra_types_identity;
22use pattern_cache::get_or_create_parser;
23
24pub use datetime::FixedTzOffset;
25pub use parser::{FindallIter, Format, FormatParser};
26pub use result::*;
27pub use results::Results;
28pub use types::conversion::*;
29// Core types come from formatparse-core
30pub use formatparse_core::strftime_to_regex;
31pub use formatparse_core::{FieldSpec, FieldType};
32pub use match_rs::Match;
33
34pub use error::PatternParseMismatch;
35
36/// Parse a string using a format specification
37#[pyfunction]
38#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true))]
39fn parse(
40    pattern: &str,
41    string: &str,
42    extra_types: Option<HashMap<String, Py<PyAny>>>,
43    case_sensitive: bool,
44    evaluate_result: bool,
45) -> PyResult<Option<Py<PyAny>>> {
46    // Validate input lengths
47    formatparse_core::validate_input_length(string)
48        .map_err(pyo3::exceptions::PyValueError::new_err)?;
49
50    // Check for null bytes in inputs
51    if string.contains('\0') {
52        return Err(pyo3::exceptions::PyValueError::new_err(
53            "Input string contains null byte",
54        ));
55    }
56
57    // Use cached parser if available
58    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
59        extra_types.as_ref().map(|et| {
60            et.iter()
61                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
62                .collect()
63        })
64    });
65    match get_or_create_parser(pattern, extra_types_cloned) {
66        Ok(parser) => parser.parse_internal(
67            string,
68            case_sensitive,
69            extra_types.as_ref(),
70            evaluate_result,
71        ),
72        Err(e) => Python::attach(|py| {
73            if e.is_instance_of::<PyNotImplementedError>(py) {
74                return Err(e);
75            }
76            if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
77                return Ok(None);
78            }
79            Err(e)
80        }),
81    }
82}
83
84/// Parse many strings with the same pattern, compiling the pattern once.
85///
86/// Each input string uses the same semantics as `parse` (including
87/// `extra_types`, `case_sensitive`, and `evaluate_result`). Non-matches
88/// become Python `None` at that index in the returned list.
89#[pyfunction]
90#[pyo3(signature = (pattern, strings, extra_types=None, case_sensitive=false, evaluate_result=true))]
91fn parse_batch(
92    pattern: &str,
93    strings: Vec<String>,
94    extra_types: Option<HashMap<String, Py<PyAny>>>,
95    case_sensitive: bool,
96    evaluate_result: bool,
97) -> PyResult<Py<PyAny>> {
98    for s in &strings {
99        formatparse_core::validate_input_length(s)
100            .map_err(pyo3::exceptions::PyValueError::new_err)?;
101        if s.contains('\0') {
102            return Err(pyo3::exceptions::PyValueError::new_err(
103                "Input string contains null byte",
104            ));
105        }
106    }
107
108    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
109        extra_types.as_ref().map(|et| {
110            et.iter()
111                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
112                .collect()
113        })
114    });
115
116    let parser = match get_or_create_parser(pattern, extra_types_cloned) {
117        Ok(p) => p,
118        Err(e) => {
119            return Python::attach(|py| -> PyResult<Py<PyAny>> {
120                if e.is_instance_of::<PyNotImplementedError>(py) {
121                    return Err(e);
122                }
123                if e.is_instance_of::<crate::error::PatternParseMismatch>(py) {
124                    let none_obj = py.None().into_py_any(py)?;
125                    let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
126                    for _ in 0..strings.len() {
127                        out.push(none_obj.clone_ref(py));
128                    }
129                    let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
130                    return PyList::new(py, items)?.into_py_any(py);
131                }
132                Err(e)
133            });
134        }
135    };
136
137    Python::attach(|py| -> PyResult<Py<PyAny>> {
138        let mut out: Vec<Py<PyAny>> = Vec::with_capacity(strings.len());
139        for s in &strings {
140            match parser.parse_internal(s, case_sensitive, extra_types.as_ref(), evaluate_result)? {
141                Some(obj) => out.push(obj),
142                None => out.push(py.None().into_py_any(py)?),
143            }
144        }
145        let items: Vec<_> = out.iter().map(|o| o.bind(py)).collect();
146        PyList::new(py, items)?.into_py_any(py)
147    })
148}
149
150/// Search for a pattern in a string
151#[pyfunction]
152#[pyo3(signature = (pattern, string, pos=0, endpos=None, extra_types=None, case_sensitive=true, evaluate_result=true))]
153fn search(
154    pattern: &str,
155    string: &str,
156    pos: usize,
157    endpos: Option<usize>,
158    extra_types: Option<HashMap<String, Py<PyAny>>>,
159    case_sensitive: bool,
160    evaluate_result: bool,
161) -> PyResult<Option<Py<PyAny>>> {
162    // Validate pos parameter
163    if pos > string.len() {
164        return Ok(None);
165    }
166
167    // Validate endpos parameter
168    let end = endpos.unwrap_or(string.len());
169    if end > string.len() {
170        return Ok(None);
171    }
172    if end < pos {
173        return Ok(None);
174    }
175
176    // Validate input lengths
177    formatparse_core::validate_input_length(string)
178        .map_err(pyo3::exceptions::PyValueError::new_err)?;
179
180    // Check for null bytes in inputs
181    if string.contains('\0') {
182        return Err(pyo3::exceptions::PyValueError::new_err(
183            "Input string contains null byte",
184        ));
185    }
186
187    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
188        extra_types.as_ref().map(|et| {
189            et.iter()
190                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
191                .collect()
192        })
193    });
194    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
195    let search_string = &string[pos..end];
196
197    if let Some(result) =
198        parser.search_pattern(search_string, case_sensitive, extra_types, evaluate_result)?
199    {
200        // Adjust positions if it's a ParseResult (not Match)
201        Python::attach(|py| {
202            if let Ok(parse_result) = result.bind(py).cast::<ParseResult>() {
203                let result_value = parse_result.borrow();
204                let adjusted = result_value.clone().with_offset(pos);
205                // Py::new() is already optimized when GIL is held
206                Ok(Some(Py::new(py, adjusted)?.into_py_any(py)?))
207            } else if let Ok(match_obj) = result.bind(py).cast::<crate::match_rs::Match>() {
208                let adjusted = match_obj.borrow().clone().with_offset(pos);
209                Ok(Some(Py::new(py, adjusted)?.into_py_any(py)?))
210            } else {
211                Ok(Some(result))
212            }
213        })
214    } else {
215        Ok(None)
216    }
217}
218
219/// Find all matches of a pattern in a string
220#[pyfunction]
221#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true, max_matches=None))]
222fn findall(
223    pattern: &str,
224    string: &str,
225    extra_types: Option<HashMap<String, Py<PyAny>>>,
226    case_sensitive: bool,
227    evaluate_result: bool,
228    max_matches: Option<usize>,
229) -> PyResult<Py<PyAny>> {
230    // Validate input lengths
231    formatparse_core::validate_input_length(string)
232        .map_err(pyo3::exceptions::PyValueError::new_err)?;
233
234    // Check for null bytes in inputs
235    if string.contains('\0') {
236        return Err(pyo3::exceptions::PyValueError::new_err(
237            "Input string contains null byte",
238        ));
239    }
240
241    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
242        extra_types.as_ref().map(|et| {
243            et.iter()
244                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
245                .collect()
246        })
247    });
248    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
249    crate::parser::findall_engine::findall_matches(
250        parser,
251        string,
252        extra_types.as_ref(),
253        case_sensitive,
254        evaluate_result,
255        max_matches,
256    )
257}
258
259/// Iterator over non-overlapping matches (same scan as :func:`findall`, one item per step).
260///
261/// See :class:`FindallIter` for memory semantics and limitations (issue #13 MVP).
262#[pyfunction]
263#[pyo3(signature = (pattern, string, extra_types=None, case_sensitive=false, evaluate_result=true, max_matches=None))]
264fn findall_iter(
265    py: Python<'_>,
266    pattern: &str,
267    string: &str,
268    extra_types: Option<HashMap<String, Py<PyAny>>>,
269    case_sensitive: bool,
270    evaluate_result: bool,
271    max_matches: Option<usize>,
272) -> PyResult<Py<FindallIter>> {
273    formatparse_core::validate_input_length(string)
274        .map_err(pyo3::exceptions::PyValueError::new_err)?;
275
276    if string.contains('\0') {
277        return Err(pyo3::exceptions::PyValueError::new_err(
278            "Input string contains null byte",
279        ));
280    }
281
282    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
283        extra_types.as_ref().map(|et| {
284            et.iter()
285                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
286                .collect()
287        })
288    });
289    let parser = get_or_create_parser(pattern, extra_types_cloned)?;
290
291    let et_map = Python::attach(|py| -> HashMap<String, Py<PyAny>> {
292        extra_types
293            .as_ref()
294            .map(|et| {
295                et.iter()
296                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
297                    .collect()
298            })
299            .unwrap_or_default()
300    });
301
302    Py::new(
303        py,
304        FindallIter::new(
305            parser,
306            string.to_string(),
307            case_sensitive,
308            evaluate_result,
309            et_map,
310            max_matches,
311        ),
312    )
313}
314
315/// Compile a pattern into a FormatParser for reuse.
316///
317/// Uses the same LRU cache as the `parse`, `search`, and `findall` bindings:
318/// `compile` with the same pattern and equivalent `extra_types` keys avoids
319/// rebuilding compiled regexes (see GitHub issue #29).
320#[pyfunction]
321#[pyo3(signature = (pattern, extra_types=None))]
322fn compile(
323    pattern: &str,
324    extra_types: Option<HashMap<String, Py<PyAny>>>,
325) -> PyResult<FormatParser> {
326    let extra_types_cloned = Python::attach(|py| -> Option<HashMap<String, Py<PyAny>>> {
327        extra_types.as_ref().map(|et| {
328            et.iter()
329                .map(|(k, v)| (k.clone(), v.clone_ref(py)))
330                .collect()
331        })
332    });
333    let arc = get_or_create_parser(pattern, extra_types_cloned)?;
334    Ok((*arc).clone())
335}
336
337/// Extract format specification components from a format string
338#[pyfunction]
339#[pyo3(signature = (format_string, _match_dict=None))]
340fn extract_format(
341    format_string: &str,
342    _match_dict: Option<&Bound<'_, PyDict>>,
343) -> PyResult<Py<PyAny>> {
344    use crate::types::FieldSpec;
345
346    // Parse the format spec string
347    let mut spec = FieldSpec::new();
348    formatparse_core::parser::pattern::parse_format_spec(format_string, &mut spec)
349        .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
350    formatparse_core::parser::pattern::validate_multiline_mvp(&spec)
351        .map_err(crate::parser::pattern::pattern_compile_error_to_py)?;
352
353    // Extract type from the original format_string (preserve original type chars like 'o', 'x', 'b')
354    // Parse the format spec to extract the type characters that come after width/precision/alignment
355    let type_str: String = if format_string == "%" {
356        "%".to_string()
357    } else {
358        // Parse format spec to find where type starts
359        // Format: [[fill]align][sign][#][0][width][,][.precision][type]
360        let chars: Vec<char> = format_string.chars().collect();
361        let mut i = 0;
362        let len = chars.len();
363
364        // Skip fill and align
365        if i < len && (chars[i] == '<' || chars[i] == '>' || chars[i] == '^' || chars[i] == '=') {
366            i += 1;
367        } else if i + 1 < len {
368            let ch = chars[i];
369            let next_ch = chars[i + 1];
370            if (next_ch == '<' || next_ch == '>' || next_ch == '^' || next_ch == '=')
371                && ch != next_ch
372            {
373                i += 2; // Skip fill + align
374            }
375        }
376
377        // Skip sign
378        if i < len && (chars[i] == '+' || chars[i] == '-' || chars[i] == ' ') {
379            i += 1;
380        }
381
382        // Skip #
383        if i < len && chars[i] == '#' {
384            i += 1;
385        }
386
387        // Skip 0
388        if i < len && chars[i] == '0' {
389            i += 1;
390        }
391
392        // Skip width (digits)
393        while i < len && chars[i].is_ascii_digit() {
394            i += 1;
395        }
396
397        // Skip comma
398        if i < len && chars[i] == ',' {
399            i += 1;
400        }
401
402        // Skip precision (.digits)
403        if i < len && chars[i] == '.' {
404            i += 1;
405            while i < len && chars[i].is_ascii_digit() {
406                i += 1;
407            }
408        }
409
410        // Type is the rest
411        if i < len {
412            format_string[i..].to_string()
413        } else {
414            "s".to_string() // Default
415        }
416    };
417
418    // Build result dictionary
419    Python::attach(|py| {
420        let result = PyDict::new(py);
421        result.set_item("type", type_str)?;
422
423        // Extract width
424        if let Some(width) = spec.width {
425            result.set_item("width", width.to_string())?;
426        }
427
428        // Extract precision
429        if let Some(precision) = spec.precision {
430            result.set_item("precision", precision.to_string())?;
431        }
432
433        // Extract alignment
434        if let Some(align) = spec.alignment {
435            result.set_item("align", align.to_string())?;
436        }
437
438        // Extract fill
439        if let Some(fill) = spec.fill {
440            result.set_item("fill", fill.to_string())?;
441        }
442
443        // Extract zero padding
444        if spec.zero_pad {
445            result.set_item("zero", true)?;
446        }
447
448        result.into_py_any(py)
449    })
450}
451
452/// Python module definition
453#[pymodule]
454fn _formatparse(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
455    m.add(
456        "PatternParseMismatch",
457        py.get_type::<crate::error::PatternParseMismatch>(),
458    )?;
459    m.add_function(wrap_pyfunction!(parse, m)?)?;
460    m.add_function(wrap_pyfunction!(parse_batch, m)?)?;
461    m.add_function(wrap_pyfunction!(search, m)?)?;
462    m.add_function(wrap_pyfunction!(findall, m)?)?;
463    m.add_function(wrap_pyfunction!(findall_iter, m)?)?;
464    m.add_function(wrap_pyfunction!(compile, m)?)?;
465    m.add_function(wrap_pyfunction!(extract_format, m)?)?;
466    m.add_class::<ParseResult>()?;
467    m.add_class::<FormatParser>()?;
468    m.add_class::<Format>()?;
469    m.add_class::<FixedTzOffset>()?;
470    m.add_class::<Match>()?;
471    m.add_class::<Results>()?;
472    m.add_class::<FindallIter>()?;
473    Ok(())
474}