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