Skip to main content

_formatparse/parser/
format_parser_pymethods.rs

1use crate::error;
2use crate::parser::findall_iter::FindallIter;
3use crate::parser::format_parser::{CompiledFields, FormatParser};
4use crate::pattern_cache::get_or_create_parser;
5use fancy_regex::Regex;
6use formatparse_core::count_capturing_groups;
7use formatparse_core::parser::validate_input_length;
8use formatparse_core::{FieldSpec, FieldType};
9use pyo3::exceptions::PyValueError;
10use pyo3::prelude::*;
11use pyo3::types::{PyAnyMethods, PyDict, PyList, PyString, PyTuple};
12use pyo3::IntoPyObjectExt;
13use std::collections::HashMap;
14
15fn merge_call_extra_types(
16    py: Python<'_>,
17    stored: &Option<HashMap<String, Py<PyAny>>>,
18    provided: Option<HashMap<String, Py<PyAny>>>,
19) -> PyResult<Option<HashMap<String, Py<PyAny>>>> {
20    let mut merged = if let Some(ref stored) = stored {
21        stored
22            .iter()
23            .map(|(k, v)| (k.clone(), v.clone_ref(py)))
24            .collect()
25    } else {
26        HashMap::new()
27    };
28    if let Some(provided) = provided {
29        for (k, v) in provided {
30            merged.insert(k.clone(), v.clone_ref(py));
31        }
32    }
33    Ok(Some(merged))
34}
35
36fn field_type_validation_tag(spec: &FieldSpec) -> String {
37    match &spec.field_type {
38        FieldType::String => "s".to_string(),
39        FieldType::Integer => "d".to_string(),
40        FieldType::Float => "f".to_string(),
41        FieldType::Boolean => "b".to_string(),
42        FieldType::Letters => "l".to_string(),
43        FieldType::Word => "w".to_string(),
44        FieldType::NonLetters => "W".to_string(),
45        FieldType::NonWhitespace => "S".to_string(),
46        FieldType::NonDigits => "D".to_string(),
47        FieldType::Custom(name) => name.clone(),
48        FieldType::Nested => "s".to_string(),
49        FieldType::Multiline | FieldType::IndentBlock => "s".to_string(),
50        FieldType::BracedContent => "s".to_string(),
51        FieldType::NumberWithThousands => "n".to_string(),
52        FieldType::Scientific => "e".to_string(),
53        FieldType::GeneralNumber => "g".to_string(),
54        FieldType::Percentage => "%".to_string(),
55        FieldType::DateTimeISO => "ti".to_string(),
56        FieldType::DateTimeRFC2822 => "te".to_string(),
57        FieldType::DateTimeGlobal => "tg".to_string(),
58        FieldType::DateTimeUS => "ta".to_string(),
59        FieldType::DateTimeCtime => "tc".to_string(),
60        FieldType::DateTimeHTTP => "th".to_string(),
61        FieldType::DateTimeTime => "tt".to_string(),
62        FieldType::DateTimeSystem => "ts".to_string(),
63        FieldType::DateTimeStrftime => "%".to_string(),
64    }
65}
66
67#[pymethods]
68impl FormatParser {
69    #[new]
70    #[pyo3(signature = (pattern=None, extra_types=None))]
71    fn new_py(
72        pattern: Option<&str>,
73        extra_types: Option<HashMap<String, Py<PyAny>>>,
74    ) -> PyResult<Self> {
75        match pattern {
76            Some(p) => Self::new_with_extra_types(p, extra_types),
77            None => {
78                // Create a dummy instance for unpickling - __setstate__ will initialize it properly
79                // We need to create a valid but minimal instance
80                let empty_regex =
81                    Regex::new("^$").map_err(|e| crate::error::regex_error(&e.to_string()))?;
82                Ok(Self {
83                    pattern: String::new(),
84                    regex: empty_regex.clone(),
85                    regex_str: String::new(),
86                    regex_case_insensitive: None,
87                    search_regex: empty_regex.clone(),
88                    search_regex_case_insensitive: None,
89                    fields: CompiledFields {
90                        field_specs: Vec::new(),
91                        field_names: Vec::new(),
92                        normalized_names: Vec::new(),
93                        custom_type_groups: Vec::new(),
94                        has_nested_dict_fields: Vec::new(),
95                        nested_parsers: Vec::new(),
96                        field_count: 0,
97                    },
98                    name_mapping: HashMap::new(),
99                    stored_extra_types: None,
100                    allows_empty_default_string_match: false,
101                })
102            }
103        }
104    }
105
106    /// Parse a string using this compiled pattern.
107    ///
108    /// Merges ``extra_types`` from compile time with any ``extra_types`` passed here
109    /// (call-time wins on duplicate keys).
110    #[pyo3(signature = (string, case_sensitive=false, extra_types=None, evaluate_result=true))]
111    fn parse(
112        &self,
113        string: &str,
114        case_sensitive: bool,
115        extra_types: Option<HashMap<String, Py<PyAny>>>,
116        evaluate_result: bool,
117    ) -> PyResult<Option<Py<PyAny>>> {
118        // Validate input length
119        validate_input_length(string).map_err(PyValueError::new_err)?;
120
121        // Check for null bytes
122        if string.contains('\0') {
123            return Err(PyValueError::new_err("Input string contains null byte"));
124        }
125        let merged_extra_types =
126            Python::attach(|py| merge_call_extra_types(py, &self.stored_extra_types, extra_types))?;
127        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
128            let cache_et = merged_extra_types.as_ref().map(|m| {
129                m.iter()
130                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
131                    .collect()
132            });
133            get_or_create_parser(&self.pattern, cache_et)
134        })?;
135        parser.parse_internal(
136            string,
137            case_sensitive,
138            merged_extra_types.as_ref(),
139            evaluate_result,
140        )
141    }
142
143    /// Get the list of named field names (returns normalized names for compatibility)
144    #[getter]
145    fn named_fields(&self) -> Vec<String> {
146        // Return normalized names (without hyphens/dots) for compatibility with original parse library
147        self.fields
148            .normalized_names
149            .iter()
150            .filter_map(|n| n.clone())
151            .collect()
152    }
153
154    /// Per-field layout for bidirectional validation (name, type tag, width, precision).
155    #[getter]
156    fn field_constraints(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
157        let list = PyList::empty(py);
158        for (i, spec) in self.fields.field_specs.iter().enumerate() {
159            let dict = PyDict::new(py);
160            match self.fields.field_names.get(i).and_then(|n| n.as_deref()) {
161                Some(name) => dict.set_item("name", name)?,
162                None => dict.set_item("name", py.None())?,
163            }
164            dict.set_item("type", field_type_validation_tag(spec))?;
165            match spec.width {
166                Some(w) => dict.set_item("width", w as i64)?,
167                None => dict.set_item("width", py.None())?,
168            }
169            match spec.precision {
170                Some(p) => dict.set_item("precision", p as i64)?,
171                None => dict.set_item("precision", py.None())?,
172            }
173            list.append(dict)?;
174        }
175        list.into_py_any(py)
176    }
177
178    /// Raw regex body for this pattern (no ``^``/``$``, no ``(?s)`` prefix).
179    ///
180    /// Intended for **composition** (GitHub issue #7): embed this string as a custom
181    /// type’s ``pattern`` when building a parent pattern via ``extra_types``. Do not use
182    /// :meth:`_expression` for that purpose; it applies display-oriented transforms that are
183    /// not guaranteed to be valid regex fragments.
184    #[getter]
185    fn regex_subpattern(&self) -> String {
186        self.regex_str.clone()
187    }
188
189    /// Number of capturing groups in :meth:`regex_subpattern`.
190    ///
191    /// Use as ``regex_group_count`` on a converter object when composing (see
192    /// :func:`formatparse.composed_type`).
193    #[getter]
194    fn regex_capturing_group_count(&self) -> usize {
195        count_capturing_groups(&self.regex_str)
196    }
197
198    /// Get the internal regex expression string (for testing)
199    /// Returns a canonical format with literal spaces instead of \s+ for compatibility
200    #[getter]
201    fn _expression(&self) -> String {
202        let mut result = self.regex_str.clone();
203
204        // Replace \s+ between capturing groups with literal spaces for canonical format
205        // This matches the original parse library's _expression format
206        result = result.replace(r")\s+(", ") (");
207        // Also replace )\s*( with ) ( for backward compatibility
208        result = result.replace(r")\s*(", ") (");
209
210        // Simplify float patterns to match expected format
211        // Our pattern: ([+-]?(?:\d+\.\d+|\.\d+|\d+\.)(?:[eE][+-]?\d+)?)
212        // Expected: ([-+ ]?\d*\.\d+)
213        // Replace the complex float pattern with the simpler one
214        result = result.replace(
215            r"([+-]?(?:\d+\.\d+|\.\d+|\d+\.)(?:[eE][+-]?\d+)?)",
216            r"([-+ ]?\d*\.\d+)",
217        );
218
219        // For alignment patterns like {:>} that produce "( *(.+?))", we need to unwrap
220        // the outer capturing group to get " *(.+?)" (no outer wrapper)
221        // Only do this for patterns that start with "(" and end with ")" and contain nested groups
222        if result.starts_with("(") && result.ends_with(")") {
223            let inner = &result[1..result.len() - 1];
224            // Check if inner already starts with a space and contains a capturing group
225            if inner.starts_with(" *(") && inner.ends_with(")") {
226                // This is a simple wrapper, unwrap it
227                result = inner.to_string();
228            }
229        }
230
231        result
232    }
233
234    /// Get the format object for formatting values into the pattern
235    #[getter]
236    fn format(&self) -> Format {
237        Format {
238            pattern: self.pattern.clone(),
239        }
240    }
241
242    /// Search for the pattern in a string.
243    ///
244    /// Merges ``extra_types`` from compile time with any ``extra_types`` passed here
245    /// (call-time wins on duplicate keys), same as :meth:`parse`.
246    #[pyo3(signature = (string, case_sensitive=true, extra_types=None, evaluate_result=true))]
247    fn search(
248        &self,
249        string: &str,
250        case_sensitive: bool,
251        extra_types: Option<HashMap<String, Py<PyAny>>>,
252        evaluate_result: bool,
253    ) -> PyResult<Option<Py<PyAny>>> {
254        // Validate input length
255        validate_input_length(string).map_err(PyValueError::new_err)?;
256
257        // Check for null bytes
258        if string.contains('\0') {
259            return Err(PyValueError::new_err("Input string contains null byte"));
260        }
261
262        let merged_extra_types =
263            Python::attach(|py| merge_call_extra_types(py, &self.stored_extra_types, extra_types))?;
264        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
265            let cache_et = merged_extra_types.as_ref().map(|m| {
266                m.iter()
267                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
268                    .collect()
269            });
270            get_or_create_parser(&self.pattern, cache_et)
271        })?;
272        parser.search_pattern(string, case_sensitive, merged_extra_types, evaluate_result)
273    }
274
275    /// Yield non-overlapping matches from ``string`` one at a time.
276    ///
277    /// Same matching rules as :func:`findall`, but each ``__next__`` converts at most one
278    /// match, lowering peak memory when you stream results. This does **not** read
279    /// arbitrary file chunks with backtracking; pair with line-based file iteration only
280    /// when matches cannot span line breaks (see GitHub issue #13).
281    #[pyo3(signature = (string, case_sensitive=false, extra_types=None, evaluate_result=true, max_matches=None))]
282    fn findall_iter(
283        &self,
284        py: Python<'_>,
285        string: &str,
286        case_sensitive: bool,
287        extra_types: Option<HashMap<String, Py<PyAny>>>,
288        evaluate_result: bool,
289        max_matches: Option<usize>,
290    ) -> PyResult<Py<FindallIter>> {
291        validate_input_length(string).map_err(PyValueError::new_err)?;
292
293        if string.contains('\0') {
294            return Err(PyValueError::new_err("Input string contains null byte"));
295        }
296
297        let merged_extra_types =
298            Python::attach(|py| merge_call_extra_types(py, &self.stored_extra_types, extra_types))?;
299        let merged_map = merged_extra_types.unwrap_or_default();
300        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
301            let cache_et = if merged_map.is_empty() {
302                None
303            } else {
304                Some(
305                    merged_map
306                        .iter()
307                        .map(|(k, v)| (k.clone(), v.clone_ref(py)))
308                        .collect(),
309                )
310            };
311            get_or_create_parser(&self.pattern, cache_et)
312        })?;
313        Py::new(
314            py,
315            FindallIter::new(
316                parser,
317                string.to_string(),
318                case_sensitive,
319                evaluate_result,
320                merged_map,
321                max_matches,
322            ),
323        )
324    }
325
326    /// Pickle support: rebuild with ``compile(pattern)`` only (no ``extra_types``).
327    ///
328    /// Custom type converters cannot be serialized reliably; use
329    /// ``compile(pattern, extra_types=...)`` after unpickling if you need them.
330    fn __reduce__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
331        let m = py.import("_formatparse")?;
332        let compile_fn = m.getattr("compile")?;
333        let args = PyTuple::new(py, [&self.pattern])?;
334        PyTuple::new(py, [compile_fn.as_any(), args.as_any()])?.into_py_any(py)
335    }
336
337    /// Pickle state: pattern string only (see class doc for ``extra_types``).
338    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
339        use pyo3::types::PyDict;
340        let state = PyDict::new(py);
341        state.set_item("pattern", &self.pattern)?;
342        state.into_py_any(py)
343    }
344
345    /// Restore from pickle state (pattern only; ``extra_types`` are not recovered).
346    fn __setstate__(&mut self, _py: Python, state: &Bound<'_, PyAny>) -> PyResult<()> {
347        use pyo3::types::PyDict;
348        let dict = state.cast::<PyDict>()?;
349        let pattern: String = dict
350            .get_item("pattern")?
351            .ok_or_else(|| error::missing_field_error("pattern"))?
352            .extract()?;
353
354        // Reconstruct the parser from the pattern
355        let reconstructed = Self::new_with_extra_types(&pattern, None)?;
356
357        // Copy all fields from reconstructed parser
358        self.pattern = reconstructed.pattern;
359        self.regex_str = reconstructed.regex_str;
360        self.regex = reconstructed.regex;
361        self.regex_case_insensitive = reconstructed.regex_case_insensitive;
362        self.search_regex = reconstructed.search_regex;
363        self.search_regex_case_insensitive = reconstructed.search_regex_case_insensitive;
364        self.fields = reconstructed.fields;
365        self.name_mapping = reconstructed.name_mapping;
366        self.stored_extra_types = reconstructed.stored_extra_types;
367        self.allows_empty_default_string_match = reconstructed.allows_empty_default_string_match;
368        Ok(())
369    }
370}
371
372/// Format object that formats values into a pattern string
373#[pyclass]
374pub struct Format {
375    pattern: String,
376}
377
378#[pymethods]
379impl Format {
380    /// Format values into the pattern string using Python's format() method
381    fn format(&self, py: Python, args: &Bound<'_, PyAny>) -> PyResult<String> {
382        // Use Python's string format method to format values into the pattern
383        let pattern_obj = PyString::new(py, &self.pattern);
384        let format_method = pattern_obj.getattr("format")?;
385
386        // Call format with the args (can be a single value, tuple, or *args)
387        let result = if let Ok(tuple) = args.cast::<PyTuple>() {
388            format_method.call1(tuple)?
389        } else {
390            // Single argument
391            format_method.call1((args,))?
392        };
393        result.extract()
394    }
395}