test_protoc_plugin.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  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. svc = fd.service.add()
  25. svc.name = "RoomService"
  26. for name in ("JoinRoom", "CreateRoom"):
  27. m = svc.method.add()
  28. m.name = name
  29. m.input_type = f".chatto.api.v1.{name}Request"
  30. m.output_type = f".chatto.api.v1.{name}Response"
  31. return req
  32. def test_emits_one_file_per_service_with_both_clients():
  33. resp = generate(_request_with_room_service())
  34. assert len(resp.file) == 1
  35. out = resp.file[0]
  36. assert out.name.endswith("rooms_connect.py")
  37. content = out.content
  38. # Both client classes, correct base classes, one method per RPC.
  39. assert "class RoomServiceClient(ConnectClient):" in content
  40. assert "class RoomServiceClientSync(ConnectClientSync):" in content
  41. assert "async def join_room(" in content
  42. assert "async def create_room(" in content
  43. assert "def join_room(" in content # sync variant
  44. # Imports only from chattolib._connect and the sibling pb2.
  45. assert "from chattolib._connect import ConnectClient, ConnectClientSync, MethodInfo" in content
  46. assert "import chatto.api.v1.rooms_pb2 as _pb2" in content
  47. # No connectrpc anywhere.
  48. assert "connectrpc" not in content
  49. # use_get is dropped.
  50. assert "use_get" not in content
  51. def test_skips_files_without_services():
  52. req = g.CodeGeneratorRequest()
  53. req.file_to_generate.append("chatto/realtime/v1/realtime.proto")
  54. fd = req.proto_file.add()
  55. fd.name = "chatto/realtime/v1/realtime.proto"
  56. fd.package = "chatto.realtime.v1"
  57. fd.message_type.add().name = "RealtimeClientFrame" # messages, no service
  58. resp = generate(req)
  59. assert len(resp.file) == 0
  60. def _request_with_cross_file_rpc() -> g.CodeGeneratorRequest:
  61. """A service in account.proto whose UpdatePresence RPC uses a message that
  62. is defined in presence.proto — the cross-file case the plugin must handle."""
  63. req = g.CodeGeneratorRequest()
  64. req.file_to_generate.append("chatto/api/v1/account.proto")
  65. # account.proto: declares the service + its own UpdateProfile messages.
  66. acc = req.proto_file.add()
  67. acc.name = "chatto/api/v1/account.proto"
  68. acc.package = "chatto.api.v1"
  69. acc.message_type.add().name = "UpdateProfileRequest"
  70. acc.message_type.add().name = "UpdateProfileResponse"
  71. svc = acc.service.add()
  72. svc.name = "MyAccountService"
  73. m = svc.method.add()
  74. m.name = "UpdatePresence"
  75. m.input_type = ".chatto.api.v1.UpdatePresenceRequest"
  76. m.output_type = ".chatto.api.v1.UpdatePresenceResponse"
  77. m2 = svc.method.add()
  78. m2.name = "UpdateProfile"
  79. m2.input_type = ".chatto.api.v1.UpdateProfileRequest"
  80. m2.output_type = ".chatto.api.v1.UpdateProfileResponse"
  81. # presence.proto: defines the UpdatePresence request/response.
  82. pres = req.proto_file.add()
  83. pres.name = "chatto/api/v1/presence.proto"
  84. pres.package = "chatto.api.v1"
  85. pres.message_type.add().name = "UpdatePresenceRequest"
  86. pres.message_type.add().name = "UpdatePresenceResponse"
  87. return req
  88. def test_cross_file_rpc_uses_the_defining_module():
  89. resp = generate(_request_with_cross_file_rpc())
  90. assert len(resp.file) == 1
  91. content = resp.file[0].content
  92. # The account_pb2 import is still there for the same-file messages.
  93. assert "import chatto.api.v1.account_pb2 as _pb2" in content
  94. # The presence messages must be imported from their own module and the
  95. # UpdatePresence method must reference *that* module, not _pb2.
  96. assert "import chatto.api.v1.presence_pb2 as _pb2_presence" in content
  97. assert "_pb2_presence.UpdatePresenceRequest" in content
  98. assert "_pb2_presence.UpdatePresenceResponse" in content
  99. # The same-file method keeps using the plain _pb2 alias.
  100. assert "_pb2.UpdateProfileRequest" in content
  101. # Crucially: UpdatePresenceRequest must NOT be referenced via _pb2 (the
  102. # original bug was `_pb2.UpdatePresenceRequest` -> AttributeError).
  103. assert "_pb2.UpdatePresenceRequest" not in content
  104. assert "_pb2.UpdatePresenceResponse" not in content