1+ from __future__ import annotations
2+
3+ from dataclasses import dataclass
14from typing import Any
5+ from typing import Dict
6+ from typing import Optional
27
38from parse import Parser
49
@@ -18,16 +23,115 @@ class PathParser(Parser): # type: ignore
1823 def __init__(
1924 self, pattern: str, pre_expression: str = "", post_expression: str = ""
2025 ) -> None:
26+ self._orig_to_safe: Dict[str, str] = {}
27+ self._safe_to_orig: Dict[str, str] = {}
28+ self._safe_suffix_counters: Dict[str, int] = {}
2129 extra_types = {
2230 self.parse_path_parameter.name: self.parse_path_parameter
2331 }
24- super().__init__(pattern, extra_types)
32+ sanitized_pattern = self._sanitize_pattern(pattern)
33+ super().__init__(sanitized_pattern, extra_types)
2534 self._expression: str = (
2635 pre_expression + self._expression + post_expression
2736 )
2837
38+ def search(self, string: str, pos: int = 0, endpos: Optional[int] = None) -> Any:
39+ result = super().search(string, pos=pos, endpos=endpos)
40+ if not result:
41+ return result
42+ return _RemappedResult(result, self._safe_to_orig)
43+
44+ def parse(self, string: str, pos: int = 0, endpos: Optional[int] = None) -> Any:
45+ result = super().parse(string, pos=pos, endpos=endpos)
46+ if not result:
47+ return result
48+ return _RemappedResult(result, self._safe_to_orig)
49+
50+ def _get_safe_field_name(self, original: str) -> str:
51+ existing = self._orig_to_safe.get(original)
52+ if existing is not None:
53+ return existing
54+
55+ safe_parts = []
56+ for ch in original:
57+ if ch == "_" or ch.isalnum():
58+ safe_parts.append(ch)
59+ else:
60+ safe_parts.append(f"__{ord(ch):x}__")
61+
62+ safe = "".join(safe_parts) or "p"
63+ # `parse` and Python `re` named groups are most reliable when the group name
64+ # starts with a letter.
65+ if not safe[0].isalpha():
66+ safe = f"p_{safe}"
67+
68+ # Ensure uniqueness across fields within this parser
69+ if safe in self._safe_to_orig and self._safe_to_orig[safe] != original:
70+ base = safe
71+ suffix = self._safe_suffix_counters.get(base, 1)
72+ while True:
73+ candidate = f"{base}__{suffix}"
74+ if candidate not in self._safe_to_orig:
75+ safe = candidate
76+ self._safe_suffix_counters[base] = suffix + 1
77+ break
78+ suffix += 1
79+
80+ self._orig_to_safe[original] = safe
81+ self._safe_to_orig[safe] = original
82+ return safe
83+
84+ def _sanitize_pattern(self, pattern: str) -> str:
85+ # Pre-sanitize field names inside `{...}` before `parse` processes them.
86+ # This ensures special characters (e.g. `~`) and digit-leading names are
87+ # treated as named fields instead of literals or positional groups.
88+ if "{" not in pattern:
89+ return pattern
90+
91+ out: list[str] = []
92+ i = 0
93+ n = len(pattern)
94+ while i < n:
95+ ch = pattern[i]
96+ if ch != "{":
97+ out.append(ch)
98+ i += 1
99+ continue
100+
101+ end = pattern.find("}", i + 1)
102+ if end == -1:
103+ out.append(ch)
104+ i += 1
105+ continue
106+
107+ original = pattern[i + 1 : end]
108+ safe = self._get_safe_field_name(original)
109+ out.append("{")
110+ out.append(safe)
111+ out.append("}")
112+ i = end + 1
113+
114+ return "".join(out)
115+
29116 def _handle_field(self, field: str) -> Any:
30117 # handle as path parameter field
31- field = field[1:-1]
32- path_parameter_field = "{%s:PathParameter}" % field
118+ safe_field = field[1:-1]
119+ path_parameter_field = "{%s:PathParameter}" % safe_field
33120 return super()._handle_field(path_parameter_field)
121+
122+
123+ @dataclass(frozen=True)
124+ class _RemappedResult:
125+ _result: Any
126+ _safe_to_orig: Dict[str, str]
127+
128+ @property
129+ def named(self) -> Dict[str, Any]:
130+ named = getattr(self._result, "named", {})
131+ return {self._safe_to_orig.get(k, k): v for k, v in named.items()}
132+
133+ def __bool__(self) -> bool:
134+ return bool(self._result)
135+
136+ def __getattr__(self, item: str) -> Any:
137+ return getattr(self._result, item)
0 commit comments