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

Commit 14630e3

Browse files
Compile well-known types (#35)
* Compile well-known types * Don't import well-known types from the lib * Add duration and timestamp to well-known types
1 parent 96d52f3 commit 14630e3

File tree

9 files changed

+111
-43
lines changed

9 files changed

+111
-43
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ jobs:
6262

6363
- name: Generate code from proto files
6464
shell: bash
65-
run: poetry run python -m tests.generate -v
65+
run: poetry run poe generate

docs/development.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,17 @@
22

33
## Recompiling the lib proto files
44

5-
After some updates in the compiler, it might be useful to recompile the standard Google proto files. As the proto files
6-
are distributed with `protoc`, their path might depend on your installation.
5+
After some updates in the compiler, it might be useful to recompile the standard Google proto files used by the
6+
compiler. As the proto files are distributed with `protoc`, their path might depend on your installation.
77

88
```bash
99
mkdir lib
1010
protoc \
11-
--python_betterproto2_opt=INCLUDE_GOOGLE \
1211
--python_betterproto2_out=lib \
1312
-I /usr/include/ \
1413
/usr/include/google/protobuf/*.proto
1514
```
1615

17-
The generated files should be distributed in the `betterproto2` package.
18-
1916
!!! warning
2017
These proto files are written with the `proto2` syntax, which is not supported by betterproto. For the compiler to
2118
work, you need to manually patch the generated file to mark the field `oneof_index` in `Field` and

pyproject.toml

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,42 @@ cmd = "pytest"
6868
help = "Run tests"
6969

7070
[tool.poe.tasks.generate]
71-
script = "tests.generate:main"
71+
sequence = ["_generate_tests", "_generate_tests_lib"]
7272
help = "Generate test cases"
7373

74+
[tool.poe.tasks._generate_tests]
75+
script = "tests.generate:main"
76+
77+
[tool.poe.tasks._generate_tests_lib]
78+
shell = """
79+
python -m grpc.tools.protoc \
80+
--python_betterproto2_out=tests/output_betterproto \
81+
google/protobuf/any.proto \
82+
google/protobuf/api.proto \
83+
google/protobuf/duration.proto \
84+
google/protobuf/empty.proto \
85+
google/protobuf/field_mask.proto \
86+
google/protobuf/source_context.proto \
87+
google/protobuf/struct.proto \
88+
google/protobuf/timestamp.proto \
89+
google/protobuf/type.proto \
90+
google/protobuf/wrappers.proto
91+
92+
python -m grpc.tools.protoc \
93+
--python_betterproto2_out=tests/output_betterproto_pydantic \
94+
--python_betterproto2_opt=pydantic_dataclasses \
95+
google/protobuf/any.proto \
96+
google/protobuf/api.proto \
97+
google/protobuf/duration.proto \
98+
google/protobuf/empty.proto \
99+
google/protobuf/field_mask.proto \
100+
google/protobuf/source_context.proto \
101+
google/protobuf/struct.proto \
102+
google/protobuf/timestamp.proto \
103+
google/protobuf/type.proto \
104+
google/protobuf/wrappers.proto
105+
"""
106+
74107
[tool.poe.tasks.typecheck]
75108
cmd = "pyright src"
76109
help = "Typecheck the code with Pyright"
@@ -103,25 +136,6 @@ help = "Check the code with the Ruff linter"
103136
cmd = "mkdocs serve"
104137
help = "Serve the documentation locally"
105138

106-
[tool.poe.tasks.generate_lib]
107-
shell = """
108-
mkdir -p tests/output_lib
109-
python -m grpc.tools.protoc \
110-
--python_betterproto2_opt=INCLUDE_GOOGLE \
111-
--python_betterproto2_out=tests/output_lib \
112-
google/protobuf/any.proto \
113-
google/protobuf/api.proto \
114-
google/protobuf/duration.proto \
115-
google/protobuf/empty.proto \
116-
google/protobuf/field_mask.proto \
117-
google/protobuf/source_context.proto \
118-
google/protobuf/struct.proto \
119-
google/protobuf/timestamp.proto \
120-
google/protobuf/type.proto \
121-
google/protobuf/wrappers.proto
122-
"""
123-
help = "Compile the well-known proto types"
124-
125139
[build-system]
126140
requires = ["poetry-core>=1.0.0,<2"]
127141
build-backend = "poetry.core.masonry.api"

src/betterproto2_compiler/compile/importing.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,6 @@ def get_type_reference(
9898
py_package: list[str] = source_package.split(".") if source_package else []
9999
py_type: str = pythonize_class_name(source_type)
100100

101-
compiling_google_protobuf = current_package == ["google", "protobuf"]
102-
importing_google_protobuf = py_package == ["google", "protobuf"]
103-
if importing_google_protobuf and not compiling_google_protobuf:
104-
py_package = ["betterproto2", "lib"] + (["pydantic"] if settings.pydantic_dataclasses else []) + py_package
105-
106-
if py_package[:1] == ["betterproto2"]:
107-
return reference_absolute(imports, py_package, py_type)
108-
109101
if py_package == current_package:
110102
return reference_sibling(py_type)
111103

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from collections.abc import Callable
22

33
from .any import Any
4+
from .duration import Duration
5+
from .timestamp import Timestamp
46

57
# For each (package, message name), lists the methods that should be added to the message definition.
68
# The source code of the method is read from the `known_types` folder. If imports are needed, they can be directly added
79
# to the template file: they will automatically be removed if not necessary.
810
KNOWN_METHODS: dict[tuple[str, str], list[Callable]] = {
911
("google.protobuf", "Any"): [Any.pack, Any.unpack, Any.to_dict],
12+
("google.protobuf", "Timestamp"): [Timestamp.from_datetime, Timestamp.to_datetime, Timestamp.timestamp_to_json],
13+
("google.protobuf", "Duration"): [Duration.from_timedelta, Duration.to_timedelta, Duration.delta_to_json],
1014
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import datetime
2+
3+
from betterproto2.lib.std.google.protobuf import Duration as VanillaDuration
4+
5+
6+
class Duration(VanillaDuration):
7+
@classmethod
8+
def from_timedelta(
9+
cls, delta: datetime.timedelta, *, _1_microsecond: datetime.timedelta = datetime.timedelta(microseconds=1)
10+
) -> "Duration":
11+
total_ms = delta // _1_microsecond
12+
seconds = int(total_ms / 1e6)
13+
nanos = int((total_ms % 1e6) * 1e3)
14+
return cls(seconds, nanos)
15+
16+
def to_timedelta(self) -> datetime.timedelta:
17+
return datetime.timedelta(seconds=self.seconds, microseconds=self.nanos / 1e3)
18+
19+
@staticmethod
20+
def delta_to_json(delta: datetime.timedelta) -> str:
21+
parts = str(delta.total_seconds()).split(".")
22+
if len(parts) > 1:
23+
while len(parts[1]) not in (3, 6, 9):
24+
parts[1] = f"{parts[1]}0"
25+
return f"{'.'.join(parts)}s"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import datetime
2+
3+
from betterproto2.lib.std.google.protobuf import Timestamp as VanillaTimestamp
4+
5+
6+
class Timestamp(VanillaTimestamp):
7+
@classmethod
8+
def from_datetime(cls, dt: datetime.datetime) -> "Timestamp":
9+
# manual epoch offset calulation to avoid rounding errors,
10+
# to support negative timestamps (before 1970) and skirt
11+
# around datetime bugs (apparently 0 isn't a year in [0, 9999]??)
12+
offset = dt - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
13+
# below is the same as timedelta.total_seconds() but without dividing by 1e6
14+
# so we end up with microseconds as integers instead of seconds as float
15+
offset_us = (offset.days * 24 * 60 * 60 + offset.seconds) * 10**6 + offset.microseconds
16+
seconds, us = divmod(offset_us, 10**6)
17+
return cls(seconds, us * 1000)
18+
19+
def to_datetime(self) -> datetime.datetime:
20+
# datetime.fromtimestamp() expects a timestamp in seconds, not microseconds
21+
# if we pass it as a floating point number, we will run into rounding errors
22+
# see also #407
23+
offset = datetime.timedelta(seconds=self.seconds, microseconds=self.nanos // 1000)
24+
return datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) + offset
25+
26+
@staticmethod
27+
def timestamp_to_json(dt: datetime.datetime) -> str:
28+
nanos = dt.microsecond * 1e3
29+
if dt.tzinfo is not None:
30+
# change timezone aware datetime objects to utc
31+
dt = dt.astimezone(datetime.timezone.utc)
32+
copy = dt.replace(microsecond=0, tzinfo=None)
33+
result = copy.isoformat()
34+
if (nanos % 1e9) == 0:
35+
# If there are 0 fractional digits, the fractional
36+
# point '.' should be omitted when serializing.
37+
return f"{result}Z"
38+
if (nanos % 1e6) == 0:
39+
# Serialize 3 fractional digits.
40+
return f"{result}.{int(nanos // 1e6) :03d}Z"
41+
if (nanos % 1e3) == 0:
42+
# Serialize 6 fractional digits.
43+
return f"{result}.{int(nanos // 1e3) :06d}Z"
44+
# Serialize 9 fractional digits.
45+
return f"{result}.{nanos:09d}"

src/betterproto2_compiler/plugin/models.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,6 @@ class OutputTemplate:
189189
messages: dict[str, "MessageCompiler"] = field(default_factory=dict)
190190
enums: dict[str, "EnumDefinitionCompiler"] = field(default_factory=dict)
191191
services: dict[str, "ServiceCompiler"] = field(default_factory=dict)
192-
output: bool = True
193192

194193
settings: Settings
195194

src/betterproto2_compiler/plugin/parser.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,6 @@ def generate_code(request: CodeGeneratorRequest) -> CodeGeneratorResponse:
107107
# Add this input file to the output corresponding to this package
108108
request_data.output_packages[output_package_name].input_files.append(proto_file)
109109

110-
if proto_file.package == "google.protobuf" and "INCLUDE_GOOGLE" not in plugin_options:
111-
# If not INCLUDE_GOOGLE,
112-
# skip outputting Google's well-known types
113-
request_data.output_packages[output_package_name].output = False
114-
115110
# Read Messages and Enums
116111
# We need to read Messages before Services in so that we can
117112
# get the references to input/output messages for each service
@@ -147,9 +142,6 @@ def generate_code(request: CodeGeneratorRequest) -> CodeGeneratorResponse:
147142
# Generate output files
148143
output_paths: set[pathlib.Path] = set()
149144
for output_package_name, output_package in request_data.output_packages.items():
150-
if not output_package.output:
151-
continue
152-
153145
# Add files to the response object
154146
output_path = pathlib.Path(*output_package_name.split("."), "__init__.py")
155147
output_paths.add(output_path)

0 commit comments

Comments
 (0)