1"""
2:func:`~pandas.eval` source string parsing functions
3"""
4
5from __future__ import annotations
6
7from enum import Enum
8from io import StringIO
9from keyword import iskeyword
10import token
11import tokenize
12from typing import TYPE_CHECKING
13
14if TYPE_CHECKING:
15 from collections.abc import (
16 Hashable,
17 Iterator,
18 )
19
20# A token value Python's tokenizer probably will never use.
21BACKTICK_QUOTED_STRING = 100
22
23
24def create_valid_python_identifier(name: str) -> str:
25 """
26 Create valid Python identifiers from any string.
27
28 Check if name contains any special characters. If it contains any
29 special characters, the special characters will be replaced by
30 a special string and a prefix is added.
31
32 Raises
33 ------
34 SyntaxError
35 If the returned name is not a Python valid identifier, raise an exception.
36 """
37 if name.isidentifier() and not iskeyword(name):
38 return name
39
40 # Escape characters that fall outside the ASCII range (U+0001..U+007F).
41 # GH 49633
42 gen = (
43 (c, "".join(chr(b) for b in c.encode("ascii", "backslashreplace")))
44 for c in name
45 )
46 name = "".join(
47 c_escaped.replace("\\", "_UNICODE_" if c != c_escaped else "_BACKSLASH_")
48 for c, c_escaped in gen
49 )
50
51 # Create a dict with the special characters and their replacement string.
52 # EXACT_TOKEN_TYPES contains these special characters
53 # token.tok_name contains a readable description of the replacement string.
54 special_characters_replacements = {
55 char: f"_{token.tok_name[tokval]}_"
56 for char, tokval in (tokenize.EXACT_TOKEN_TYPES.items())
57 }
58 special_characters_replacements.update(
59 {
60 " ": "_",
61 "?": "_QUESTIONMARK_",
62 "!": "_EXCLAMATIONMARK_",
63 "$": "_DOLLARSIGN_",
64 "€": "_EUROSIGN_",
65 "°": "_DEGREESIGN_",
66 "'": "_SINGLEQUOTE_",
67 '"': "_DOUBLEQUOTE_",
68 "#": "_HASH_",
69 "`": "_BACKTICK_",
70 }
71 )
72
73 name = "".join([special_characters_replacements.get(char, char) for char in name])
74 name = f"BACKTICK_QUOTED_STRING_{name}"
75
76 if not name.isidentifier():
77 raise SyntaxError(f"Could not convert '{name}' to a valid Python identifier.")
78
79 return name
80
81
82def clean_backtick_quoted_toks(tok: tuple[int, str]) -> tuple[int, str]:
83 """
84 Clean up a column name if surrounded by backticks.
85
86 Backtick quoted string are indicated by a certain tokval value. If a string
87 is a backtick quoted token it will processed by
88 :func:`_create_valid_python_identifier` so that the parser can find this
89 string when the query is executed.
90 In this case the tok will get the NAME tokval.
91
92 Parameters
93 ----------
94 tok : tuple of int, str
95 ints correspond to the all caps constants in the tokenize module
96
97 Returns
98 -------
99 tok : Tuple[int, str]
100 Either the input or token or the replacement values
101 """
102 toknum, tokval = tok
103 if toknum == BACKTICK_QUOTED_STRING:
104 return tokenize.NAME, create_valid_python_identifier(tokval)
105 return toknum, tokval
106
107
108def clean_column_name(name: Hashable) -> Hashable:
109 """
110 Function to emulate the cleaning of a backtick quoted name.
111
112 The purpose for this function is to see what happens to the name of
113 identifier if it goes to the process of being parsed a Python code
114 inside a backtick quoted string and than being cleaned
115 (removed of any special characters).
116
117 Parameters
118 ----------
119 name : hashable
120 Name to be cleaned.
121
122 Returns
123 -------
124 name : hashable
125 Returns the name after tokenizing and cleaning.
126 """
127 try:
128 # Escape backticks
129 name = name.replace("`", "``") if isinstance(name, str) else name
130
131 tokenized = tokenize_string(f"`{name}`")
132 tokval = next(tokenized)[1]
133 return create_valid_python_identifier(tokval)
134 except SyntaxError:
135 return name
136
137
138class ParseState(Enum):
139 DEFAULT = 0
140 IN_BACKTICK = 1
141 IN_SINGLE_QUOTE = 2
142 IN_DOUBLE_QUOTE = 3
143
144
145def _split_by_backtick(s: str) -> list[tuple[bool, str]]:
146 """
147 Splits a str into substrings along backtick characters (`).
148
149 Disregards backticks inside quotes.
150
151 Parameters
152 ----------
153 s : str
154 The Python source code string.
155
156 Returns
157 -------
158 substrings: list[tuple[bool, str]]
159 List of tuples, where each tuple has two elements:
160 The first is a boolean indicating if the substring is backtick-quoted.
161 The second is the actual substring.
162 """
163 substrings = []
164 substr: list[str] = [] # Will join into a string before adding to `substrings`
165 i = 0
166 parse_state = ParseState.DEFAULT
167 while i < len(s):
168 char = s[i]
169
170 match char:
171 case "`":
172 # start of a backtick-quoted string
173 if parse_state == ParseState.DEFAULT:
174 if substr:
175 substrings.append((False, "".join(substr)))
176
177 substr = [char]
178 i += 1
179 parse_state = ParseState.IN_BACKTICK
180 continue
181
182 elif parse_state == ParseState.IN_BACKTICK:
183 # escaped backtick inside a backtick-quoted string
184 next_char = s[i + 1] if (i != len(s) - 1) else None
185 if next_char == "`":
186 substr.append(char)
187 substr.append(next_char)
188 i += 2
189 continue
190
191 # end of the backtick-quoted string
192 else:
193 substr.append(char)
194 substrings.append((True, "".join(substr)))
195
196 substr = []
197 i += 1
198 parse_state = ParseState.DEFAULT
199 continue
200 case "'":
201 # start of a single-quoted string
202 if parse_state == ParseState.DEFAULT:
203 parse_state = ParseState.IN_SINGLE_QUOTE
204 # end of a single-quoted string
205 elif (parse_state == ParseState.IN_SINGLE_QUOTE) and (s[i - 1] != "\\"):
206 parse_state = ParseState.DEFAULT
207 case '"':
208 # start of a double-quoted string
209 if parse_state == ParseState.DEFAULT:
210 parse_state = ParseState.IN_DOUBLE_QUOTE
211 # end of a double-quoted string
212 elif (parse_state == ParseState.IN_DOUBLE_QUOTE) and (s[i - 1] != "\\"):
213 parse_state = ParseState.DEFAULT
214 substr.append(char)
215 i += 1
216
217 if substr:
218 substrings.append((False, "".join(substr)))
219
220 return substrings
221
222
223def tokenize_string(source: str) -> Iterator[tuple[int, str]]:
224 """
225 Tokenize a Python source code string.
226
227 Parameters
228 ----------
229 source : str
230 The Python source code string.
231
232 Returns
233 -------
234 tok_generator : Iterator[Tuple[int, str]]
235 An iterator yielding all tokens with only toknum and tokval (Tuple[ing, str]).
236 """
237 # GH 59285
238 # Escape characters, including backticks
239 source = "".join(
240 (
241 create_valid_python_identifier(substring[1:-1])
242 if is_backtick_quoted
243 else substring
244 )
245 for is_backtick_quoted, substring in _split_by_backtick(source)
246 )
247
248 line_reader = StringIO(source).readline
249 token_generator = tokenize.generate_tokens(line_reader)
250
251 for toknum, tokval, _, _, _ in token_generator:
252 yield toknum, tokval