diff --git a/compiler/cpp/src/thrift/generate/t_netstd_generator.cc b/compiler/cpp/src/thrift/generate/t_netstd_generator.cc index 09a999d9885..4a85fa4a3ba 100644 --- a/compiler/cpp/src/thrift/generate/t_netstd_generator.cc +++ b/compiler/cpp/src/thrift/generate/t_netstd_generator.cc @@ -452,6 +452,10 @@ void t_netstd_generator::generate_enum(ostream& out, t_enum* tenum) generate_netstd_doc(out, tenum); generate_deprecation_attribute(out, tenum->annotations_); + if (tenum->annotations_.count("enum.flags") != 0) + { + out << indent() << "[Flags]" << '\n'; + } out << indent() << "public enum " << type_name(tenum,false) << '\n'; scope_up(out); diff --git a/compiler/cpp/src/thrift/generate/t_py_generator.cc b/compiler/cpp/src/thrift/generate/t_py_generator.cc index 10d00e78c8e..fd9df81e618 100644 --- a/compiler/cpp/src/thrift/generate/t_py_generator.cc +++ b/compiler/cpp/src/thrift/generate/t_py_generator.cc @@ -18,6 +18,7 @@ */ #include +#include #include #include #include @@ -297,6 +298,64 @@ class t_py_generator : public t_generator { return package_dir + real_module; } + string flags_base_alias() { + std::set names; + for (auto type : program_->get_enums()) { + names.insert(maybe_escape_identifier(type->get_name())); + } + for (auto type : program_->get_objects()) { + names.insert(maybe_escape_identifier(type->get_name())); + } + for (auto constant : program_->get_consts()) { + names.insert(maybe_escape_identifier(constant->get_name())); + } + for (auto include : program_->get_includes()) { + string module = get_real_py_module(include, gen_twisted_, package_prefix_); + names.insert(module.substr(0, module.find('.'))); + } + string alias = "_thrift_Flag"; + while (names.count(alias) != 0) { + alias += "_"; + } + return alias; + } + + static bool is_flags(t_type* type) { + type = type->get_true_type(); + return type->is_enum() && type->annotations_.count("enum.flags") != 0; + } + + static bool contains_flags(t_type* type, std::set& visited) { + type = type->get_true_type(); + if (!visited.insert(type).second) { + return false; + } + if (is_flags(type)) { + return true; + } + if (type->is_struct() || type->is_xception()) { + for (auto field : static_cast(type)->get_members()) { + if (contains_flags(field->get_type(), visited)) { + return true; + } + } + } else if (type->is_map()) { + auto map_type = static_cast(type); + return contains_flags(map_type->get_key_type(), visited) + || contains_flags(map_type->get_val_type(), visited); + } else if (type->is_list()) { + return contains_flags(static_cast(type)->get_elem_type(), visited); + } else if (type->is_set()) { + return contains_flags(static_cast(type)->get_elem_type(), visited); + } + return false; + } + + static bool contains_flags(t_type* type) { + std::set visited; + return contains_flags(type, visited); + } + static bool is_immutable(t_type* ttype) { std::map>::iterator it = ttype->annotations_.find("python.immutable"); @@ -511,6 +570,13 @@ string t_py_generator::py_imports() { if (gen_enum_) { ss << "from enum import IntEnum" << '\n'; } + for (auto tenum : program_->get_enums()) { + if (is_flags(tenum)) { + ss << "from enum import " << (gen_type_hints_ ? "IntFlag" : "Flag") + << " as " << flags_base_alias() << '\n'; + break; + } + } if (gen_utf8strings_) { ss << '\n' << "import sys"; } @@ -550,7 +616,9 @@ void t_py_generator::generate_enum(t_enum* tenum) { std::ostringstream to_string_mapping, from_string_mapping; std::string base_class; - if (gen_enum_) { + if (is_flags(tenum)) { + base_class = flags_base_alias(); + } else if (gen_enum_) { base_class = "IntEnum"; } else if (gen_newstyle_) { base_class = "object"; @@ -567,6 +635,49 @@ void t_py_generator::generate_enum(t_enum* tenum) { indent_up(); generate_python_docstring(f_types_, tenum); + if (is_flags(tenum)) { + int32_t declared_bits = 0; + for (auto value : tenum->get_constants()) { + declared_bits |= value->get_value(); + } + // Preserve unknown bits on older Python versions as well as signed I32 values. + indent(f_types_) << "@classmethod" << '\n'; + indent(f_types_) << "def _missing_(cls, value):" << '\n'; + indent_up(); + indent(f_types_) << "if not isinstance(value, int):" << '\n'; + indent(f_types_) << indent_str() << "return None" << '\n'; + // Let enum name known combinations, but do not cache unknown wire values. + indent(f_types_) << "if value >= 0 and not (value & ~(" << declared_bits << ")):" << '\n'; + indent_up(); + indent(f_types_) << "try:" << '\n'; + indent(f_types_) << indent_str() << "return super()._missing_(value)" << '\n'; + indent(f_types_) << "except ValueError:" << '\n'; + indent(f_types_) << indent_str() << "pass" << '\n'; + indent_down(); + indent(f_types_) << "member = " + << (gen_type_hints_ ? "int.__new__(cls, value)" : "object.__new__(cls)") << '\n'; + indent(f_types_) << "member._name_ = None" << '\n'; + indent(f_types_) << "member._value_ = value" << '\n'; + indent(f_types_) << "return member" << '\n'; + indent_down(); + f_types_ << '\n'; + + if (!gen_type_hints_) { + // Uncached Flag values need value equality and a hash independent of their name. + indent(f_types_) << "def __eq__(self, other):" << '\n'; + indent_up(); + indent(f_types_) << "if self.__class__ is other.__class__:" << '\n'; + indent(f_types_) << indent_str() << "return self._value_ == other._value_" << '\n'; + indent(f_types_) << "return NotImplemented" << '\n'; + indent_down(); + f_types_ << '\n'; + indent(f_types_) << "def __hash__(self):" << '\n'; + indent(f_types_) << indent_str() << "return hash(self._value_)" << '\n' << '\n'; + } + indent(f_types_) << "def __invert__(self):" << '\n'; + indent(f_types_) << indent_str() << "return self.__class__(~self._value_)" << '\n' << '\n'; + } + to_string_mapping << indent() << "_VALUES_TO_NAMES = {" << '\n'; from_string_mapping << indent() << "_NAMES_TO_VALUES = {" << '\n'; @@ -588,7 +699,7 @@ void t_py_generator::generate_enum(t_enum* tenum) { indent_down(); f_types_ << '\n'; - if (!gen_enum_) { + if (!gen_enum_ && !is_flags(tenum)) { f_types_ << to_string_mapping.str() << '\n' << from_string_mapping.str(); } } @@ -648,7 +759,9 @@ string t_py_generator::render_const_value(t_type* type, t_const_value* value) { } else if (type->is_enum()) { out << indent(); int64_t int_val = value->get_integer(); - if (gen_enum_) { + if (is_flags(type)) { + out << type_name(type) << "(" << int_val << ")"; + } else if (gen_enum_) { t_enum_value* enum_val = ((t_enum*)type)->get_constant_by_value(int_val); out << type_name(type) << "." << maybe_escape_identifier(enum_val->get_name()); } else { @@ -925,7 +1038,13 @@ void t_py_generator::generate_py_struct_definition(ostream& out, } if (is_immutable(tstruct)) { - if (gen_enum_ && type->is_enum()) { + if (gen_enum_ && is_flags(type)) { + string field_name = maybe_escape_identifier((*m_iter)->get_name()); + indent(out) << "super(" << maybe_escape_identifier(tstruct->get_name()) << ", self).__setattr__('" + << (*m_iter)->get_name() << "', " << field_name + << " if hasattr(" << field_name << ", 'value') or " << field_name << " is None else " + << type_name(true_type) << "(" << field_name << "))" << '\n'; + } else if (gen_enum_ && type->is_enum()) { indent(out) << "super(" << maybe_escape_identifier(tstruct->get_name()) << ", self).__setattr__('" << (*m_iter)->get_name() << "', " << maybe_escape_identifier((*m_iter)->get_name()) << " if hasattr(" << maybe_escape_identifier((*m_iter)->get_name()) << ", 'value') else " @@ -1024,7 +1143,7 @@ void t_py_generator::generate_py_struct_definition(ostream& out, } } - if (!gen_dynamic_) { + if (!gen_dynamic_ || contains_flags(tstruct)) { out << '\n'; generate_py_struct_reader(out, tstruct); generate_py_struct_writer(out, tstruct); @@ -1112,19 +1231,22 @@ void t_py_generator::generate_py_struct_reader(ostream& out, t_struct* tstruct) const char* id = is_immutable(tstruct) ? "cls" : "self"; - indent(out) << "if iprot._fast_decode is not None " - "and isinstance(iprot.trans, TTransport.CReadableTransport) " - "and " - << id << ".thrift_spec is not None:" << '\n'; - indent_up(); + // The accelerated codec's I32 specification does not preserve flag types. + if (!contains_flags(tstruct)) { + indent(out) << "if iprot._fast_decode is not None " + "and isinstance(iprot.trans, TTransport.CReadableTransport) " + "and " + << id << ".thrift_spec is not None:" << '\n'; + indent_up(); - if (is_immutable(tstruct)) { - indent(out) << "return iprot._fast_decode(None, iprot, [cls, cls.thrift_spec])" << '\n'; - } else { - indent(out) << "iprot._fast_decode(self, iprot, [self.__class__, self.thrift_spec])" << '\n'; - indent(out) << "return" << '\n'; + if (is_immutable(tstruct)) { + indent(out) << "return iprot._fast_decode(None, iprot, [cls, cls.thrift_spec])" << '\n'; + } else { + indent(out) << "iprot._fast_decode(self, iprot, [self.__class__, self.thrift_spec])" << '\n'; + indent(out) << "return" << '\n'; + } + indent_down(); } - indent_down(); indent(out) << "iprot.increment_recursion_depth()" << '\n'; indent(out) << "try:" << '\n'; @@ -1221,14 +1343,16 @@ void t_py_generator::generate_py_struct_writer(ostream& out, t_struct* tstruct) indent(out) << "def write(self, oprot):" << '\n'; indent_up(); indent(out) << "self.validate()" << '\n'; - indent(out) << "if oprot._fast_encode is not None and self.thrift_spec is not None:" << '\n'; - indent_up(); + if (!contains_flags(tstruct)) { + indent(out) << "if oprot._fast_encode is not None and self.thrift_spec is not None:" << '\n'; + indent_up(); - indent(out) - << "oprot.trans.write(oprot._fast_encode(self, [self.__class__, self.thrift_spec]))" - << '\n'; - indent(out) << "return" << '\n'; - indent_down(); + indent(out) + << "oprot.trans.write(oprot._fast_encode(self, [self.__class__, self.thrift_spec]))" + << '\n'; + indent(out) << "return" << '\n'; + indent_down(); + } indent(out) << "oprot.increment_recursion_depth()" << '\n'; indent(out) << "try:" << '\n'; @@ -2430,7 +2554,7 @@ void t_py_generator::generate_deserialize_field(ostream& out, } out << '\n'; } else if (type->is_enum()) { - if (gen_enum_) { + if (gen_enum_ || is_flags(type)) { indent(out) << name << " = " << type_name(type) << "(iprot.readI32())"; } else { indent(out) << name << " = iprot.readI32()"; @@ -2623,7 +2747,7 @@ void t_py_generator::generate_serialize_field(ostream& out, t_field* tfield, str throw "compiler error: no Python name for base type " + t_base_type::t_base_name(tbase); } } else if (type->is_enum()) { - if (gen_enum_){ + if (gen_enum_ || is_flags(type)) { out << "writeI32(" << name << ".value)"; } else { out << "writeI32(" << name << ")"; diff --git a/compiler/cpp/test/CMakeLists.txt b/compiler/cpp/test/CMakeLists.txt index e15ef35d830..ba351c7e9dd 100644 --- a/compiler/cpp/test/CMakeLists.txt +++ b/compiler/cpp/test/CMakeLists.txt @@ -45,6 +45,7 @@ if(Python3_Interpreter_FOUND) add_test(NAME StalenessCheckTest COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/compiler/staleness_check.py ${THRIFT_COMPILER}) add_test(NAME MarkdownDocTest COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/compiler/markdown_doc_test.py ${THRIFT_COMPILER}) add_test(NAME ExceptionDocTest COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/compiler/exception_doc_test.py ${THRIFT_COMPILER}) + add_test(NAME EnumFlagsTest COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/compiler/enum_flags_test.py ${THRIFT_COMPILER}) else() message(WARNING "Skipping Python compiler tests as there is no python interpreter available.") endif() diff --git a/compiler/cpp/test/compiler/EnumFlagsTest.thrift b/compiler/cpp/test/compiler/EnumFlagsTest.thrift new file mode 100644 index 00000000000..4246f5ae754 --- /dev/null +++ b/compiler/cpp/test/compiler/EnumFlagsTest.thrift @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +namespace netstd EnumFlagsTest + +enum Permissions { + None = 0, + Read = 1, + Write = 2, + ReadWrite = 3, + Execute = 4, +} (enum.flags) + +enum Ordinary { + First = 1, + Second = 2, +} + +typedef Permissions PermissionAlias + +const Permissions DEFAULT_PERMISSIONS = Permissions.Read + +struct FlagsContainer { + 1: Permissions permissions = Permissions.Read, + 2: list values, + 3: PermissionAlias alias, +} + +struct FrozenFlags { + 1: Permissions permissions = Permissions.Read, +} (python.immutable) + +struct NestedFlags { + 1: FlagsContainer flags, + 2: map mapping, + 3: set values, + 4: optional NestedFlags child, +} diff --git a/compiler/cpp/test/compiler/enum_flags_test.py b/compiler/cpp/test/compiler/enum_flags_test.py new file mode 100644 index 00000000000..7b0adde79ca --- /dev/null +++ b/compiler/cpp/test/compiler/enum_flags_test.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +"""Tests for enum.flags in the Python and C# generators.""" + +import enum +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock + +runtime = Path(__file__).resolve().parents[4] / "lib/py/src/__init__.py" +spec = importlib.util.spec_from_file_location("thrift", runtime) +thrift = importlib.util.module_from_spec(spec) +sys.modules["thrift"] = thrift +spec.loader.exec_module(thrift) +from thrift.protocol import TBinaryProtocol, TCompactProtocol, TJSONProtocol +from thrift.transport import TTransport + + +class TestEnumFlags(unittest.TestCase): + THRIFT_COMPILER = None + FIXTURE = Path(__file__).with_name("EnumFlagsTest.thrift") + + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.output = Path(self.directory.name) + + def generate(self, language): + subprocess.run([self.THRIFT_COMPILER, "--gen", language, + "-out", str(self.output), str(self.FIXTURE)], check=True, + capture_output=True, text=True) + + def generate_schema(self, schema, options): + fixture = self.output / "Schema.thrift" + fixture.write_text(schema) + subprocess.run([self.THRIFT_COMPILER, "--gen", options, + "-out", str(self.output), str(fixture)], check=True, + capture_output=True, text=True) + source = (self.output / "Schema/ttypes.py").read_text() + namespace = {} + exec(compile(source, "ttypes.py", "exec"), namespace) + return namespace + + def test_python_flag_names(self): + schema = """ + enum Flag { A = 1 } (enum.flags) + enum IntFlag { A = 1 } (enum.flags) + enum _thrift_Flag { A = 1 } (enum.flags) + enum _thrift_Flag_ { A = 1 } (enum.flags) + enum Other { B = 2 } (enum.flags) + """ + for options in ("py", "py:enum", "py:enum,type_hints"): + with self.subTest(options=options): + namespace = self.generate_schema(schema, options) + for name in ("Flag", "IntFlag", "_thrift_Flag", "_thrift_Flag_", "Other"): + expected = enum.IntFlag if "type_hints" in options else enum.Flag + self.assertEqual(namespace[name].__bases__, (expected,)) + self.assertEqual(namespace["Other"].B.value, 2) + + def test_python_schema_evolution(self): + schema = """ + enum Permissions { Read = 1, Write = 2, %s } (enum.flags) + struct Message { + 1: Permissions flags, + 2: list values, + 3: map mapping, + } + """ + for options in ("py", "py:enum", "py:enum,type_hints", "py:dynamic", "py:slots"): + old = self.generate_schema(schema % "", options) + new = self.generate_schema(schema % "Execute = 4, High = -2147483648,", options) + for protocol in (TBinaryProtocol.TBinaryProtocol, + TCompactProtocol.TCompactProtocol, + TJSONProtocol.TJSONProtocol): + for bits in (4, 5, -2147483648): + with self.subTest(options=options, protocol=protocol.__name__, bits=bits): + flag = new["Permissions"](bits) + message = new["Message"](flags=flag, values=[flag], mapping={flag: flag}) + wire = TTransport.TMemoryBuffer() + message.write(protocol(wire)) + older = old["Message"]() + older.read(protocol(TTransport.TMemoryBuffer(wire.getvalue()))) + self.assertIsInstance(older.flags, old["Permissions"]) + self.assertEqual(older.flags, old["Permissions"](bits)) + self.assertEqual(older.flags.value, bits) + self.assertEqual(older.values[0].value, bits) + self.assertEqual(older.mapping[older.flags].value, bits) + self.assertEqual((older.flags | old["Permissions"].Read).value, bits | 1) + forwarded = TTransport.TMemoryBuffer() + older.write(protocol(forwarded)) + restored = new["Message"]() + restored.read(protocol(TTransport.TMemoryBuffer(forwarded.getvalue()))) + self.assertEqual(restored, message) + + def test_python_unknown_flag_hashes(self): + schema = """ + enum Permissions { Read = 1, Write = 2 } (enum.flags) + enum Other { Read = 1 } (enum.flags) + struct Message { + 1: set values, + 2: map mapping, + } + """ + for options in ("py", "py:enum", "py:enum,type_hints"): + with self.subTest(options=options): + ns = self.generate_schema(schema, options) + flags = ns["Permissions"] + values = [flags(bits) for bits in range(16, 528)] + self.assertEqual(len({hash(value) for value in values}), len(values)) + self.assertEqual(flags(17), flags(17)) + self.assertFalse(flags(17) != flags(17)) + self.assertNotEqual(flags(17), flags(18)) + self.assertEqual(hash(flags.Read), hash(flags(1))) + if "type_hints" not in options: + self.assertNotEqual(flags(17), 17) + self.assertNotEqual(flags.Read, ns["Other"].Read) + message = ns["Message"](values=set(values), mapping={v: v for v in values}) + for protocol in (TBinaryProtocol.TBinaryProtocol, + TCompactProtocol.TCompactProtocol, + TJSONProtocol.TJSONProtocol): + wire = TTransport.TMemoryBuffer() + message.write(protocol(wire)) + restored = ns["Message"]() + restored.read(protocol(TTransport.TMemoryBuffer(wire.getvalue()))) + self.assertEqual(restored, message) + self.assertEqual(restored.mapping[flags(17)], flags(17)) + + def test_python_unknown_flags_are_not_cached(self): + for options in ("py", "py:enum", "py:enum,type_hints"): + with self.subTest(options=options): + ns = self.generate_schema( + "enum Permissions { Read = 1, Write = 2 } (enum.flags)", options) + flags = ns["Permissions"] + original = dict(flags._value2member_map_) + for bits in range(16, 528): + self.assertEqual(flags(bits).value, bits) + self.assertEqual(flags(-bits).value, -bits) + self.assertEqual(flags._value2member_map_, original) + + def test_python_clearing_flags_preserves_unknown_bits(self): + schema = """ + enum Permissions { Read = 1, Write = 2, ReadWrite = 3 } (enum.flags) + struct Message { 1: Permissions flags } + """ + for options in ("py", "py:enum", "py:enum,type_hints"): + with self.subTest(options=options): + ns = self.generate_schema(schema, options) + flags = ns["Permissions"] + self.assertEqual((flags(17) & ~flags.Read).value, 16) + self.assertEqual(~flags.Read & flags.ReadWrite, flags.Write) + self.assertEqual((~~flags.Read), flags.Read) + self.assertEqual((flags(17) ^ flags.Read).value, 16) + self.assertEqual((flags(17) | flags.Write).value, 19) + for protocol in (TBinaryProtocol.TBinaryProtocol, + TCompactProtocol.TCompactProtocol, + TJSONProtocol.TJSONProtocol): + message = ns["Message"](flags=flags(17) & ~flags.Read) + wire = TTransport.TMemoryBuffer() + message.write(protocol(wire)) + restored = ns["Message"]() + restored.read(protocol(TTransport.TMemoryBuffer(wire.getvalue()))) + self.assertEqual(restored.flags.value, 16) + + def test_python_immutable_flags_accept_integers(self): + schema = """ + enum Permissions { Read = 1, Write = 2 } (enum.flags) + typedef Permissions Alias + struct Frozen { + 1: optional Permissions flags, + 2: optional Alias alias, + } (python.immutable) + exception Failure { + 1: optional Permissions flags, + 2: optional Alias alias, + } + """ + for options in ("py:enum", "py:enum,type_hints", "py:enum,slots", "py:enum,dynamic"): + ns = self.generate_schema(schema, options) + flags = ns["Permissions"] + for name in ("Frozen", "Failure"): + for value in (None, 1, 5, flags.Read, flags(5)): + with self.subTest(options=options, structure=name, value=value): + message = ns[name](flags=value, alias=value) + expected = None if value is None else flags(value) + self.assertEqual(message.flags, expected) + self.assertEqual(message.alias, expected) + for protocol in (TBinaryProtocol.TBinaryProtocol, + TCompactProtocol.TCompactProtocol, + TJSONProtocol.TJSONProtocol): + wire = TTransport.TMemoryBuffer() + message.write(protocol(wire)) + restored = ns[name].read( + protocol(TTransport.TMemoryBuffer(wire.getvalue()))) + self.assertEqual(restored.flags, expected) + self.assertEqual(restored.alias, expected) + + def test_netstd(self): + self.generate("netstd") + flags = (self.output / "EnumFlagsTest/Permissions.cs").read_text() + ordinary = (self.output / "EnumFlagsTest/Ordinary.cs").read_text() + self.assertIn("[Flags]", flags) + self.assertNotIn("[Flags]", ordinary) + self.assertIn("ReadWrite = 3,", flags) + + def test_python(self): + for options in ("py", "py:enum", "py:enum,type_hints", "py:dynamic", "py:slots"): + with self.subTest(options=options): + self.generate(options) + namespace = {} + source = (self.output / "EnumFlagsTest/ttypes.py").read_text() + exec(compile(source, "ttypes.py", "exec"), namespace) + flags = namespace["Permissions"] + expected = enum.IntFlag if "type_hints" in options else enum.Flag + self.assertEqual(flags.__bases__, (expected,)) + combined = flags.Read | flags.Execute + self.assertEqual(combined.value, 5) + self.assertEqual(flags.Read | flags.Write, flags.ReadWrite) + self.assertEqual(flags(0).value, 0) + with self.assertRaises(ValueError): + flags("invalid") + ordinary = namespace["Ordinary"] + if "enum" in options: + self.assertEqual(ordinary.__bases__, (enum.IntEnum,)) + else: + self.assertEqual(ordinary.First, 1) + self.assertEqual(ordinary._VALUES_TO_NAMES[1], "First") + self.assertIn("DEFAULT_PERMISSIONS = Permissions(1)", + (self.output / "EnumFlagsTest/constants.py").read_text()) + container = namespace["FlagsContainer"] + self.assertEqual(container().permissions, flags.Read) + for protocol in (TBinaryProtocol.TBinaryProtocol, + TCompactProtocol.TCompactProtocol, + TJSONProtocol.TJSONProtocol): + value = container(permissions=combined, + values=[flags(0), flags.ReadWrite, combined], + alias=combined) + frozen = namespace["FrozenFlags"](permissions=combined) + nested = namespace["NestedFlags"]( + flags=value, mapping={flags.Read: combined}, values={combined}) + for original in (value, frozen, nested): + with self.subTest(protocol=protocol.__name__, + structure=type(original).__name__): + transport = TTransport.TMemoryBuffer() + writer = protocol(transport) + writer._fast_encode = Mock(side_effect=AssertionError("fast encode")) + original.write(writer) + writer._fast_encode.assert_not_called() + reader = protocol(TTransport.TMemoryBuffer(transport.getvalue())) + reader._fast_decode = Mock(side_effect=AssertionError("fast decode")) + if original is frozen: + restored = type(original).read(reader) + else: + restored = type(original)() + restored.read(reader) + reader._fast_decode.assert_not_called() + self.assertEqual(restored, original) + + +if __name__ == "__main__": + TestEnumFlags.THRIFT_COMPILER = os.path.abspath(sys.argv.pop(1)) + unittest.main() diff --git a/doc/specs/idl.md b/doc/specs/idl.md index d3f61577d95..5e3922c9ed2 100644 --- a/doc/specs/idl.md +++ b/doc/specs/idl.md @@ -60,6 +60,33 @@ An enum creates an enumerated type, with named values. If no constant value is s [10] Enum ::= 'enum' Identifier '{' (Identifier ('=' IntConstant)? ListSeparator?)* '}' +The `enum.flags` annotation marks an enum as a set of bit flags: + +```thrift +enum Permissions { + Read = 1, + Write = 2, + ReadWrite = 3, +} (enum.flags) +``` + +The C# (`netstd`) generator adds `[Flags]`. The Python generator uses `enum.Flag`, +or `enum.IntFlag` with `--gen py:enum,type_hints`. Flag enums require Python 3.6 +or later. Unannotated enums keep their existing behavior. The annotation does not +assign values or check that they are powers of two; zero and composite values are +allowed. The wire representation remains an `i32`. Python preserves unknown flag +bits when reading and writing values, allowing peers with different versions of +an enum to exchange new flags without losing them. Bitwise complement also +preserves this behavior: `permissions & ~Permissions.Read` clears `Read` without +removing unknown bits. The complement uses signed integer semantics, so +`~Permissions.Read` can have a negative value. + +Python flag values compare and hash by value (and, for `Flag`, by enum type when +comparing). Unknown values are not retained in the enum's member cache; use `==` +rather than `is` to compare them. With `--gen py:enum`, integer arguments to flag +fields are converted to the enum type in immutable structs and exceptions as +well as mutable structs. + ### Struct Structs are the fundamental compositional type in Thrift. The name of each field must be unique within the struct.