_run.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. # Copyright (c) 2025-2026 Buf Technologies, Inc.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. from __future__ import annotations
  15. import dataclasses
  16. import sys
  17. import traceback
  18. from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
  19. from protobuf import maximum_supported_edition, minimum_supported_edition
  20. from protobuf.plugin._file import write
  21. from protobuf.plugin._options import parse_options
  22. from protobuf.plugin._schema import _Schema
  23. from protobuf.wkt import CodeGeneratorRequest, CodeGeneratorResponse
  24. if TYPE_CHECKING:
  25. from collections.abc import Callable
  26. from _typeshed import DataclassInstance
  27. from protobuf.plugin._schema import Schema
  28. D = TypeVar("D", bound="DataclassInstance")
  29. T = TypeVar("T")
  30. @overload
  31. def run(
  32. name: str,
  33. version: str,
  34. generate: Callable[[Schema[None]], None],
  35. /,
  36. *,
  37. minimum_edition: int = minimum_supported_edition,
  38. maximum_edition: int = maximum_supported_edition,
  39. ) -> None: ...
  40. @overload
  41. def run(
  42. name: str,
  43. version: str,
  44. options: type[D],
  45. generate: Callable[[Schema[D]], None],
  46. /,
  47. *,
  48. minimum_edition: int = minimum_supported_edition,
  49. maximum_edition: int = maximum_supported_edition,
  50. ) -> None: ...
  51. @overload
  52. def run(
  53. name: str,
  54. version: str,
  55. options: Callable[[str], T],
  56. generate: Callable[[Schema[T]], None],
  57. /,
  58. *,
  59. minimum_edition: int = minimum_supported_edition,
  60. maximum_edition: int = maximum_supported_edition,
  61. ) -> None: ...
  62. def run(
  63. name: str,
  64. version: str,
  65. options_or_generate: type[DataclassInstance] | Callable[..., Any],
  66. generate: Callable[[Schema[Any]], None] | None = None,
  67. /,
  68. *,
  69. minimum_edition: int = minimum_supported_edition,
  70. maximum_edition: int = maximum_supported_edition,
  71. ) -> None:
  72. """Run a protoc plugin.
  73. This is the single entry point for any protoc plugin. It handles the
  74. full lifecycle: reading the request, parsing options, building descriptors,
  75. calling the generate callback, and writing the response.
  76. Two calling conventions are supported:
  77. run(name, version, generate)
  78. run(name, version, options, generate)
  79. Args:
  80. name: Plugin name, used for `--version` output.
  81. version: Plugin version, used for `--version` output.
  82. options_or_generate: If a dataclass type, parse key=value pairs into it.
  83. If a callable and `generate` is omitted, used directly as the
  84. generate callback. Otherwise, called with the raw parameter string.
  85. generate: Callback invoked with the schema.
  86. minimum_edition: Minimum edition supported by this plugin.
  87. Defaults to the runtime's minimum_supported_edition.
  88. maximum_edition: Maximum edition supported by this plugin.
  89. Defaults to the runtime's maximum_supported_edition.
  90. """
  91. if generate is None:
  92. generate = cast("Callable[[Schema[Any]], None]", options_or_generate)
  93. options: type[DataclassInstance] | Callable[[str], Any] | None = None
  94. else:
  95. options = options_or_generate
  96. if "--version" in sys.argv:
  97. sys.stdout.write(f"{name} v{version}\n")
  98. sys.exit(0)
  99. if isinstance(options, type):
  100. framework_fields = {f.name for f in dataclasses.fields(_FrameworkOptions)}
  101. plugin_fields = {f.name for f in dataclasses.fields(options)}
  102. conflicts = plugin_fields & framework_fields
  103. if conflicts:
  104. keys = ", ".join(sorted(conflicts))
  105. msg = (
  106. f"dataclass options type uses reserved framework option key(s): {keys}"
  107. )
  108. raise ValueError(msg)
  109. req = CodeGeneratorRequest.from_binary(sys.stdin.buffer.read())
  110. try:
  111. fw_opts, remaining_parameter = parse_options(_FrameworkOptions, req.parameter)
  112. plugin_options = _parse_plugin_options(options, remaining_parameter)
  113. schema = _Schema(
  114. req,
  115. plugin_options,
  116. minimum_edition=minimum_edition,
  117. maximum_edition=maximum_edition,
  118. name=name,
  119. version=version,
  120. escape_module_with_hash=fw_opts.escape_module_with_hash,
  121. )
  122. generate(schema)
  123. response_files = [
  124. CodeGeneratorResponse.File(
  125. name=path, content=write(f, path, no_fmt_off=fw_opts.no_fmt_off)
  126. )
  127. for path, f in schema._generated_files.items()
  128. ]
  129. features = (
  130. CodeGeneratorResponse.Feature.PROTO3_OPTIONAL
  131. | CodeGeneratorResponse.Feature.SUPPORTS_EDITIONS
  132. )
  133. resp = CodeGeneratorResponse(
  134. supported_features=int(features),
  135. minimum_edition=minimum_edition,
  136. maximum_edition=maximum_edition,
  137. file=response_files,
  138. )
  139. sys.stdout.buffer.write(resp.to_binary())
  140. except Exception: # noqa: BLE001
  141. error_msg = traceback.format_exc()
  142. _write_error_response(error_msg)
  143. def _parse_plugin_options(
  144. options: type[DataclassInstance] | Callable[[str], Any] | None, parameter: str
  145. ) -> Any:
  146. """Parse plugin options from the parameter string.
  147. Args:
  148. options: None, a dataclass type, or a callable.
  149. parameter: The remaining parameter string after framework options removed.
  150. Returns:
  151. Parsed options, or None if options is None.
  152. Raises:
  153. ValueError: If options is None but parameter is non-empty.
  154. """
  155. if options is None:
  156. if parameter:
  157. msg = f"plugin does not accept options but got parameter: {parameter!r}"
  158. raise ValueError(msg)
  159. return None
  160. if isinstance(options, type):
  161. result, unknown = parse_options(options, parameter)
  162. if unknown:
  163. msg = f"unknown option(s): {unknown}"
  164. raise ValueError(msg)
  165. return result
  166. return options(parameter)
  167. def _write_error_response(error: str) -> None:
  168. """Write an error response to stdout."""
  169. resp = CodeGeneratorResponse(error=error)
  170. sys.stdout.buffer.write(resp.to_binary())
  171. @dataclasses.dataclass
  172. class _FrameworkOptions:
  173. no_fmt_off: bool = False
  174. escape_module_with_hash: bool = False