"""Bidirectional parse and format."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple, Union, cast
from ._native import FormatParser, ParseResult
from .api import compile
from .types import ExtraTypes, FieldConstraint
def _constraints_from_parser(parser: FormatParser) -> List[FieldConstraint]:
"""Build validation constraints from compiled field metadata."""
constraints: List[FieldConstraint] = []
for item in parser.field_constraints:
name = item.get("name")
if name is not None and not isinstance(name, str):
name = None
width = item.get("width")
precision = item.get("precision")
constraints.append(
cast(
FieldConstraint,
{
"name": name,
"type": str(item["type"]),
"width": int(width) if width is not None else None,
"precision": int(precision) if precision is not None else None,
},
)
)
return constraints
[docs]
class BidirectionalPattern:
"""A bidirectional pattern that can parse and format strings.
Enables round-trip parsing: parse → modify → format back, with built-in validation.
This class combines parsing and formatting capabilities, allowing you to parse
a string, modify the extracted values, and format them back while maintaining
the original format constraints.
:param pattern: Format string pattern (e.g., ``"{name:>10}: {value:05d}"``)
:type pattern: str
:param extra_types: Optional dictionary of custom type converters
:type extra_types: dict, optional
Example::
>>> formatter = BidirectionalPattern("{name:>10}: {value:05d}")
>>> result = formatter.parse(" John: 00042")
>>> result.named['name']
'John'
>>> result.named['value']
42
>>> result.format()
' John: 00042'
>>> result.named['value'] = 100
>>> result.format()
' John: 00100'
"""
[docs]
def __init__(self, pattern: str, extra_types: Optional[ExtraTypes] = None) -> None:
"""Initialize a bidirectional pattern.
:param pattern: Format string pattern (e.g., ``"{name:>10}: {value:05d}"``)
:type pattern: str
:param extra_types: Optional dictionary of custom type converters
:type extra_types: dict, optional
"""
self._parser: FormatParser = compile(pattern, extra_types=extra_types)
self._pattern: str = pattern
self._extra_types: Optional[ExtraTypes] = extra_types
self._field_constraints: List[FieldConstraint] = _constraints_from_parser(
self._parser
)
[docs]
def parse(
self, string: str, case_sensitive: bool = False, evaluate_result: bool = True
) -> Optional["BidirectionalResult"]:
"""Parse a string and return BidirectionalResult.
:param string: String to parse
:type string: str
:param case_sensitive: Whether matching is case-sensitive (default: False)
:type case_sensitive: bool
:param evaluate_result: Whether to evaluate result (convert types) (default: True)
:type evaluate_result: bool
:returns: BidirectionalResult if match found, None otherwise
:rtype: BidirectionalResult or None
Example::
>>> formatter = BidirectionalPattern("{name:>10}: {value:05d}")
>>> result = formatter.parse(" John: 00042")
>>> result.named['name']
'John'
>>> result.named['value']
42
"""
result = self._parser.parse(
string,
extra_types=self._extra_types,
case_sensitive=case_sensitive,
evaluate_result=evaluate_result,
)
if result:
return BidirectionalResult(self, result)
return None
[docs]
def validate(
self, values: Union[dict, tuple, ParseResult]
) -> Tuple[bool, List[str]]:
"""
Validate values against format constraints.
Args:
values: Dict (for named fields), tuple (for positional), or ParseResult
Returns:
Tuple of (is_valid, list_of_errors)
"""
errors = []
# Convert values to dict/list format
if isinstance(values, ParseResult):
named_values = dict(values.named) if values.named else {}
fixed_values = list(values.fixed) if values.fixed else []
elif isinstance(values, dict):
named_values = values
fixed_values = []
elif isinstance(values, tuple):
named_values = {}
fixed_values = list(values)
else:
return False, ["Invalid values type: expected dict, tuple, or ParseResult"]
# Validate each field
for i, constraint in enumerate(self._field_constraints):
field_name = constraint["name"]
field_type = constraint["type"]
width = constraint["width"]
precision = constraint["precision"]
# Get value
if field_name:
if field_name not in named_values:
continue # Field not present, skip validation
value = named_values[field_name]
else:
if i >= len(fixed_values):
continue # Positional field not present
value = fixed_values[i]
# Type validation (single-letter built-in tags only; custom types are multi-char)
if len(field_type) == 1:
if field_type == "d" and not isinstance(value, int):
errors.append(
f"Field '{field_name or i}': expected int, got {type(value).__name__}"
)
elif field_type == "f" and not isinstance(value, (int, float)):
errors.append(
f"Field '{field_name or i}': expected float, got {type(value).__name__}"
)
# Width/precision validation for strings
if isinstance(value, str):
if precision is not None and len(value) > precision:
errors.append(
f"Field '{field_name or i}': string length {len(value)} exceeds precision {precision}"
)
if width is not None and len(value) > width:
errors.append(
f"Field '{field_name or i}': string length {len(value)} exceeds width {width}"
)
# Width validation for integers (zero-padded)
if isinstance(value, int) and width is not None:
# Check if value fits in width with zero-padding
# Need to account for sign if negative
value_str = str(abs(value))
sign_len = 1 if value < 0 else 0
if len(value_str) + sign_len > width:
errors.append(
f"Field '{field_name or i}': integer {value} exceeds width {width} (with zero-padding)"
)
return len(errors) == 0, errors
[docs]
class BidirectionalResult:
"""Result from BidirectionalPattern.parse() that allows modification and formatting.
Stores parsed values in a mutable format and provides methods to format back
and validate against the original pattern constraints. Unlike ParseResult, this
class allows you to modify the extracted values and format them back while
maintaining the original format constraints.
Example::
>>> formatter = BidirectionalPattern("{name:>10}: {value:05d}")
>>> result = formatter.parse(" John: 00042")
>>> result.named['value'] = 100
>>> result.format()
' John: 00100'
>>> result.validate()
(True, [])
"""
[docs]
def __init__(self, pattern: BidirectionalPattern, result: ParseResult) -> None:
"""Initialize a bidirectional result.
:param pattern: The BidirectionalPattern that created this result
:type pattern: BidirectionalPattern
:param result: The ParseResult from parsing
:type result: ParseResult
"""
self._pattern: BidirectionalPattern = pattern
self._result: ParseResult = result
self._named: Dict[str, Any] = dict(result.named) if result.named else {}
self._fixed: List[Any] = list(result.fixed) if result.fixed else []
@property
def named(self) -> Dict[str, Any]:
"""Mutable named fields dictionary.
:returns: Dictionary of named fields (can be modified)
:rtype: Dict[str, Any]
Example::
>>> formatter = BidirectionalPattern("{name}: {age:d}")
>>> result = formatter.parse("Alice: 30")
>>> result.named['age'] = 31
>>> result.format()
'Alice: 31'
"""
return self._named
@property
def fixed(self) -> List[Any]:
"""Mutable fixed (positional) fields list.
:returns: List of positional fields (can be modified)
:rtype: List[Any]
Example::
>>> formatter = BidirectionalPattern("{}, {}")
>>> result = formatter.parse("Hello, World")
>>> result.fixed[1] = "Python"
>>> result.format()
'Hello, Python'
"""
return self._fixed
[docs]
def validate(self) -> Tuple[bool, List[str]]:
"""Validate current values against format constraints.
Checks if the current (potentially modified) values conform to the
pattern's constraints (type, width, precision).
:returns: Tuple of (is_valid, list_of_errors)
:rtype: Tuple[bool, List[str]]
Example::
>>> formatter = BidirectionalPattern("{name:>10}: {value:05d}")
>>> result = formatter.parse(" John: 00042")
>>> result.validate()
(True, [])
>>> result.named['value'] = "not a number"
>>> is_valid, errors = result.validate()
>>> is_valid
False
>>> len(errors) > 0
True
"""
# Pass the actual values dict/list, not the wrapper structure
if self._named:
return self._pattern.validate(self._named)
return self._pattern.validate(tuple(self._fixed))
[docs]
def __repr__(self) -> str:
"""String representation"""
if self._named:
return f"<BidirectionalResult {self._named}>"
return f"<BidirectionalResult {self._fixed}>"