diff --git a/pydough/errors/error_utils.py b/pydough/errors/error_utils.py index b2956ce53..098ff223c 100644 --- a/pydough/errors/error_utils.py +++ b/pydough/errors/error_utils.py @@ -27,11 +27,15 @@ "is_positive_int", "is_string", "is_valid_name", + "is_valid_sql_name", "simple_join_keys_predicate", "unique_properties_predicate", ] +import builtins +import keyword +import re from abc import ABC, abstractmethod import numpy as np @@ -97,11 +101,189 @@ class ValidName(PyDoughPredicate): as the name of a PyDough graph/collection/property. """ + def __init__(self): + self.error_messages: dict[str, str] = { + "identifier": "must be a string that is a valid Python identifier", + "python_keyword": "must be a string that is not a Python reserved word or built-in name", + "pydough_keyword": "must be a string that is not a PyDough reserved word", + "sql_keyword": "must be a string that is not a SQL reserved word", + } + + def _error_code(self, obj: object) -> str | None: + """Return an error code if invalid, or None if valid.""" + ret_val: str | None = None + # Check that obj is a string + if isinstance(obj, str): + # Check that obj is a valid Python identifier + if not obj.isidentifier(): + ret_val = "identifier" + # Check that obj is not a Python reserved word or built-in name + elif keyword.iskeyword(obj) or hasattr(builtins, obj): + ret_val = "python_keyword" + # Check that obj is not a PyDough reserved word + elif self._is_pydough_keyword(obj): + ret_val = "pydough_keyword" + else: + ret_val = "identifier" + + return ret_val + + def _is_pydough_keyword(self, name: str) -> bool: + """ + helper: Verifies if name is a PyDough reserved word. + Extend with new PyDough reserved words if required. + """ + # Dictionary of all registered operators pre-built from the PyDough source + from pydough.pydough_operators import builtin_registered_operators + + # Set of collection operators + PYDOUGH_RESERVED: set[str] = { + "CALCULATE", + "WHERE", + "ORDER_BY", + "TOP_K", + "PARTITION", + "SINGULAR", + "BEST", + "CROSS", + } + return (name in PYDOUGH_RESERVED) or (name in builtin_registered_operators()) + def accept(self, obj: object) -> bool: - return isinstance(obj, str) and obj.isidentifier() + return self._error_code(obj) is None def error_message(self, error_name: str) -> str: - return f"{error_name} must be a string that is a Python identifier" + # Generic fallback (since we don't have the object here) + return f"{error_name} must be a valid identifier and not a reserved word" + + def verify(self, obj: object, error_name: str) -> None: + code: str | None = self._error_code(obj) + if code is not None: + raise PyDoughMetadataException(f"{error_name} {self.error_messages[code]}") + + +class ValidSQLName(PyDoughPredicate): + """Predicate class to check that an object is a string that can be used + as the name for a SQL table path/column name. + """ + + # Regex for unquoted SQL identifiers + _UNQUOTED_SQL_IDENTIFIER = re.compile( + r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$" + ) + + def __init__(self): + self.error_messages: dict[str, str] = { + "identifier": "must have a SQL name that is a valid SQL identifier", + "sql_keyword": "must have a SQL name that is not a reserved word", + } + + def _error_code(self, obj: object) -> str | None: + """Return an error code if invalid, or None if valid.""" + ret_val: str | None = None + # Check that obj is a string + if isinstance(obj, str): + # Check that obj is a valid SQL identifier + if not self.is_valid_sql_identifier(obj): + ret_val = "identifier" + # Check that obj is not a SQL reserved word + elif self._is_sql_keyword(obj): + ret_val = "sql_keyword" + else: + ret_val = "identifier" + + return ret_val + + def is_valid_sql_identifier(self, name: str) -> bool: + """ + Check if a string is a valid SQL identifier. + + - Unquoted: starts with letter/underscore, then letters, digits, + underscores. + - Double-quoted: allows any chars, but " "" " is the only valid way to + include a double-quote char. + - Backtick-quoted: allows any chars, but `` `` `` is the only valid + way to include a backtick char. + """ + if not name: + return False + + # Case 1: unquoted + if self._UNQUOTED_SQL_IDENTIFIER.match(name): + return True + + # Case 2: double quoted + if name.startswith('"') and name.endswith('"'): + inner = name[1:-1] + # Any " must be escaped as "" + return '"' not in inner.replace('""', "") + + # Case 3: backtick quoted + if name.startswith("`") and name.endswith("`"): + inner = name[1:-1] + # Any ` must be escaped as `` + return "`" not in inner.replace("``", "") + + return False + + # fmt: off + SQL_RESERVED_KEYWORDS: set[str] = { + # Query & DML + "select", "from", "where", "group", "having", "distinct", "as", + "join", "inner", "union", "intersect", "except", + + # DDL & schema + "create", "alter", "drop", "table", "view", "index", "sequence", + "trigger", "schema", "database", "column", "constraint", + + # DML + "insert", "update", "delete", "into", "values", "set", + + # Control flow & logical + "and", "or", "not", "in", "is", "like", "between", "case", "when", + "then", "else", "end", "exists", + + # Transaction & session + "begin", "commit", "rollback", "savepoint", "transaction", + "lock", "grant", "revoke", + + # Data types + "int", "integer", "bigint", "smallint", "decimal", "numeric", + "float", "real", "double", "char", "varchar", "text", + "timestamp", "boolean", "null", + + # Functions + "cast", + } + """ + Set of SQL reserved keywords that may cause conflicts when used as table or + column names. This list was compiled from commonly reserved terms across + multiple SQL dialects (e.g., PostgreSQL, SQLite, MySQL), with emphasis on + keywords that are likely to appear in generated SQL statements. + If any of these are used as identifiers, they must be properly escaped to + avoid syntax errors. + """ + # fmt: on + + def _is_sql_keyword(self, name: str) -> bool: + """ + helper: Verifies if name is a SQL reserved word. + Uses SQL_RESERVED_KEYWORDS set. + Extend with new SQL reserved words if required. + """ + return name.lower() in self.SQL_RESERVED_KEYWORDS + + def accept(self, obj: object) -> bool: + return self._error_code(obj) is None + + def error_message(self, error_name: str) -> str: + # Generic fallback (since we don't have the object here) + return f"{error_name} must be a valid SQL identifier and not a reserved word" + + def verify(self, obj: object, error_name: str) -> None: + code: str | None = self._error_code(obj) + if code is not None: + raise PyDoughMetadataException(f"{error_name} {self.error_messages[code]}") class NoExtraKeys(PyDoughPredicate): @@ -304,6 +486,7 @@ def error_message(self, error_name: str) -> str: ############################################################################### is_valid_name: PyDoughPredicate = ValidName() +is_valid_sql_name: PyDoughPredicate = ValidSQLName() is_integer = HasType(int, "integer") is_string = HasType(str, "string") is_bool = HasType(bool, "boolean") diff --git a/pydough/metadata/collections/collection_metadata.py b/pydough/metadata/collections/collection_metadata.py index ffe31624f..a612d63e1 100644 --- a/pydough/metadata/collections/collection_metadata.py +++ b/pydough/metadata/collections/collection_metadata.py @@ -48,8 +48,8 @@ def __init__( PropertyMetadata, ) - is_valid_name.verify(name, "name") - HasType(GraphMetadata).verify(graph, "graph") + is_valid_name.verify(name, f"collection name {name!r}") + HasType(GraphMetadata).verify(graph, f"graph {name!r}") self._graph: GraphMetadata = graph self._name: str = name diff --git a/pydough/metadata/collections/simple_table_metadata.py b/pydough/metadata/collections/simple_table_metadata.py index deadb374a..14940646b 100644 --- a/pydough/metadata/collections/simple_table_metadata.py +++ b/pydough/metadata/collections/simple_table_metadata.py @@ -10,6 +10,7 @@ extract_array, extract_string, is_string, + is_valid_sql_name, unique_properties_predicate, ) from pydough.metadata.graphs import GraphMetadata @@ -146,6 +147,7 @@ def parse_from_json( # Extract the relevant properties from the JSON to build the new # collection, then add it to the graph. table_path: str = extract_string(collection_json, "table path", error_name) + is_valid_sql_name.verify(table_path, error_name) HasPropertyWith("unique properties", unique_properties_predicate).verify( collection_json, error_name ) @@ -161,8 +163,6 @@ def parse_from_json( ) # Parse the optional common semantic properties like the description. new_collection.parse_optional_properties(collection_json) - properties: list = extract_array( - collection_json, "properties", new_collection.error_name - ) + properties: list = extract_array(collection_json, "properties", error_name) new_collection.add_properties_from_json(properties) graph.add_collection(new_collection) diff --git a/pydough/metadata/graphs/graph_metadata.py b/pydough/metadata/graphs/graph_metadata.py index b23e6f2be..a47b4eb5e 100644 --- a/pydough/metadata/graphs/graph_metadata.py +++ b/pydough/metadata/graphs/graph_metadata.py @@ -43,7 +43,7 @@ def __init__( synonyms: list[str] | None, extra_semantic_info: dict | None, ): - is_valid_name.verify(name, "graph name") + is_valid_name.verify(name, f"graph name {name!r}") self._additional_definitions: list[str] | None = additional_definitions self._verified_pydough_analysis: list[dict] | None = verified_pydough_analysis self._name: str = name @@ -179,7 +179,7 @@ def add_function(self, name: str, function: "ExpressionFunctionOperator") -> Non `PyDoughMetadataException`: if `function` cannot be inserted into the graph because of a name collision. """ - is_valid_name.verify(name, "function name") + is_valid_name.verify(name, f"function name {name!r}") if name == self.name: raise PyDoughMetadataException( f"Function name {name!r} cannot be the same as the graph name {self.name!r}" diff --git a/pydough/metadata/properties/property_metadata.py b/pydough/metadata/properties/property_metadata.py index b5a077878..8a8e47c22 100644 --- a/pydough/metadata/properties/property_metadata.py +++ b/pydough/metadata/properties/property_metadata.py @@ -45,8 +45,8 @@ def __init__( synonyms: list[str] | None, extra_semantic_info: dict | None, ): - is_valid_name.verify(name, "name") - HasType(CollectionMetadata).verify(collection, "collection") + is_valid_name.verify(name, f"property name {name!r}") + HasType(CollectionMetadata).verify(collection, f"collection {name}") self._name: str = name self._collection: CollectionMetadata = collection super().__init__(description, synonyms, extra_semantic_info) diff --git a/pydough/metadata/properties/table_column_metadata.py b/pydough/metadata/properties/table_column_metadata.py index d296f3cd9..da6c49098 100644 --- a/pydough/metadata/properties/table_column_metadata.py +++ b/pydough/metadata/properties/table_column_metadata.py @@ -11,6 +11,7 @@ NoExtraKeys, extract_string, is_string, + is_valid_sql_name, ) from pydough.metadata.collections import CollectionMetadata from pydough.types import PyDoughType, parse_type_from_string @@ -99,6 +100,7 @@ def parse_from_json( except PyDoughTypeException as e: raise PyDoughMetadataException(*e.args) column_name: str = extract_string(property_json, "column name", error_name) + is_valid_sql_name.verify(column_name, error_name) NoExtraKeys(TableColumnMetadata.allowed_fields).verify( property_json, error_name diff --git a/tests/test_metadata/defog_graphs.json b/tests/test_metadata/defog_graphs.json index c2ed72368..09c2860db 100644 --- a/tests/test_metadata/defog_graphs.json +++ b/tests/test_metadata/defog_graphs.json @@ -223,7 +223,7 @@ "synonyms": ["record datetime", "price update date"] }, { - "name": "open", + "name": "_open", "type": "table column", "column name": "sbDpOpen", "data type": "numeric", diff --git a/tests/test_metadata/invalid_graphs.json b/tests/test_metadata/invalid_graphs.json index bde86d9b0..2ba692036 100644 --- a/tests/test_metadata/invalid_graphs.json +++ b/tests/test_metadata/invalid_graphs.json @@ -52,7 +52,7 @@ "version": "V2", "collections": [ { - "name": "0", + "name": "Invalid name", "type": "simple table", "table path": "TableName", "unique properties": ["key"], @@ -66,7 +66,7 @@ "version": "V2", "collections": [ { - "name": "0", + "name": "table_name", "type": "simple table", "table path": "TableName", "unique properties": ["key"], @@ -84,7 +84,7 @@ { "name": "0", "type": "simple table", - "table path": "TableName", + "table path": "`Table Name`", "unique properties": ["key"], "properties": [ {"name": true} @@ -93,6 +93,314 @@ ], "relationships": [] }, + { + "name": "BAD_TABLE_NAME", + "version": "V2", + "collections": [ + { + "name": "invalid table_name", + "type": "simple table", + "table path": "TableName", + "unique properties": ["column_name"], + "properties": [ + { + "name": "column_name", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "BAD_COLUMN_NAME", + "version": "V2", + "collections": [ + { + "name": "collection", + "type": "simple table", + "table path": "TableName", + "unique properties": ["key"], + "properties": [ + { + "name": "invalid column name", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "BAD_UNIQUE_PROPERTY", + "version": "V2", + "collections": [ + { + "name": "collection", + "type": "simple table", + "table path": "TableName", + "unique properties": ["invalid column name"], + "properties": [ + { + "name": "invalid column name", + "type": "table column", + "column name": "\"column name\"", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "RESERVED_PROPERTY_KEYWORD_1", + "version": "V2", + "collections": [ + { + "name": "collection", + "type": "simple table", + "table path": "\"Table Name\"", + "unique properties": ["id"], + "properties": [ + { + "name": "id", + "type": "table column", + "column name": "id", + "data type": "numeric", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "RESERVED_PROPERTY_KEYWORD_2", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "`Table Name`", + "unique properties": ["CALCULATE"], + "properties": [ + { + "name": "CALCULATE", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "RESERVED_PROPERTY_KEYWORD_3", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "table_name", + "unique properties": ["MONOTONIC"], + "properties": [ + { + "name": "MONOTONIC", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "COLUMN_NAME_RESERVED_KEYWORD", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "TableName", + "unique properties": ["FROM_RESERVED_SQL_WORD"], + "properties": [ + { + "name": "FROM_RESERVED_SQL_WORD", + "type": "table column", + "column name": "FROM", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "TABLE_PATH_RESERVED_KEYWORD", + "version": "V2", + "collections": [ + { + "name": "cast_reserved_sql_word", + "type": "simple table", + "table path": "cast", + "unique properties": ["_id"], + "properties": [ + { + "name": "_id", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "COLUMN_NAME_INVALID_NAME_1", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "TableName", + "unique properties": ["FROM_RESERVED_SQL_WORD"], + "properties": [ + { + "name": "invalid_column_name", + "type": "table column", + "column name": "column name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "COLUMN_NAME_INVALID_NAME_2", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "TableName", + "unique properties": ["FROM_RESERVED_SQL_WORD"], + "properties": [ + { + "name": "invalid_column_name", + "type": "table column", + "column name": "\"column\"name\"", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "COLUMN_NAME_INVALID_NAME_3", + "version": "V2", + "collections": [ + { + "name": "table_name", + "type": "simple table", + "table path": "TableName", + "unique properties": ["FROM_RESERVED_SQL_WORD"], + "properties": [ + { + "name": "invalid_column_name", + "type": "table column", + "column name": "`column`name`", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "TABLE_PATH_INVALID_NAME_1", + "version": "V2", + "collections": [ + { + "name": "invalid_table_path", + "type": "simple table", + "table path": "table name", + "unique properties": ["_id"], + "properties": [ + { + "name": "_id", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "TABLE_PATH_INVALID_NAME_2", + "version": "V2", + "collections": [ + { + "name": "invalid_table_path", + "type": "simple table", + "table path": "\"table\"name\"", + "unique properties": ["_id"], + "properties": [ + { + "name": "_id", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, + { + "name": "TABLE_PATH_INVALID_NAME_3", + "version": "V2", + "collections": [ + { + "name": "invalid_table_path", + "type": "simple table", + "table path": "`table`name`", + "unique properties": ["_id"], + "properties": [ + { + "name": "_id", + "type": "table column", + "column name": "column_name", + "data type": "string", + "description": "column description" + } + ] + } + ], + "relationships": [] + }, { "name": "BAD_RELATIONSHIP_NAME", "version": "V2", diff --git a/tests/test_metadata/mysql_defog_graphs.json b/tests/test_metadata/mysql_defog_graphs.json index 7b58c3cf5..159682847 100644 --- a/tests/test_metadata/mysql_defog_graphs.json +++ b/tests/test_metadata/mysql_defog_graphs.json @@ -223,7 +223,7 @@ "synonyms": ["record datetime", "price update date"] }, { - "name": "open", + "name": "_open", "type": "table column", "column name": "sbDpOpen", "data type": "numeric", diff --git a/tests/test_metadata/snowflake_defog_graphs.json b/tests/test_metadata/snowflake_defog_graphs.json index 3a1007b13..97568aead 100644 --- a/tests/test_metadata/snowflake_defog_graphs.json +++ b/tests/test_metadata/snowflake_defog_graphs.json @@ -223,7 +223,7 @@ "synonyms": ["record datetime", "price update date"] }, { - "name": "open", + "name": "_open", "type": "table column", "column name": "sbDpOpen", "data type": "numeric", diff --git a/tests/test_metadata_errors.py b/tests/test_metadata_errors.py index 85793a47f..77a572171 100644 --- a/tests/test_metadata_errors.py +++ b/tests/test_metadata_errors.py @@ -53,7 +53,7 @@ def test_missing_property(get_sample_graph: graph_fetcher) -> None: ), pytest.param( "#BadGraphName", - "graph name must be a string that is a Python identifier", + "graph name '#BadGraphName' must be a string that is a valid Python identifier", id="#BadGraphName", ), pytest.param( @@ -85,24 +85,94 @@ def test_missing_property(get_sample_graph: graph_fetcher) -> None: ), pytest.param( "BAD_COLLECTION_NAME_1", - "name must be a string that is a Python identifier", + "collection name '0' must be a string that is a valid Python identifier", id="BAD_COLLECTION_NAME_1", ), pytest.param( "BAD_COLLECTION_NAME_2", - "name must be a string that is a Python identifier", + "collection name 'Invalid name' must be a string that is a valid Python identifier", id="BAD_COLLECTION_NAME_2", ), pytest.param( "BAD_PROPERTY_NAME_1", - "name must be a string that is a Python identifier", + "property 'bad name' of simple table collection 'table_name' in graph 'BAD_PROPERTY_NAME_1' must be a JSON object containing a field 'type' and field 'type' must be a string", id="BAD_PROPERTY_NAME_1", ), pytest.param( "BAD_PROPERTY_NAME_2", - "name must be a string that is a Python identifier", + "collection name '0' must be a string that is a valid Python identifier", id="BAD_PROPERTY_NAME_2", ), + pytest.param( + "BAD_TABLE_NAME", + "collection name 'invalid table_name' must be a string that is a valid Python identifier", + id="BAD_TABLE_NAME", + ), + pytest.param( + "BAD_COLUMN_NAME", + "property name 'invalid column name' must be a string that is a valid Python identifier", + id="BAD_COLUMN_NAME", + ), + pytest.param( + "BAD_UNIQUE_PROPERTY", + "property name 'invalid column name' must be a string that is a valid Python identifier", + id="BAD_UNIQUE_PROPERTY", + ), + pytest.param( + "RESERVED_PROPERTY_KEYWORD_1", + "property name 'id' must be a string that is not a Python reserved word or built-in name", + id="RESERVED_PROPERTY_KEYWORD_1", + ), + pytest.param( + "RESERVED_PROPERTY_KEYWORD_2", + "property name 'CALCULATE' must be a string that is not a PyDough reserved word", + id="RESERVED_PROPERTY_KEYWORD_2", + ), + pytest.param( + "RESERVED_PROPERTY_KEYWORD_3", + "property name 'MONOTONIC' must be a string that is not a PyDough reserved word", + id="RESERVED_PROPERTY_KEYWORD_3", + ), + pytest.param( + "COLUMN_NAME_RESERVED_KEYWORD", + "table column property 'FROM_RESERVED_SQL_WORD' of simple table collection 'table_name' in graph 'COLUMN_NAME_RESERVED_KEYWORD' must have a SQL name that is not a reserved word", + id="COLUMN_NAME_RESERVED_KEYWORD", + ), + pytest.param( + "TABLE_PATH_RESERVED_KEYWORD", + "simple table collection 'cast_reserved_sql_word' in graph 'TABLE_PATH_RESERVED_KEYWORD' must have a SQL name that is not a reserved word", + id="TABLE_PATH_RESERVED_KEYWORD", + ), + pytest.param( + "COLUMN_NAME_INVALID_NAME_1", + "table column property 'invalid_column_name' of simple table collection 'table_name' in graph 'COLUMN_NAME_INVALID_NAME_1' must have a SQL name that is a valid SQL identifier", + id="COLUMN_NAME_INVALID_NAME_1", + ), + pytest.param( + "COLUMN_NAME_INVALID_NAME_2", + "table column property 'invalid_column_name' of simple table collection 'table_name' in graph 'COLUMN_NAME_INVALID_NAME_2' must have a SQL name that is a valid SQL identifier", + id="COLUMN_NAME_INVALID_NAME_2", + ), + pytest.param( + "COLUMN_NAME_INVALID_NAME_3", + "table column property 'invalid_column_name' of simple table collection 'table_name' in graph 'COLUMN_NAME_INVALID_NAME_3' must have a SQL name that is a valid SQL identifier", + id="COLUMN_NAME_INVALID_NAME_3", + ), + pytest.param( + "TABLE_PATH_INVALID_NAME_1", + "simple table collection 'invalid_table_path' in graph 'TABLE_PATH_INVALID_NAME_1' must have a SQL name that is a valid SQL identifier", + id="TABLE_PATH_INVALID_NAME_1", + ), + pytest.param( + "TABLE_PATH_INVALID_NAME_2", + "simple table collection 'invalid_table_path' in graph 'TABLE_PATH_INVALID_NAME_2' must have a SQL name that is a valid SQL identifier", + id="TABLE_PATH_INVALID_NAME_2", + ), + pytest.param( + "TABLE_PATH_INVALID_NAME_3", + "simple table collection 'invalid_table_path' in graph 'TABLE_PATH_INVALID_NAME_3' must have a SQL name that is a valid SQL identifier", + id="TABLE_PATH_INVALID_NAME_3", + ), pytest.param( "BAD_RELATIONSHIP_NAME", "metadata for relationships within graph 'BAD_RELATIONSHIP_NAME' must be a JSON object containing a field 'type' and field 'type' must be a string", @@ -463,7 +533,7 @@ def test_missing_property(get_sample_graph: graph_fetcher) -> None: ), pytest.param( "BAD_FUNCTION_BAD_NAME_2", - "function name must be a string that is a Python identifier", + "function name '' must be a string that is a valid Python identifier", id="BAD_FUNCTION_BAD_NAME_2", ), pytest.param(