Skip to content

Commit 461360b

Browse files
Jens-Gclaude
andcommitted
THRIFT-6115: Escape Python-keyword names in service extends and cross-module type references
Client: py type_name(), the shared helper rendering any struct/enum/exception/ service reference as a Python expression, never escaped the identifier on any of its three return paths. This broke two more cases sharing Thrift5927.thrift's existing keyword-named struct/service fixture data: - A service "extends" clause naming a keyword-named parent service produced a broken "import module.<keyword>" statement (a direct call, not through type_name) and a broken base-class reference "class Client(module.<keyword>.Client):" (through type_name). - A struct field, deserializer, or "except <Type> as <name>:" clause referencing a keyword-named type across an "include" boundary produced a broken "module.ttypes.<keyword>" reference. Escape the identifier at type_name()'s three return points and at the one direct (non-type_name) extends-import call site. One added wrinkle: True/False/None are Python keyword *literals*, not statement keywords, so an unescaped "except True as e:" parses without a SyntaxError -- it silently binds the wrong object and would raise a runtime TypeError if ever hit. py_compile-based testing alone can't see that, so test_keyword_escape.py also walks the AST of each generated file and fails if any exception handler's type is a bare keyword-literal Constant. Test fixtures: a same-file extends of the existing "continue" service, plus a new thrift5927include.thrift (modeled on tutorial/shared.thrift + tutorial/tutorial.thrift's include pattern) providing a keyword-named struct and service, included and referenced/extended from Thrift5927.thrift the same way tutorial.thrift uses shared.thrift. This depends on THRIFT-6114 for the service-extends-a-keyword-named- service case specifically (6114 fixes the parent's own module filename; this fixes the child's reference to it), so this branch is based on THRIFT-6114 rather than master. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 5f33aee commit 461360b

4 files changed

Lines changed: 85 additions & 7 deletions

File tree

compiler/cpp/src/thrift/generate/t_py_generator.cc

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,7 @@ void t_py_generator::generate_service(t_service* tservice) {
12981298
if (tservice->get_extends() != nullptr) {
12991299
f_service_ << "import "
13001300
<< get_real_py_module(tservice->get_extends()->get_program(), gen_twisted_, package_prefix_) << "."
1301-
<< tservice->get_extends()->get_name() << '\n';
1301+
<< maybe_escape_identifier(tservice->get_extends()->get_name()) << '\n';
13021302
}
13031303

13041304
f_service_ << "import logging" << '\n'
@@ -2877,12 +2877,12 @@ string t_py_generator::type_name(t_type* ttype) {
28772877

28782878
t_program* program = ttype->get_program();
28792879
if (ttype->is_service()) {
2880-
return get_real_py_module(program, gen_twisted_, package_prefix_) + "." + ttype->get_name();
2880+
return get_real_py_module(program, gen_twisted_, package_prefix_) + "." + maybe_escape_identifier(ttype->get_name());
28812881
}
28822882
if (program != nullptr && program != program_) {
2883-
return get_real_py_module(program, gen_twisted_, package_prefix_) + ".ttypes." + ttype->get_name();
2883+
return get_real_py_module(program, gen_twisted_, package_prefix_) + ".ttypes." + maybe_escape_identifier(ttype->get_name());
28842884
}
2885-
return ttype->get_name();
2885+
return maybe_escape_identifier(ttype->get_name());
28862886
}
28872887

28882888
string t_py_generator::arg_hint(t_type* type) {

lib/py/test/test_compiler/Thrift5927.thrift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717

1818
namespace py thrift5927
1919

20-
enum Lambda {
21-
None = 0,
22-
FluxCapacitor = 1
20+
include "thrift5927include.thrift"
21+
22+
enum Lambda {
23+
None = 0,
24+
FluxCapacitor = 1
2325
}
2426

2527
struct False {
@@ -39,3 +41,20 @@ service continue {
3941
import return(1: False while) throws (1: True yield)
4042
}
4143

44+
# Exercises type_name()'s cross-module branch: a field typed with a struct
45+
# defined in an included file, where that struct's name is a keyword.
46+
struct UsesIncluded {
47+
1: thrift5927include.except item
48+
}
49+
50+
# Exercises the extends import/base-class code path with a keyword-named
51+
# parent service defined in the same file.
52+
service AlsoDerived extends continue {
53+
}
54+
55+
# Exercises type_name()'s service branch for both "extends" (import of the
56+
# parent's module + base class reference) and the cross-module include path
57+
# at once, since the parent service's name is also a keyword.
58+
service Derived extends thrift5927include.class {
59+
}
60+

lib/py/test/test_compiler/test_keyword_escape.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
# specific language governing permissions and limitations
1717
# under the License.
1818

19+
import ast
1920
import os
2021
import sys
2122
import subprocess
@@ -61,6 +62,27 @@ def find_thrift():
6162
return None
6263

6364

65+
def find_keyword_literal_except_types(py_files):
66+
"""
67+
"True"/"False"/"None" are valid Python expression atoms (unlike most
68+
other reserved words), so an unescaped "except True as e:" parses fine
69+
-- it just silently tries to catch the literal bool/None instead of the
70+
intended exception class, and would raise a runtime TypeError once hit.
71+
py_compile can't see this; walk the AST instead.
72+
"""
73+
problems = []
74+
for py_file in py_files:
75+
with open(py_file) as f:
76+
try:
77+
tree = ast.parse(f.read(), filename=py_file)
78+
except SyntaxError:
79+
continue # already reported by the compile check
80+
for node in ast.walk(tree):
81+
if isinstance(node, ast.ExceptHandler) and isinstance(node.type, ast.Constant):
82+
problems.append((py_file, node.lineno, repr(node.type.value)))
83+
return problems
84+
85+
6486
def test_keyword_escape_compilation():
6587
"""
6688
Test that the Python generator produces valid Python code
@@ -118,6 +140,13 @@ def test_keyword_escape_compilation():
118140
print(" " + file_path + ": " + error)
119141
return 1
120142

143+
keyword_literal_excepts = find_keyword_literal_except_types(py_files)
144+
if keyword_literal_excepts:
145+
print("ERROR: Generated code catches a keyword literal instead of an exception class:")
146+
for file_path, lineno, value in keyword_literal_excepts:
147+
print(" " + file_path + ":" + str(lineno) + ": except " + value + " as ...")
148+
return 1
149+
121150
print("OK: All " + str(len(all_files)) + " generated Python files compile successfully")
122151
return 0
123152

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# Included by Thrift5927.thrift to exercise cross-module (include) references
19+
# to Python-keyword-colliding struct and service names, the same way
20+
# tutorial/tutorial.thrift includes tutorial/shared.thrift.
21+
22+
namespace py thrift5927include
23+
24+
struct except {
25+
1: i32 value
26+
}
27+
28+
service class {
29+
except getValue(1: i32 key)
30+
}

0 commit comments

Comments
 (0)