#13982 Python: Generate StrEnum classes for AppEnum-typed fields

Auto-generate enum.StrEnum classes from cafPdmPythonGenerator for every
caf::AppEnum-backed scriptable field, deduplicated by C++ AppEnum type.
Generated PdmObject attributes and method arguments now reference the
typed enum (e.g. self.depth_type: DepthType = DepthType.MEASURED_DEPTH)
instead of bare str. Member names are sanitized against Python keywords
and identifier rules (None -> None_, R-G -> R_G).

Existing scripts that pass plain strings keep working because StrEnum
members are strings; the wire format is unchanged.

Bumps python_requires to >=3.11 (StrEnum). Adds an Enum branch in
PdmObjectBase.__convert_to_grpc_value so future IntEnum use is also
handled.
This commit is contained in:
Kristian Bendiksen committed 2026-05-13 13:47:40 +02:00
1 parent eee33cdc8d
commit 21f1330232
5 files changed
+174 -2

No files matched your search

@@ -149,6 +149,128 @@ QString caf::PdmPythonGenerator::generate( PdmObjectFactory* factory, std::vecto
std::map<QString, QString> classCommentsGenerated;
std::set<QString> dataTypesInChildFields;
// Bookkeeping for AppEnum-typed fields. Each unique C++ AppEnum type (keyed by its xml
// dataTypeName, which is typeid().name() and therefore unique per type) gets one Python
// StrEnum class. Names are derived from the script field name to stay readable.
struct EnumClassDef
{
QString className;
std::vector<std::pair<QString, QString>> members; // (memberName, originalText), in declaration order
std::map<QString, QString> memberNameByText;
};
std::map<QString, EnumClassDef> enumDefByDataTypeName;
std::map<QString, QString> enumClassNameByDataTypeName;
// Seed with names that are already bound at module scope in the generated file (imports
// and module-level functions) so a generated enum cannot shadow them.
std::set<QString> usedEnumClassNames = { "PdmObjectBase",
"PdmObject_pb2",
"grpc",
"StrEnum",
"Optional",
"Dict",
"List",
"Tuple",
"Type",
"class_dict",
"class_from_keyword" };
for ( std::shared_ptr<PdmObject> object : dummyObjects )
{
QString scriptClassName =
PdmObjectScriptingCapabilityRegister::scriptClassNameFromClassKeyword( object->classKeyword() );
if ( scriptClassName.isEmpty() ) scriptClassName = object->classKeyword();
usedEnumClassNames.insert( scriptClassName );
}
auto sanitizeEnumMemberName = []( const QString& text ) -> QString
{
// Python identifiers must start with a letter or underscore and may only contain
// letters, digits, and underscores. Some AppEnum texts (e.g. "R-G", "None") violate
// these rules — coerce them into valid identifiers without changing the wire value.
static const std::set<QString> pythonKeywords = { "False", "None", "True", "and", "as", "assert",
"async", "await", "break", "class", "continue", "def",
"del", "elif", "else", "except", "finally", "for",
"from", "global", "if", "import", "in", "is",
"lambda", "nonlocal", "not", "or", "pass", "raise",
"return", "try", "while", "with", "yield", "match",
"case" };
QString s;
for ( QChar ch : text )
{
if ( ch.isLetterOrNumber() || ch == '_' )
s += ch;
else
s += '_';
}
if ( s.isEmpty() ) s = "_";
if ( s[0].isDigit() ) s = "_" + s;
if ( pythonKeywords.count( s ) ) s += "_";
return s;
};
auto enumClassNameForField = [&]( PdmFieldHandle* field, PdmAbstractFieldScriptingCapability* scriptability ) -> QString
{
QStringList enumTexts = scriptability->enumScriptTexts();
if ( enumTexts.empty() ) return QString();
QString dataTypeName = field->capability<PdmXmlFieldHandle>()->dataTypeName();
auto cached = enumClassNameByDataTypeName.find( dataTypeName );
if ( cached != enumClassNameByDataTypeName.end() ) return cached->second;
QString baseName = snakeToCamelCase( camelToSnakeCase( scriptability->scriptFieldName() ) );
if ( baseName.isEmpty() ) baseName = "Enum";
QString candidate = baseName;
int suffix = 1;
while ( usedEnumClassNames.count( candidate ) )
{
candidate = baseName + QString::number( ++suffix );
}
EnumClassDef def;
def.className = candidate;
std::set<QString> usedMemberNames;
for ( const QString& text : enumTexts )
{
QString memberName = sanitizeEnumMemberName( text );
int memberSuffix = 1;
QString unique = memberName;
while ( usedMemberNames.count( unique ) )
{
unique = memberName + QString( "_%1" ).arg( ++memberSuffix );
}
usedMemberNames.insert( unique );
def.members.push_back( { unique, text } );
def.memberNameByText[text] = unique;
}
enumClassNameByDataTypeName[dataTypeName] = candidate;
enumDefByDataTypeName[dataTypeName] = def;
usedEnumClassNames.insert( candidate );
return candidate;
};
auto enumDefaultValueExpression =
[&]( PdmFieldHandle* field, const QString& enumClassName, const QString& quotedDefault ) -> QString
{
// The existing getDefaultValue() returns the AppEnum text wrapped in double quotes (because
// dataTypeString reports "str" for enum fields). Convert "MATRIX_MODEL" -> EnumName.MATRIX_MODEL.
// If the default isn't a quoted string (e.g. the literal None for fields with no default),
// leave it unchanged.
if ( !quotedDefault.startsWith( '"' ) || !quotedDefault.endsWith( '"' ) || quotedDefault.size() < 2 )
{
return quotedDefault;
}
QString stripped = quotedDefault.mid( 1, quotedDefault.size() - 2 );
if ( stripped.isEmpty() ) return quotedDefault;
QString dataTypeName = field->capability<PdmXmlFieldHandle>()->dataTypeName();
auto it = enumDefByDataTypeName.find( dataTypeName );
QString memberName = ( it != enumDefByDataTypeName.end() && it->second.memberNameByText.count( stripped ) )
? it->second.memberNameByText.at( stripped )
: sanitizeEnumMemberName( stripped );
return QString( "%1.%2" ).arg( enumClassName ).arg( memberName );
};
// First generate all attributes and comments to go into each object
for ( std::shared_ptr<PdmObject> object : dummyObjects )
{
@@ -264,6 +386,13 @@ QString caf::PdmPythonGenerator::generate( PdmObjectFactory* factory, std::vecto
dataType = QString( "Optional[%1]" ).arg( dataType );
}
QString enumClassName = enumClassNameForField( field, scriptability );
if ( !enumClassName.isEmpty() )
{
dataType = enumClassName;
defaultValue = enumDefaultValueExpression( field, enumClassName, defaultValue );
}
QString fieldCode =
QString( " self.%1: %2 = %3\n" ).arg( snake_field_name ).arg( dataType ).arg( defaultValue );
@@ -393,6 +522,13 @@ QString caf::PdmPythonGenerator::generate( PdmObjectFactory* factory, std::vecto
commentOrEnumDescription = "One of [" + enumTexts.join( ", " ) + "]";
}
QString enumClassName = enumClassNameForField( field, scriptability );
if ( !enumClassName.isEmpty() )
{
dataType = enumClassName;
defaultValue = enumDefaultValueExpression( field, enumClassName, defaultValue );
}
inputArgumentStrings.push_back(
QString( "%1: %2=%3" ).arg( argumentName ).arg( dataType ).arg( defaultValue ) );
outputArgumentStrings.push_back( QString( "%1=%1" ).arg( argumentName ) );
@@ -444,9 +580,26 @@ QString caf::PdmPythonGenerator::generate( PdmObjectFactory* factory, std::vecto
out << "from rips.pdmobject import PdmObjectBase\n";
out << "import PdmObject_pb2\n";
out << "import grpc\n";
out << "from enum import StrEnum\n";
out << "from typing import Optional, Dict, List, Tuple, Type\n";
out << "\n";
// Emit enum classes sorted by name for stable diffs across regenerations.
std::map<QString, const EnumClassDef*> enumDefByClassName;
for ( const auto& [dataTypeName, def] : enumDefByDataTypeName )
{
enumDefByClassName[def.className] = &def;
}
for ( const auto& [enumClassName, def] : enumDefByClassName )
{
out << QString( "class %1(StrEnum):\n" ).arg( enumClassName );
for ( const auto& [memberName, originalText] : def->members )
{
out << QString( " %1 = \"%2\"\n" ).arg( memberName ).arg( originalText );
}
out << "\n";
}
for ( std::shared_ptr<PdmObject> object : dummyObjects )
{
const std::list<QString>& classInheritanceStack = object->classInheritanceStack();
@@ -631,6 +784,19 @@ QString PdmPythonGenerator::camelToSnakeCase( const QString& camelString )
return snake_case.toLower();
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
QString PdmPythonGenerator::snakeToCamelCase( const QString& snakeString )
{
QString camel;
for ( const QString& part : snakeString.split( '_', Qt::SkipEmptyParts ) )
{
camel += part.left( 1 ).toUpper() + part.mid( 1 ).toLower();
}
return camel;
}
//--------------------------------------------------------------------------------------------------
///
//--------------------------------------------------------------------------------------------------
@@ -51,6 +51,7 @@ class PdmPythonGenerator : public PdmCodeGenerator
public:
QString generate( PdmObjectFactory* factory, std::vector<QString>& errorMessages ) const override;
static QString camelToSnakeCase( const QString& camelString );
static QString snakeToCamelCase( const QString& snakeString );
static QString dataTypeString( const PdmFieldHandle* field, bool useStrForUnknownDataTypes );
static QString pythonifyDataValue( const QString& dataValue );
+3
View File
@@ -3,6 +3,7 @@
ResInsight caf::PdmObject connection module
"""
from enum import Enum
from functools import wraps
import grpc
import re
@@ -243,6 +244,8 @@ class PdmObjectBase:
if value:
return "true"
return "false"
if isinstance(value, Enum):
return str(value.value)
if isinstance(value, PdmObjectBase):
return value.__class__.__name__ + ":" + str(value.address())
if isinstance(value, list):
@@ -30,13 +30,15 @@ def test_well_log_plots(rips_instance, initialize_test):
for plot in plots:
if isinstance(plot, rips.WellLogPlot):
assert plot.depth_type == "MEASURED_DEPTH"
assert plot.depth_type == rips.DepthType.MEASURED_DEPTH
well_log_plots.append(plot)
assert len(well_log_plots) == 2
with tempfile.TemporaryDirectory(prefix="rips") as tmpdirname:
for well_log_plot in well_log_plots:
well_log_plot.depth_type = "TRUE_VERTICAL_DEPTH_RKB"
well_log_plot.depth_type = rips.DepthType.TRUE_VERTICAL_DEPTH_RKB
well_log_plot.update()
assert well_log_plot.depth_type == "TRUE_VERTICAL_DEPTH_RKB"
if rips_instance.is_gui():
well_log_plot.export_snapshot(tmpdirname)
well_log_plot.export_data_as_las(tmpdirname)
+1 -1
View File
@@ -20,5 +20,5 @@ setup(
packages=['rips'],
package_data={'rips': ['py.typed', '*.py', 'generated/*.py', 'PythonExamples/*.py', 'tests/*.py']},
install_requires=['grpcio', 'protobuf', 'wheel', 'typing_extensions'],
python_requires='>=3.8',
python_requires='>=3.11',
)