Skip to content
This repository was archived by the owner on Jun 9, 2025. It is now read-only.

Commit a7d4041

Browse files
Add synchronous stubs (#14)
* Use jinja templates to define service stubs, add sync stub * Sync stub: add constructor, remove parent class * Redefine method definition for sync stubs * Add blocks * Move method body to async template * Generated methods for sync clients * Format code * Add missing import * Remove useless condition * Remove unsupported parameters * Add service proto file * Convert iterable to iterator * Use the grpc-async extra * Option to choose whether to compile sync or / and async clients * Fix Python 3.10
1 parent f3e3e8a commit a7d4041

File tree

11 files changed

+334
-107
lines changed

11 files changed

+334
-107
lines changed

poetry.lock

Lines changed: 27 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ packages = [
2626

2727
[tool.poetry.dependencies]
2828
python = "^3.10"
29-
betterproto2 = "^0.3.1"
29+
betterproto2 = { version = "^0.3.1", extras = ["grpc-async"] }
3030
# betterproto2 = { git="https://github.com/betterproto/python-betterproto2" }
3131
# The Ruff version is pinned. To update it, also update it in .pre-commit-config.yaml
3232
ruff = "~0.9.3"
33-
grpclib = "^0.4.1"
3433
jinja2 = ">=3.0.3"
3534
typing-extensions = "^4.7.1"
35+
strenum = [
36+
{version = "^0.4.15", python = "=3.10"},
37+
]
3638

3739
[tool.poetry.group.dev.dependencies]
3840
pre-commit = "^2.17.0"

src/betterproto2_compiler/plugin/parser.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
CodeGeneratorResponseFeature,
1515
CodeGeneratorResponseFile,
1616
)
17-
from betterproto2_compiler.settings import Settings
17+
from betterproto2_compiler.settings import ClientGeneration, Settings
1818

1919
from .compiler import outputfile_compiler
2020
from .models import (
@@ -60,8 +60,24 @@ def _traverse(
6060

6161

6262
def get_settings(plugin_options: list[str]) -> Settings:
63+
# Synchronous clients are suitable for most users
64+
client_generation = ClientGeneration.SYNC
65+
66+
for opt in plugin_options:
67+
if opt.startswith("client_generation="):
68+
name = opt.split("=")[1]
69+
70+
# print(ClientGeneration.__members__, file=sys.stderr)
71+
# print([member.value for member in ClientGeneration])
72+
73+
try:
74+
client_generation = ClientGeneration(name)
75+
except ValueError:
76+
raise ValueError(f"Invalid client_generation option: {name}")
77+
6378
return Settings(
6479
pydantic_dataclasses="pydantic_dataclasses" in plugin_options,
80+
client_generation=client_generation,
6581
)
6682

6783

src/betterproto2_compiler/settings.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,67 @@
1+
import sys
12
from dataclasses import dataclass
23

4+
if sys.version_info >= (3, 11):
5+
from enum import StrEnum
6+
else:
7+
from strenum import StrEnum
8+
9+
10+
class ClientGeneration(StrEnum):
11+
NONE = "none"
12+
"""Clients are not generated."""
13+
14+
SYNC = "sync"
15+
"""Only synchronous clients are generated."""
16+
17+
ASYNC = "async"
18+
"""Only asynchronous clients are generated."""
19+
20+
SYNC_ASYNC = "sync_async"
21+
"""Both synchronous and asynchronous clients are generated.
22+
23+
The asynchronous client is generated with the Async suffix."""
24+
25+
ASYNC_SYNC = "async_sync"
26+
"""Both synchronous and asynchronous clients are generated.
27+
28+
The synchronous client is generated with the Sync suffix."""
29+
30+
SYNC_ASYNC_NO_DEFAULT = "sync_async_no_default"
31+
"""Both synchronous and asynchronous clients are generated.
32+
33+
The synchronous client is generated with the Sync suffix, and the asynchronous client is generated with the Async
34+
suffix."""
35+
36+
@property
37+
def is_sync_generated(self) -> bool:
38+
return self in {
39+
ClientGeneration.SYNC,
40+
ClientGeneration.SYNC_ASYNC,
41+
ClientGeneration.ASYNC_SYNC,
42+
ClientGeneration.SYNC_ASYNC_NO_DEFAULT,
43+
}
44+
45+
@property
46+
def is_async_generated(self) -> bool:
47+
return self in {
48+
ClientGeneration.ASYNC,
49+
ClientGeneration.SYNC_ASYNC,
50+
ClientGeneration.ASYNC_SYNC,
51+
ClientGeneration.SYNC_ASYNC_NO_DEFAULT,
52+
}
53+
54+
@property
55+
def is_sync_prefixed(self) -> bool:
56+
return self in {ClientGeneration.ASYNC_SYNC, ClientGeneration.SYNC_ASYNC_NO_DEFAULT}
57+
58+
@property
59+
def is_async_prefixed(self) -> bool:
60+
return self in {ClientGeneration.SYNC_ASYNC, ClientGeneration.SYNC_ASYNC_NO_DEFAULT}
61+
362

463
@dataclass
564
class Settings:
665
pydantic_dataclasses: bool
66+
67+
client_generation: ClientGeneration

src/betterproto2_compiler/templates/header.py.j2

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ __all__ = (
2121
import builtins
2222
import datetime
2323
import warnings
24-
from collections.abc import AsyncIterable, AsyncIterator, Iterable
24+
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator
2525
import typing
2626
from typing import TYPE_CHECKING
2727

@@ -33,10 +33,9 @@ from dataclasses import dataclass
3333
{% endif %}
3434

3535
import betterproto2
36-
{% if output_file.services %}
3736
from betterproto2.grpc.grpclib_server import ServiceBase
37+
import grpc
3838
import grpclib
39-
{% endif %}
4039

4140
{# Import the message pool of the generated code. #}
4241
{% if output_file.package %}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class {% block class_name %}{% endblock %}({% block inherit_from %}{% endblock %}):
2+
{% block service_docstring scoped %}
3+
{% if service.comment %}
4+
"""
5+
{{ service.comment | indent(4) }}
6+
"""
7+
{% elif not service.methods %}
8+
pass
9+
{% endif %}
10+
{% endblock %}
11+
12+
{% block class_content %}{% endblock %}
13+
14+
{% for method in service.methods %}
15+
{% block method_definition scoped required %}{% endblock %}
16+
{% block method_docstring scoped %}
17+
{% if method.comment %}
18+
"""
19+
{{ method.comment | indent(8) }}
20+
"""
21+
{% endif %}
22+
{% endblock %}
23+
24+
{% block deprecation_warning scoped %}
25+
{% if method.deprecated %}
26+
warnings.warn("{{ service.py_name }}.{{ method.py_name }} is deprecated", DeprecationWarning)
27+
{% endif %}
28+
{% endblock %}
29+
30+
{% block method_body scoped required %}{% endblock %}
31+
32+
{% endfor %}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{% extends "service_stub.py.j2" %}
2+
3+
{# Class definition #}
4+
{% block class_name %}{{ service.py_name }}{% if output_file.settings.client_generation.is_async_prefixed %}Async{% endif %}Stub{% endblock %}
5+
{% block inherit_from %}betterproto2.ServiceStub{% endblock %}
6+
7+
{# Methods definition #}
8+
{% block method_definition %}
9+
async def {{ method.py_name }}(self
10+
{%- if not method.client_streaming -%}
11+
, message:
12+
{%- if method.is_input_msg_empty -%}
13+
"{{ method.py_input_message_type }} | None" = None
14+
{%- else -%}
15+
"{{ method.py_input_message_type }}"
16+
{%- endif -%}
17+
{%- else -%}
18+
{# Client streaming: need a request iterator instead #}
19+
, messages: "AsyncIterable[{{ method.py_input_message_type }}] | Iterable[{{ method.py_input_message_type }}]"
20+
{%- endif -%}
21+
,
22+
*
23+
, timeout: "float | None" = None
24+
, deadline: "Deadline | None" = None
25+
, metadata: "MetadataLike | None" = None
26+
) -> "{% if method.server_streaming %}AsyncIterator[{{ method.py_output_message_type }}]{% else %}{{ method.py_output_message_type }}{% endif %}":
27+
{% endblock %}
28+
29+
{% block method_body %}
30+
{% if method.server_streaming %}
31+
{% if method.client_streaming %}
32+
async for response in self._stream_stream(
33+
"{{ method.route }}",
34+
messages,
35+
{{ method.py_input_message_type }},
36+
{{ method.py_output_message_type }},
37+
timeout=timeout,
38+
deadline=deadline,
39+
metadata=metadata,
40+
):
41+
yield response
42+
{% else %}
43+
{% if method.is_input_msg_empty %}
44+
if message is None:
45+
message = {{ method.py_input_message_type }}()
46+
47+
{% endif %}
48+
async for response in self._unary_stream(
49+
"{{ method.route }}",
50+
message,
51+
{{ method.py_output_message_type }},
52+
timeout=timeout,
53+
deadline=deadline,
54+
metadata=metadata,
55+
):
56+
yield response
57+
58+
{% endif %}
59+
{% else %}
60+
{% if method.client_streaming %}
61+
return await self._stream_unary(
62+
"{{ method.route }}",
63+
messages,
64+
{{ method.py_input_message_type }},
65+
{{ method.py_output_message_type }},
66+
timeout=timeout,
67+
deadline=deadline,
68+
metadata=metadata,
69+
)
70+
{% else %}
71+
{% if method.is_input_msg_empty %}
72+
if message is None:
73+
message = {{ method.py_input_message_type }}()
74+
75+
{% endif %}
76+
return await self._unary_unary(
77+
"{{ method.route }}",
78+
message,
79+
{{ method.py_output_message_type }},
80+
timeout=timeout,
81+
deadline=deadline,
82+
metadata=metadata,
83+
)
84+
{% endif %}
85+
{% endif %}
86+
{% endblock %}

0 commit comments

Comments
 (0)