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 = Python::attach(|py| {
126            merge_call_extra_types(py, &self.stored_extra_types, extra_types)
127        })?;
128        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
129            let cache_et = merged_extra_types.as_ref().map(|m| {
130                m.iter()
131                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
132                    .collect()
133            });
134            get_or_create_parser(&self.pattern, cache_et)
135        })?;
136        parser.parse_internal(
137            string,
138            case_sensitive,
139            merged_extra_types.as_ref(),
140            evaluate_result,
141        )
142    }
143
144    /// Get the list of named field names (returns normalized names for compatibility)
145    #[getter]
146    fn named_fields(&self) -> Vec<String> {
147        // Return normalized names (without hyphens/dots) for compatibility with original parse library
148        self.fields
149            .normalized_names
150            .iter()
151            .filter_map(|n| n.clone())
152            .collect()
153    }
154
155    /// Per-field layout for bidirectional validation (name, type tag, width, precision).
156    #[getter]
157    fn field_constraints(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
158        let list = PyList::empty(py);
159        for (i, spec) in self.fields.field_specs.iter().enumerate() {
160            let dict = PyDict::new(py);
161            match self.fields.field_names.get(i).and_then(|n| n.as_deref()) {
162                Some(name) => dict.set_item("name", name)?,
163                None => dict.set_item("name", py.None())?,
164            }
165            dict.set_item("type", field_type_validation_tag(spec))?;
166            match spec.width {
167                Some(w) => dict.set_item("width", w as i64)?,
168                None => dict.set_item("width", py.None())?,
169            }
170            match spec.precision {
171                Some(p) => dict.set_item("precision", p as i64)?,
172                None => dict.set_item("precision", py.None())?,
173            }
174            list.append(dict)?;
175        }
176        list.into_py_any(py)
177    }
178
179    /// Raw regex body for this pattern (no ``^``/``$``, no ``(?s)`` prefix).
180    ///
181    /// Intended for **composition** (GitHub issue #7): embed this string as a custom
182    /// type’s ``pattern`` when building a parent pattern via ``extra_types``. Do not use
183    /// :meth:`_expression` for that purpose; it applies display-oriented transforms that are
184    /// not guaranteed to be valid regex fragments.
185    #[getter]
186    fn regex_subpattern(&self) -> String {
187        self.regex_str.clone()
188    }
189
190    /// Number of capturing groups in :meth:`regex_subpattern`.
191    ///
192    /// Use as ``regex_group_count`` on a converter object when composing (see
193    /// :func:`formatparse.composed_type`).
194    #[getter]
195    fn regex_capturing_group_count(&self) -> usize {
196        count_capturing_groups(&self.regex_str)
197    }
198
199    /// Get the internal regex expression string (for testing)
200    /// Returns a canonical format with literal spaces instead of \s+ for compatibility
201    #[getter]
202    fn _expression(&self) -> String {
203        let mut result = self.regex_str.clone();
204
205        // Replace \s+ between capturing groups with literal spaces for canonical format
206        // This matches the original parse library's _expression format
207        result = result.replace(r")\s+(", ") (");
208        // Also replace )\s*( with ) ( for backward compatibility
209        result = result.replace(r")\s*(", ") (");
210
211        // Simplify float patterns to match expected format
212        // Our pattern: ([+-]?(?:\d+\.\d+|\.\d+|\d+\.)(?:[eE][+-]?\d+)?)
213        // Expected: ([-+ ]?\d*\.\d+)
214        // Replace the complex float pattern with the simpler one
215        result = result.replace(
216            r"([+-]?(?:\d+\.\d+|\.\d+|\d+\.)(?:[eE][+-]?\d+)?)",
217            r"([-+ ]?\d*\.\d+)",
218        );
219
220        // For alignment patterns like {:>} that produce "( *(.+?))", we need to unwrap
221        // the outer capturing group to get " *(.+?)" (no outer wrapper)
222        // Only do this for patterns that start with "(" and end with ")" and contain nested groups
223        if result.starts_with("(") && result.ends_with(")") {
224            let inner = &result[1..result.len() - 1];
225            // Check if inner already starts with a space and contains a capturing group
226            if inner.starts_with(" *(") && inner.ends_with(")") {
227                // This is a simple wrapper, unwrap it
228                result = inner.to_string();
229            }
230        }
231
232        result
233    }
234
235    /// Get the format object for formatting values into the pattern
236    #[getter]
237    fn format(&self) -> Format {
238        Format {
239            pattern: self.pattern.clone(),
240        }
241    }
242
243    /// Search for the pattern in a string.
244    ///
245    /// Merges ``extra_types`` from compile time with any ``extra_types`` passed here
246    /// (call-time wins on duplicate keys), same as :meth:`parse`.
247    #[pyo3(signature = (string, case_sensitive=true, extra_types=None, evaluate_result=true))]
248    fn search(
249        &self,
250        string: &str,
251        case_sensitive: bool,
252        extra_types: Option<HashMap<String, Py<PyAny>>>,
253        evaluate_result: bool,
254    ) -> PyResult<Option<Py<PyAny>>> {
255        // Validate input length
256        validate_input_length(string).map_err(PyValueError::new_err)?;
257
258        // Check for null bytes
259        if string.contains('\0') {
260            return Err(PyValueError::new_err("Input string contains null byte"));
261        }
262
263        let merged_extra_types = Python::attach(|py| {
264            merge_call_extra_types(py, &self.stored_extra_types, extra_types)
265        })?;
266        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
267            let cache_et = merged_extra_types.as_ref().map(|m| {
268                m.iter()
269                    .map(|(k, v)| (k.clone(), v.clone_ref(py)))
270                    .collect()
271            });
272            get_or_create_parser(&self.pattern, cache_et)
273        })?;
274        parser.search_pattern(string, case_sensitive, merged_extra_types, evaluate_result)
275    }
276
277    /// Yield non-overlapping matches from ``string`` one at a time.
278    ///
279    /// Same matching rules as :func:`findall`, but each ``__next__`` converts at most one
280    /// match, lowering peak memory when you stream results. This does **not** read
281    /// arbitrary file chunks with backtracking; pair with line-based file iteration only
282    /// when matches cannot span line breaks (see GitHub issue #13).
283    #[pyo3(signature = (string, case_sensitive=false, extra_types=None, evaluate_result=true, max_matches=None))]
284    fn findall_iter(
285        &self,
286        py: Python<'_>,
287        string: &str,
288        case_sensitive: bool,
289        extra_types: Option<HashMap<String, Py<PyAny>>>,
290        evaluate_result: bool,
291        max_matches: Option<usize>,
292    ) -> PyResult<Py<FindallIter>> {
293        validate_input_length(string).map_err(PyValueError::new_err)?;
294
295        if string.contains('\0') {
296            return Err(PyValueError::new_err("Input string contains null byte"));
297        }
298
299        let merged_extra_types = Python::attach(|py| {
300            merge_call_extra_types(py, &self.stored_extra_types, extra_types)
301        })?;
302        let merged_map = merged_extra_types.unwrap_or_default();
303        let parser = Python::attach(|py| -> PyResult<std::sync::Arc<FormatParser>> {
304            let cache_et = if merged_map.is_empty() {
305                None
306            } else {
307                Some(
308                    merged_map
309                        .iter()
310                        .map(|(k, v)| (k.clone(), v.clone_ref(py)))
311                        .collect(),
312                )
313            };
314            get_or_create_parser(&self.pattern, cache_et)
315        })?;
316        Py::new(
317            py,
318            FindallIter::new(
319                parser,
320                string.to_string(),
321                case_sensitive,
322                evaluate_result,
323                merged_map,
324                max_matches,
325            ),
326        )
327    }
328
329    /// Pickle support: rebuild with ``compile(pattern)`` only (no ``extra_types``).
330    ///
331    /// Custom type converters cannot be serialized reliably; use
332    /// ``compile(pattern, extra_types=...)`` after unpickling if you need them.
333    fn __reduce__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
334        let m = py.import("_formatparse")?;
335        let compile_fn = m.getattr("compile")?;
336        let args = PyTuple::new(py, [&self.pattern])?;
337        PyTuple::new(py, [compile_fn.as_any(), args.as_any()])?.into_py_any(py)
338    }
339
340    /// Pickle state: pattern string only (see class doc for ``extra_types``).
341    fn __getstate__(&self, py: Python) -> PyResult<Py<PyAny>> {
342        use pyo3::types::PyDict;
343        let state = PyDict::new(py);
344        state.set_item("pattern", &self.pattern)?;
345        state.into_py_any(py)
346    }
347
348    /// Restore from pickle state (pattern only; ``extra_types`` are not recovered).
349    fn __setstate__(&mut self, _py: Python, state: &Bound<'_, PyAny>) -> PyResult<()> {
350        use pyo3::types::PyDict;
351        let dict = state.cast::<PyDict>()?;
352        let pattern: String = dict
353            .get_item("pattern")?
354            .ok_or_else(|| error::missing_field_error("pattern"))?
355            .extract()?;
356
357        // Reconstruct the parser from the pattern
358        let reconstructed = Self::new_with_extra_types(&pattern, None)?;
359
360        // Copy all fields from reconstructed parser
361        self.pattern = reconstructed.pattern;
362        self.regex_str = reconstructed.regex_str;
363        self.regex = reconstructed.regex;
364        self.regex_case_insensitive = reconstructed.regex_case_insensitive;
365        self.search_regex = reconstructed.search_regex;
366        self.search_regex_case_insensitive = reconstructed.search_regex_case_insensitive;
367        self.fields = reconstructed.fields;
368        self.name_mapping = reconstructed.name_mapping;
369        self.stored_extra_types = reconstructed.stored_extra_types;
370        self.allows_empty_default_string_match = reconstructed.allows_empty_default_string_match;
371        Ok(())
372    }
373}
374
375/// Format object that formats values into a pattern string
376#[pyclass]
377pub struct Format {
378    pattern: String,
379}
380
381#[pymethods]
382impl Format {
383    /// Format values into the pattern string using Python's format() method
384    fn format(&self, py: Python, args: &Bound<'_, PyAny>) -> PyResult<String> {
385        // Use Python's string format method to format values into the pattern
386        let pattern_obj = PyString::new(py, &self.pattern);
387        let format_method = pattern_obj.getattr("format")?;
388
389        // Call format with the args (can be a single value, tuple, or *args)
390        let result = if let Ok(tuple) = args.cast::<PyTuple>() {
391            format_method.call1(tuple)?
392        } else {
393            // Single argument
394            format_method.call1((args,))?
395        };
396        result.extract()
397    }
398}