test_protoc_plugin.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. """The plugin turns a CodeGeneratorRequest into async+sync client stubs."""
  2. import importlib.util
  3. import os
  4. from chattolib._protoc_plugin import generate
  5. _CODEGEN_DIR = os.path.join(
  6. os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
  7. "src",
  8. "chattolib",
  9. "_codegen",
  10. )
  11. def _load(name):
  12. path = os.path.join(_CODEGEN_DIR, f"{name}.py")
  13. spec = importlib.util.spec_from_file_location(f"_test_codegen_{name}", path)
  14. mod = importlib.util.module_from_spec(spec)
  15. spec.loader.exec_module(mod) # type: ignore[union-attr]
  16. return mod
  17. g = _load("protoc_gen_request_pb2")
  18. def _request_with_room_service() -> g.CodeGeneratorRequest:
  19. req = g.CodeGeneratorRequest()
  20. req.file_to_generate.append("chatto/api/v1/rooms.proto")
  21. fd = req.proto_file.add()
  22. fd.name = "chatto/api/v1/rooms.proto"
  23. fd.package = "chatto.api.v1"
  24. for name in ("JoinRoomRequest", "JoinRoomResponse", "CreateRoomRequest", "CreateRoomResponse"):
  25. fd.message_type.add().name = name
  26. svc = fd.service.add()
  27. svc.name = "RoomService"
  28. for name in ("JoinRoom", "CreateRoom"):
  29. m = svc.method.add()
  30. m.name = name
  31. m.input_type = f".chatto.api.v1.{name}Request"
  32. m.output_type = f".chatto.api.v1.{name}Response"
  33. return req
  34. def test_emits_one_file_per_service_with_both_clients():
  35. resp = generate(_request_with_room_service())
  36. assert len(resp.file) == 1
  37. out = resp.file[0]
  38. assert out.name.endswith("rooms_connect.py")
  39. content = out.content
  40. # Both client classes, correct base classes, one method per RPC.
  41. assert "class RoomServiceClient(ConnectClient):" in content
  42. assert "class RoomServiceClientSync(ConnectClientSync):" in content
  43. assert "async def join_room(" in content
  44. assert "async def create_room(" in content
  45. assert "def join_room(" in content # sync variant
  46. # Imports only from chattolib._connect and the sibling pb2 (unique alias).
  47. assert "from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo" in content
  48. assert "import chatto.api.v1.rooms_pb2 as chatto_api_v1_rooms_pb2" in content
  49. # No connectrpc anywhere.
  50. assert "connectrpc" not in content
  51. # use_get is dropped.
  52. assert "use_get" not in content
  53. def _request_with_cross_file_ref() -> g.CodeGeneratorRequest:
  54. """A service in account.proto that references messages defined elsewhere."""
  55. req = g.CodeGeneratorRequest()
  56. req.file_to_generate.append("chatto/api/v1/account.proto")
  57. fd = req.proto_file.add()
  58. fd.name = "chatto/api/v1/account.proto"
  59. fd.package = "chatto.api.v1"
  60. svc = fd.service.add()
  61. svc.name = "MyAccountService"
  62. m = svc.method.add()
  63. m.name = "ListExternalIdentities"
  64. m.input_type = ".chatto.api.v1.ListExternalIdentitiesRequest"
  65. m.output_type = ".chatto.api.v1.ListExternalIdentitiesResponse"
  66. # The referenced messages live in a *different* proto (no service of its own).
  67. fd2 = req.proto_file.add()
  68. fd2.name = "chatto/api/v1/external_identities.proto"
  69. fd2.package = "chatto.api.v1"
  70. for name in ("ListExternalIdentitiesRequest", "ListExternalIdentitiesResponse"):
  71. fd2.message_type.add().name = name
  72. return req
  73. def test_cross_file_message_reference_uses_defining_module():
  74. """Regression: a service must import the module that *defines* each message.
  75. (The old plugin referenced every message through the service's own module,
  76. which broke cross-file references like ``account.proto`` ->
  77. ``external_identities.proto``.)
  78. """
  79. resp = generate(_request_with_cross_file_ref())
  80. assert len(resp.file) == 1
  81. content = resp.file[0].content
  82. assert (
  83. "import chatto.api.v1.external_identities_pb2 as chatto_api_v1_external_identities_pb2"
  84. in content
  85. )
  86. assert "chatto_api_v1_external_identities_pb2.ListExternalIdentitiesRequest" in content
  87. # Not referenced through the service's own module.
  88. assert "chatto_api_v1_account_pb2.ListExternalIdentitiesRequest" not in content
  89. def test_acronym_rpc_keeps_acronym_whole():
  90. """Regression: ``StartDM`` must snake_case to ``start_dm``, not ``start_d_m``.
  91. (The old per-capital splitter chopped consecutive capitals — acronym runs
  92. like ``DM`` — into single letters, so the generated stub was
  93. ``start_d_m`` while ``client.py`` called ``start_dm``: an AttributeError
  94. only reachable at call time.)
  95. """
  96. req = g.CodeGeneratorRequest()
  97. req.file_to_generate.append("chatto/api/v1/rooms.proto")
  98. fd = req.proto_file.add()
  99. fd.name = "chatto/api/v1/rooms.proto"
  100. fd.package = "chatto.api.v1"
  101. for name in (
  102. "StartDMRequest",
  103. "StartDMResponse",
  104. "GetRoomDMStatsRequest",
  105. "GetRoomDMStatsResponse",
  106. ):
  107. fd.message_type.add().name = name
  108. svc = fd.service.add()
  109. svc.name = "RoomService"
  110. for name in ("StartDM", "GetRoomDMStats"):
  111. m = svc.method.add()
  112. m.name = name
  113. m.input_type = f".chatto.api.v1.{name}Request"
  114. m.output_type = f".chatto.api.v1.{name}Response"
  115. resp = generate(req)
  116. content = resp.file[0].content
  117. assert "async def start_dm(" in content
  118. assert "def start_dm(" in content # sync variant
  119. assert "start_d_m" not in content
  120. # acronym followed by another word: boundary before the acronym AND before
  121. # the word that follows it
  122. assert "async def get_room_dm_stats(" in content
  123. def test_skips_files_without_services():
  124. req = g.CodeGeneratorRequest()
  125. req.file_to_generate.append("chatto/realtime/v1/realtime.proto")
  126. fd = req.proto_file.add()
  127. fd.name = "chatto/realtime/v1/realtime.proto"
  128. fd.package = "chatto.realtime.v1"
  129. fd.message_type.add().name = "RealtimeClientFrame" # messages, no service
  130. resp = generate(req)
  131. assert len(resp.file) == 0