_schema.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  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. from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, overload
  16. from protobuf import DescFile
  17. from protobuf._file_registry import _get_file_edition
  18. from protobuf.wkt import FileDescriptorSet
  19. from ._file import File, _File
  20. from ._ident import Module
  21. if TYPE_CHECKING:
  22. from collections.abc import Sequence
  23. from protobuf.wkt import CodeGeneratorRequest
  24. T_co = TypeVar("T_co", covariant=True)
  25. class Schema(Protocol[T_co]):
  26. """Schema describes the files and types that the plugin is requested to generate."""
  27. @property
  28. def options(self) -> T_co:
  29. """Parsed plugin options."""
  30. ...
  31. @property
  32. def files_to_generate(self) -> Sequence[DescFile]:
  33. """Files we are asked to generate."""
  34. ...
  35. @property
  36. def all_files(self) -> Sequence[DescFile]:
  37. """All files including transitive dependencies."""
  38. ...
  39. @overload
  40. def generate_file(self, path: str, /) -> File: ...
  41. @overload
  42. def generate_file(self, desc: DescFile, suffix: str, /) -> File: ...
  43. def generate_file(
  44. self, path_or_desc: str | DescFile = "", suffix: str = "", /
  45. ) -> File:
  46. """Create a generated file.
  47. Two calling conventions are supported:
  48. `generate_file(path)`
  49. Create a file at an arbitrary output path relative to the
  50. generation root.
  51. `generate_file(desc, suffix)`
  52. Create a file with a path derived from the descriptor:
  53. `<proto path without .proto><suffix>.py`.
  54. Args:
  55. path_or_desc: Either a string path (e.g.
  56. `"my_package/__init__.py"`) or a `DescFile` to
  57. derive the path from.
  58. suffix: When `path_or_desc` is a `DescFile`, the
  59. suffix to append, with file extension. For example,
  60. `"_pb.py"` turns `google/protobuf/any.proto` into
  61. `google/protobuf/any_pb.py`, while `"_pb.txt"` turns it into
  62. `google/protobuf/any_pb.txt`.
  63. Returns:
  64. A new `File` for writing content into.
  65. """
  66. ...
  67. class _Schema(Generic[T_co]):
  68. def __init__(
  69. self,
  70. req: CodeGeneratorRequest,
  71. options: T_co,
  72. *,
  73. minimum_edition: int,
  74. maximum_edition: int,
  75. name: str,
  76. version: str,
  77. escape_module_with_hash: bool,
  78. ) -> None:
  79. self._options = options
  80. self._name = name
  81. self._version = version
  82. self._parameter = req.parameter
  83. self._generated_files: dict[str, _File] = {}
  84. self._escape_module_with_hash = escape_module_with_hash
  85. file_to_generate = frozenset(req.file_to_generate)
  86. source_by_name = {s.name: s for s in req.source_file_descriptors}
  87. for file in req.proto_file:
  88. if file.name not in file_to_generate:
  89. continue
  90. edition = _get_file_edition(source_by_name.get(file.name, file))
  91. if edition < minimum_edition or edition > maximum_edition:
  92. msg = f"{file.name} has edition {edition}, which is outside the supported range {minimum_edition} to {maximum_edition}"
  93. raise ValueError(msg)
  94. reg = FileDescriptorSet(
  95. file=[source_by_name.get(f.name, f) for f in req.proto_file]
  96. ).to_registry()
  97. all_files: list[DescFile] = []
  98. for f in req.proto_file:
  99. desc = reg.file(f.name)
  100. assert desc is not None, f"registry missing file {f.name}" # noqa: S101
  101. all_files.append(desc)
  102. self._all_files = all_files
  103. self._file_to_generate = file_to_generate
  104. self._file_desc_to_generate = [
  105. d for d in all_files if d.name in file_to_generate
  106. ]
  107. @property
  108. def options(self) -> T_co:
  109. return self._options
  110. @property
  111. def files_to_generate(self) -> Sequence[DescFile]:
  112. return self._file_desc_to_generate
  113. @property
  114. def all_files(self) -> Sequence[DescFile]:
  115. return self._all_files
  116. def generate_file(
  117. self, path_or_desc: str | DescFile = "", suffix: str = ""
  118. ) -> _File:
  119. if isinstance(path_or_desc, DescFile):
  120. # Module represents a Python import path base, which generation uses
  121. # primarily for resolving relative imports. Because suffix can point to
  122. # arbitrary non-Python files, we always ensure the module is still a
  123. # standard Python module path.
  124. suffix, _, ext = suffix.partition(".")
  125. module = Module.for_desc(
  126. path_or_desc, suffix, escape_with_hash=self._escape_module_with_hash
  127. )
  128. path = _output_path_from_module(module)
  129. if ext:
  130. path += f".{ext}"
  131. else:
  132. path = path_or_desc
  133. module = _module_from_path(path_or_desc)
  134. f = _File(
  135. path,
  136. module,
  137. self._file_to_generate,
  138. self._name,
  139. self._version,
  140. self._parameter,
  141. escape_module_with_hash=self._escape_module_with_hash,
  142. )
  143. self._generated_files[path] = f
  144. return f
  145. def _output_path_from_module(module: Module) -> str:
  146. """Compute the output path from a module."""
  147. module_path = module.path.removeprefix(".")
  148. if not module_path:
  149. return "__init__"
  150. return module_path.replace(".", "/")
  151. def _module_from_path(path: str) -> Module:
  152. """Compute a Module from an arbitrary output path.
  153. `__init__.py` paths are mapped to their parent package because
  154. `foo/bar/__init__.py` is the Python package `foo.bar`, not the
  155. module `foo.bar.__init__`. A bare `__init__.py` maps to the
  156. root package `.`.
  157. """
  158. parts = path.split("/")
  159. if not parts:
  160. return Module(".")
  161. base, _, _ = parts[-1].partition(".")
  162. parts[-1] = base
  163. if parts[-1] == "__init__":
  164. parts = parts[:-1]
  165. if not parts:
  166. return Module(".")
  167. return Module(f".{'.'.join(parts)}")