_file.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  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 sys
  16. from collections import defaultdict
  17. from contextlib import AbstractContextManager, contextmanager
  18. from typing import TYPE_CHECKING, Any, Final, Protocol
  19. from protobuf import DescEnum, DescExtension, DescFile, DescMessage, ScalarType
  20. from protobuf._typing import assert_never
  21. from protobuf.plugin._ident import Ident, Module
  22. if TYPE_CHECKING:
  23. from collections.abc import Generator, Iterable, Iterator
  24. _INDENT = " " * 4
  25. _TYPING = Module("typing")
  26. _TYPE_CHECKING = _TYPING.ident("TYPE_CHECKING")
  27. _WKT_MODULE = Module("protobuf.wkt")
  28. _WKT_PROTO_PATHS: frozenset[str] = frozenset(
  29. {
  30. "google/protobuf/compiler/plugin.proto",
  31. "google/protobuf/any.proto",
  32. "google/protobuf/api.proto",
  33. "google/protobuf/cpp_features.proto",
  34. "google/protobuf/descriptor.proto",
  35. "google/protobuf/duration.proto",
  36. "google/protobuf/empty.proto",
  37. "google/protobuf/field_mask.proto",
  38. "google/protobuf/go_features.proto",
  39. "google/protobuf/java_features.proto",
  40. "google/protobuf/source_context.proto",
  41. "google/protobuf/struct.proto",
  42. "google/protobuf/timestamp.proto",
  43. "google/protobuf/type.proto",
  44. "google/protobuf/wrappers.proto",
  45. }
  46. )
  47. class File(Protocol):
  48. """A Python file that will have content generated based on the operations on this object."""
  49. path: str
  50. """The path of the file to generate, relative to the output directory."""
  51. def print(self, *args: object) -> None:
  52. """Add a line to the file.
  53. Args:
  54. *args: Elements to print on the line. Each argument is converted
  55. to a string element:
  56. - `str` values are printed verbatim.
  57. - `int`/`float`/`bool` as literals.
  58. - `bytes` as a bytes literal.
  59. - `Ident` as an imported name.
  60. - `ScalarType`, `DescMessage`, `DescEnum`, or
  61. `DescExtension` as imported symbols.
  62. - `list` values are recursively flattened.
  63. - Other values are formatted with `f"{arg}"`.
  64. """
  65. def ident(self, name: str, *, type_only: bool = False) -> Ident:
  66. """Return an identifier scoped to this file's module.
  67. Args:
  68. name: The symbol name.
  69. type_only: If true, the import is emitted only inside an
  70. `if TYPE_CHECKING:` block.
  71. Returns:
  72. An `Ident` linked to this file's module.
  73. """
  74. ...
  75. def scope(self, *args: object) -> AbstractContextManager[None]:
  76. """Open an indented scope.
  77. Prints `args` as the scope header and indents all subsequent
  78. `print()` calls inside the context manager by one level.
  79. Args:
  80. *args: Elements to print as the scope header line.
  81. Examples:
  82. ```python
  83. with f.scope("class Foo:"):
  84. f.print("field: str")
  85. # Output:
  86. # class Foo:
  87. # field: str
  88. ```
  89. """
  90. ...
  91. def type_checking(self) -> AbstractContextManager[None]:
  92. """Open an `if TYPE_CHECKING:` scope.
  93. Emits `if TYPE_CHECKING:` as a scope header and marks all
  94. identifiers printed inside the context manager as type-only
  95. imports.
  96. Raises:
  97. RuntimeError: If already inside a type-checking
  98. context.
  99. Examples:
  100. ```python
  101. with f.type_checking():
  102. f.print("x: ", f.ident("Foo"))
  103. # Output:
  104. # if TYPE_CHECKING:
  105. # x: Foo
  106. #
  107. # The import for Foo is emitted under TYPE_CHECKING.
  108. ```
  109. """
  110. ...
  111. def preamble(self, desc: DescFile) -> None:
  112. """Add a `DO NOT EDIT` preamble derived from `desc`.
  113. Args:
  114. desc: The file descriptor whose proto name is used in the preamble.
  115. """
  116. ...
  117. def doc(self, *args: object) -> AbstractContextManager[None]:
  118. r'''Open a Python docstring.
  119. Prints opening `"""` (with `args` on the same line if
  120. provided) and closing `"""` on exit. If `args` are
  121. provided and nothing is printed inside the context manager, the
  122. docstring collapses to a single line. While the context is
  123. open, `print()` escapes `\` and `"""` in string
  124. elements so they cannot break the docstring. `scope()` works
  125. normally inside for indented sections.
  126. Args:
  127. *args: Optional elements to print on the opening line
  128. after the triple-quote.
  129. Examples:
  130. ```python
  131. with f.doc("A single-line docstring."):
  132. pass
  133. # Output:
  134. # """A single-line docstring."""
  135. ```
  136. ```python
  137. with f.doc("A short description."):
  138. f.print()
  139. f.print("Longer description.")
  140. # Output:
  141. # """A short description.
  142. #
  143. # Longer description.
  144. # """
  145. ```
  146. ```python
  147. with f.doc("Get a user by ID."):
  148. f.print()
  149. with f.scope("Args:"):
  150. f.print("user_id: The unique identifier.")
  151. f.print()
  152. with f.scope("Returns:"):
  153. f.print("The matching user.")
  154. # Output:
  155. # """Get a user by ID.
  156. #
  157. # Args:
  158. # user_id: The unique identifier.
  159. #
  160. # Returns:
  161. # The matching user.
  162. # """
  163. ```
  164. '''
  165. ...
  166. class _File:
  167. if TYPE_CHECKING:
  168. module: Final[Module]
  169. def __init__(
  170. self,
  171. path: str,
  172. module: Module,
  173. file_to_generate: frozenset[str],
  174. plugin_name: str,
  175. plugin_version: str,
  176. parameter: str,
  177. *,
  178. escape_module_with_hash: bool = False,
  179. ) -> None:
  180. self.path = path
  181. self.module = module
  182. self._file_to_generate = file_to_generate
  183. self._plugin_name = plugin_name
  184. self._plugin_version = plugin_version
  185. self._parameter = parameter
  186. self._escape_module_with_hash = escape_module_with_hash
  187. self._indent = 0
  188. self._type_checking = False
  189. self._in_doc = False
  190. self._has_preamble = False
  191. self._preamble_proto_name: str | None = None
  192. self._elements: list[str | Ident] = []
  193. self._runtime_imports: dict[Module, set[Ident]] = defaultdict(set)
  194. self._type_imports: dict[Module, set[Ident]] = defaultdict(set)
  195. def print(self, *args: object) -> None:
  196. elements: list[str | Ident] = [self._to_el(arg) for arg in _flatten(args)]
  197. if all(element == "" for element in elements):
  198. self._elements.append("\n")
  199. return
  200. self._elements.extend([_INDENT * self._indent, *elements, "\n"])
  201. def ident(self, name: str, *, type_only: bool = False) -> Ident:
  202. return self.module.ident(name, type_only=type_only)
  203. @contextmanager
  204. def scope(self, *args: object) -> Generator[None, Any, None]:
  205. self.print(*args)
  206. self._indent += 1
  207. try:
  208. yield
  209. finally:
  210. self._indent -= 1
  211. @contextmanager
  212. def type_checking(self) -> Generator[None, Any, None]:
  213. if self._type_checking:
  214. msg = "already in a typechecking context"
  215. raise RuntimeError(msg)
  216. try:
  217. with self.scope("if ", _TYPE_CHECKING, ":"):
  218. self._type_checking = True
  219. yield
  220. finally:
  221. self._type_checking = False
  222. def preamble(self, desc: DescFile) -> None:
  223. self._has_preamble = True
  224. self._preamble_proto_name = desc.name
  225. @contextmanager
  226. def doc(self, *args: object) -> Generator[None, Any, None]:
  227. self._in_doc = True
  228. # Build the opening line manually: the '"""' prefix must not
  229. # be escaped, but user-provided args must be.
  230. self._elements.extend(
  231. [
  232. _INDENT * self._indent,
  233. '"""',
  234. *[self._to_el(arg) for arg in _flatten(args)],
  235. "\n",
  236. ]
  237. )
  238. count = len(self._elements)
  239. try:
  240. yield
  241. finally:
  242. self._in_doc = False
  243. if len(self._elements) == count:
  244. # Nothing was printed inside the block — collapse to
  245. # a single line: """Summary."""
  246. self._elements[-1] = '"""\n'
  247. else:
  248. self._elements.extend([_INDENT * self._indent, '"""', "\n"])
  249. def _to_el(self, v: object) -> str | Ident:
  250. ident: Ident
  251. match v:
  252. case ScalarType(): # This should be before int because this is an IntEnum
  253. return _scalar_type(v)
  254. case str():
  255. if self._in_doc:
  256. # Escape backslashes and triple-quotes so they
  257. # cannot break the enclosing docstring.
  258. return v.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
  259. return v
  260. case DescMessage() | DescEnum() | DescExtension() | DescFile():
  261. ident = _desc_ident(
  262. v,
  263. self._file_to_generate,
  264. type_only=False,
  265. escape_module_with_hash=self._escape_module_with_hash,
  266. )
  267. case Ident():
  268. ident = (
  269. _desc_ident(
  270. v._desc,
  271. self._file_to_generate,
  272. type_only=v.type_only,
  273. escape_module_with_hash=self._escape_module_with_hash,
  274. )
  275. if v._desc
  276. else v
  277. )
  278. case _:
  279. return repr(v)
  280. ident = self._relativize(ident)
  281. if ident.type_only:
  282. self._type_imports[ident.module].add(ident)
  283. else:
  284. self._runtime_imports[ident.module].add(ident)
  285. return ident
  286. def _relativize(self, ident: Ident) -> Ident:
  287. if not _is_relative(ident.module):
  288. return Ident(
  289. ident.name,
  290. ident.module,
  291. type_only=ident.type_only or self._type_checking,
  292. )
  293. self_segments = _module_segments(self.module)
  294. import_segments = _module_segments(ident.module)
  295. if self_segments == import_segments:
  296. path = ""
  297. else:
  298. package_segments = (
  299. self_segments if _is_init_py(self.path) else self_segments[:-1]
  300. )
  301. shared = _shared_prefix_len(package_segments, import_segments)
  302. leading_dots = len(package_segments) - shared + 1
  303. path = "." * leading_dots + ".".join(import_segments[shared:])
  304. return Module(path).ident(
  305. ident.name, type_only=ident.type_only or self._type_checking
  306. )
  307. def write(file: _File, path: str, *, no_fmt_off: bool = False) -> str:
  308. """Writes the file contents to string and returns that.
  309. We sort and group imports similar to ruff.
  310. """
  311. # Collect all the runtime imports with sorted identifier names
  312. runtime_imports: dict[Module, list[Ident]] = {
  313. module: sorted(idents)
  314. for module, idents in file._runtime_imports.items()
  315. if module.path != ""
  316. }
  317. # Type only imports that are not already in the runtime imports
  318. type_only_imports: dict[Module, list[Ident]] = {}
  319. for module, idents in file._type_imports.items():
  320. if module.path == "":
  321. continue
  322. unique = [
  323. ident for ident in idents if ident not in runtime_imports.get(module, [])
  324. ]
  325. if unique:
  326. type_only_imports[module] = sorted(unique)
  327. # If there are type only imports, add the TYPE_CHECKING identifier if not already added
  328. if type_only_imports:
  329. if _TYPING not in runtime_imports:
  330. runtime_imports[_TYPING] = []
  331. if _TYPE_CHECKING not in runtime_imports[_TYPING]:
  332. runtime_imports[_TYPING] = [
  333. _TYPE_CHECKING,
  334. *sorted(runtime_imports[_TYPING]),
  335. ]
  336. aliases = _AliasResolver(file)
  337. hdr: list[str] = []
  338. if file._has_preamble and file._preamble_proto_name is not None:
  339. hdr.extend(
  340. [
  341. _preamble(
  342. file._preamble_proto_name,
  343. file._plugin_name,
  344. file._plugin_version,
  345. file._parameter,
  346. no_fmt_off=no_fmt_off,
  347. ),
  348. "",
  349. ]
  350. )
  351. if path.endswith(".py"):
  352. # Ruff doesn't complain even if this is in an empty file and always puts this at the top with a new line after
  353. hdr.extend(["from __future__ import annotations", ""])
  354. _write_imports(hdr, aliases, runtime_imports)
  355. if type_only_imports:
  356. hdr.extend([f"if {aliases.resolve(_TYPE_CHECKING)}:"])
  357. _write_imports(hdr, aliases, type_only_imports, indent=_INDENT)
  358. result = "".join(
  359. [
  360. "\n".join(hdr) + "\n\n" if hdr else "",
  361. *[
  362. el if isinstance(el, str) else aliases.resolve(el)
  363. for el in file._elements
  364. ],
  365. ]
  366. )
  367. return f"{result.rstrip()}\n" if result else ""
  368. def _preamble(
  369. proto_name: str,
  370. plugin_name: str,
  371. plugin_version: str,
  372. parameter: str,
  373. *,
  374. no_fmt_off: bool,
  375. ) -> str:
  376. """Return the DO NOT EDIT file header for a generated proto file."""
  377. lines = [
  378. f"# Generated from {proto_name}. DO NOT EDIT.",
  379. f'# Generated by {plugin_name} v{plugin_version} with parameter "{parameter}".',
  380. "# ruff: noqa: PGH004",
  381. "# ruff: noqa",
  382. ]
  383. if not no_fmt_off:
  384. lines.append("# fmt: off")
  385. return "\n".join(lines)
  386. def _desc_ident(
  387. desc: DescEnum | DescMessage | DescExtension | DescFile,
  388. file_to_generate: frozenset[str],
  389. *,
  390. type_only: bool = False,
  391. escape_module_with_hash: bool = False,
  392. ) -> Ident:
  393. """Return an importable identifier for a descriptor type."""
  394. ident = Ident.for_desc(
  395. desc, type_only=type_only, escape_module_with_hash=escape_module_with_hash
  396. )
  397. file = desc if isinstance(desc, DescFile) else desc.file
  398. if _use_wkt_module(file, file_to_generate):
  399. return Ident(ident.name, _WKT_MODULE, type_only=type_only)
  400. return ident
  401. def _use_wkt_module(desc: DescFile, file_to_generate: frozenset[str]) -> bool:
  402. """Return True if the descriptor should be imported from protobuf.wkt."""
  403. # Well-known types are imported from protobuf.wkt unless the
  404. # WKT proto is itself being generated, in which case we use
  405. # a relative import to the generated file.
  406. return desc.name in _WKT_PROTO_PATHS and desc.name not in file_to_generate
  407. def _write_imports(
  408. lines: list[str],
  409. aliases: _AliasResolver,
  410. imports: dict[Module, list[Ident]],
  411. indent: str = "",
  412. ) -> None:
  413. """Append grouped import lines to `lines`."""
  414. # Ruff splits the imports into groups of std, global, and relative. We also do the same:
  415. for group in _group_and_sort_imports(imports):
  416. for module, idents in group.items():
  417. deduped = sorted({aliases.resolve_import(ident) for ident in idents})
  418. lines.append(f"{indent}from {module.path} import {', '.join(deduped)}")
  419. lines.append("")
  420. def _group_and_sort_imports(
  421. imports: dict[Module, list[Ident]],
  422. ) -> list[dict[Module, list[Ident]]]:
  423. """Split imports into stdlib, third-party, and relative groups."""
  424. std, gbl, rel = {}, {}, {}
  425. for module, idents in imports.items():
  426. if _is_relative(module):
  427. rel[module] = idents
  428. elif module.path.split(".", 1)[0] in sys.stdlib_module_names:
  429. std[module] = idents
  430. else:
  431. gbl[module] = idents
  432. return [dict(sorted(group.items())) for group in [std, gbl, rel] if group]
  433. class _AliasResolver:
  434. """Keeps track of all the symbols (imported and self) and their aliases for a file.
  435. Aliases are created by adding `_` to the end. For every n conflicting symbols, each
  436. nth conflict's alias is (n-1) underscores appended.
  437. Symbols that belong to the file take precedence.
  438. """
  439. def __init__(self, file: _File) -> None:
  440. # Mark all identifiers of the current file as seen to avoid aliases.
  441. self._seen: dict[str, list[Ident]] = {
  442. ident.name: [ident] for ident in file._runtime_imports[Module("")]
  443. }
  444. # We must also mark type only ones of the current module
  445. for ident in file._type_imports[Module("")]:
  446. self._resolve(ident)
  447. def resolve(self, ident: Ident) -> str:
  448. """Returns the alias or name."""
  449. root, _, suffix = ident.name.partition(".")
  450. if suffix:
  451. resolved = self._resolve_name(Ident(root, ident.module))
  452. return f"{resolved}.{suffix}"
  453. return self._resolve_name(ident)
  454. def resolve_import(self, ident: Ident) -> str:
  455. """Returns the import statement for an alias or the name."""
  456. root, _, suffix = ident.name.partition(".")
  457. if suffix:
  458. ident = Ident(root, ident.module)
  459. al = self._resolve(ident)
  460. return f"{ident.name} as {al}" if al else ident.name
  461. def _resolve_name(self, ident: Ident) -> str:
  462. """Returns the alias or name for the ident."""
  463. return al if (al := self._resolve(ident)) else ident.name
  464. def _resolve(self, ident: Ident) -> str:
  465. if ident.name not in self._seen:
  466. self._seen[ident.name] = [ident]
  467. if ident not in self._seen[ident.name]:
  468. self._seen[ident.name].append(ident)
  469. i = self._seen[ident.name].index(ident)
  470. if i == 0:
  471. return ""
  472. candidate = f"{ident.name}{'_' * i}"
  473. return self._resolve(Ident(candidate, ident.module)) or candidate
  474. def _scalar_type(scalar: ScalarType) -> str: # noqa: RET503
  475. """Map a protobuf scalar type to its Python type name."""
  476. match scalar:
  477. case ScalarType.DOUBLE | ScalarType.FLOAT:
  478. return "float"
  479. case (
  480. ScalarType.INT64
  481. | ScalarType.UINT64
  482. | ScalarType.INT32
  483. | ScalarType.FIXED64
  484. | ScalarType.FIXED32
  485. | ScalarType.UINT32
  486. | ScalarType.SFIXED32
  487. | ScalarType.SFIXED64
  488. | ScalarType.SINT32
  489. | ScalarType.SINT64
  490. ):
  491. return "int"
  492. case ScalarType.BOOL:
  493. return "bool"
  494. case ScalarType.STRING:
  495. return "str"
  496. case ScalarType.BYTES:
  497. return "bytes"
  498. case _:
  499. assert_never(scalar)
  500. def _flatten(args: Iterable[object]) -> Iterator[object]:
  501. """Recursively flatten nested lists into a single sequence."""
  502. for arg in args:
  503. if isinstance(arg, list):
  504. yield from _flatten(arg)
  505. else:
  506. yield arg
  507. def _is_relative(module: Module) -> bool:
  508. """Return True if the module path is a relative import."""
  509. return module.path.startswith(".")
  510. def _module_segments(module: Module) -> list[str]:
  511. path = module.path.removeprefix(".")
  512. return path.split(".") if path else []
  513. def _is_init_py(path: str) -> bool:
  514. file_name = path.rsplit("/", maxsplit=1)[-1]
  515. return file_name == "__init__.py"
  516. def _shared_prefix_len(left: list[str], right: list[str]) -> int:
  517. for i, (left_part, right_part) in enumerate(zip(left, right, strict=False)):
  518. if left_part != right_part:
  519. return i
  520. return min(len(left), len(right))