Skip to content

Commit c3239e4

Browse files
authored
Improve and fix support for compiling protobuf files (#19)
The main changes are: - Avoid running sessions on sources for API repos API repos are supposed to just ship python files generated from protocol buffer files. Only a stub `__init__.py` should be included, which doesn't really need to be checked. Allowing checks on the sources for an API repos triggers a lot of issues when the protocol buffers files are generated, as they don't pass any checks. An alternative would be to exclude auto-generated files, but it's not easy because each tool has its own exclusion mechanism, and some don't even have any (hello `darglint`), so it is simpler to just skip testing the stub `__init__.py` file. - Avoid the need to write a setup.py file for API repos To do this we declare a distutils entry point in the `pyproject.toml` file to automatically add the `compile_proto` command using the `CompileProto` proto class. Then, in the `grpc_tools` module we just add the newly declared command as a `build` sub-command, so it is executed first. This make all other subsequent sub-commands will see the generated sources as if they were already there. The only downside of this is now we don't have an easy way to configure the command. The options are there, and can be passed via command-line, like: ```sh python -c 'import setuptools; setuptools.setup()' compile_proto \ --proto-path=another/path ``` But there is no easy way to do it in a config file, or at least at the moment I couldn't find a way to pass arbitrary options to setuptools commands in `pyproject.toml`. If we need this, we could manually parse the `pyproject.toml` file and look for options, just like `setuptools_scm` does.
2 parents 97c1259 + 289768b commit c3239e4

File tree

6 files changed

+131
-118
lines changed

6 files changed

+131
-118
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ dynamic = ["version"]
3636
name = "Frequenz Energy-as-a-Service GmbH"
3737
3838

39+
[project.entry-points."distutils.commands"]
40+
compile_proto = "frequenz.repo.config.setuptools.grpc_tools:CompileProto"
41+
3942
[project.optional-dependencies]
4043
actor = []
4144
api = [

src/frequenz/py.typed

Whitespace-only changes.

src/frequenz/repo/config/__init__.py

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,16 @@
154154
155155
When configuring APIs it is assumed that they have a gRPC interface.
156156
157+
The project structure is assumed to be as follows:
158+
159+
- `proto/`: Directory containing the `.proto` files.
160+
- `py/`: Directory containing the Python code. It should only provide
161+
a `py.typed` file and a `__init__.py` file. API repositories should not
162+
contain any other Python code.
163+
- `pytests/`: Directory containing the tests for the Python code.
164+
- `submodules/api-common-protos`: Directory containing the submodule with the
165+
`google/api-common-protos` repository.
166+
157167
Normally Frequenz APIs use basic types from
158168
[`google/api-common-protos`](https://github.com/googleapis/api-common-protos),
159169
so you need to make sure the proper submodule is added to your project:
@@ -164,17 +174,6 @@
164174
git commit -m "Add Google api-common-protos submodule" submodules/api-common-protos
165175
```
166176
167-
Then you need to create a `setup.py` file with the following content:
168-
169-
```py
170-
import setuptools
171-
172-
from frequenz.repo.config.setuptools import grpc_tools
173-
174-
if __name__ == "__main__":
175-
setuptools.setup(cmdclass=grpc_tools.build_proto_cmdclass())
176-
```
177-
178177
Then you need to add this package as a build dependency and a few extra
179178
dependencies to your project, for example:
180179
@@ -198,22 +197,33 @@
198197
package. Of course you need to replace the version numbers with the correct
199198
ones too.
200199
200+
You should also add the following configuration to your `pyproject.toml` file
201+
to make sure the generated files are included in the wheel:
202+
203+
```toml
204+
[tool.setuptools.package-dir]
205+
"" = "py"
206+
207+
[tool.setuptools.package-data]
208+
"*" = ["*.pyi"]
209+
210+
[tools.pytest.ini_options]
211+
testpaths = ["pytests"]
212+
```
213+
201214
Finally you need to make sure to include the generated `*.pyi` files in the
202215
source distribution, as well as the Google api-common-protos files, as it
203216
is not handled automatically yet
204-
([#13](https://github.com/frequenz-floss/frequenz-repo-config-python/issues/13)):
217+
([#13](https://github.com/frequenz-floss/frequenz-repo-config-python/issues/13)).
218+
Make sure to include these lines in the `MANIFEST.in` file:
205219
206220
```
207-
recursive-include py *.pyi
208221
recursive-include submodules/api-common-protos/google *.proto
209222
```
210223
211-
If you need to customize how the protobuf files are compiled, you can pass
212-
a path to look for the protobuf files, a glob pattern to find the protobuf
213-
files, and a list of paths to include when compiling the protobuf files. By
214-
default `submodules/api-common-protos` is used as an include path, so if
215-
yout configured the submodules in a different path, you need to pass the
216-
correct path to the `include_paths` argument.
224+
For now there is no way to customize where the protocol files are located,
225+
where the generated files should be placed, or which extra directories must be
226+
included when compiling the protocol files.
217227
"""
218228

219229
from . import nox, setuptools

src/frequenz/repo/config/nox/default.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@
9191

9292
api_config: _config.Config = dataclasses.replace(
9393
common_config,
94-
source_paths=list(util.replace(common_config.source_paths, {"src": "py"})),
94+
opts=api_command_options,
95+
# We don't check the sources at all because they are automatically generated.
96+
source_paths=[],
97+
# We adapt the path to the tests.
9598
extra_paths=list(util.replace(common_config.extra_paths, {"tests": "pytests"})),
9699
)
97100
"""Default configuration for APIs.

src/frequenz/repo/config/nox/util.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import pathlib
1212
import tomllib
13-
from collections.abc import Iterable, Mapping, Set
13+
from collections.abc import Iterable, Mapping
1414
from typing import TypeVar
1515

1616
_T = TypeVar("_T")
@@ -36,14 +36,13 @@ def replace(iterable: Iterable[_T], replacements: Mapping[_T, _T], /) -> Iterabl
3636
3737
Args:
3838
iterable: The iterable to replace elements in.
39-
old: The elements to replace.
40-
new: The elements to replace with.
39+
replacements: A mapping of elements to replace with other elements.
4140
42-
Returns:
43-
An iterable with the elements in `iterable` replaced.
41+
Yields:
42+
The next element in the iterable, with the replacements applied.
4443
4544
Example:
46-
>>> assert list(replace([1, 2, 3], old={1, 2}, new={4, 5})) == [4, 5, 3]
45+
>>> assert list(replace([1, 2, 3], {1: 4, 2: 5})) == [4, 5, 3]
4746
"""
4847
for item in iterable:
4948
if item in replacements:
@@ -210,7 +209,7 @@ def discover_paths() -> list[str]:
210209
with open("pyproject.toml", "rb") as toml_file:
211210
data = tomllib.load(toml_file)
212211

213-
testpaths = (
212+
testpaths: list[str] = (
214213
data.get("tool", {})
215214
.get("pytest", {})
216215
.get("ini_options", {})

src/frequenz/repo/config/setuptools/grpc_tools.py

Lines changed: 89 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,101 +3,99 @@
33

44
"""Setuptool hooks to build protobuf files.
55
6-
This module provides a function that returns a dictionary with the required
7-
machinery to build protobuf files via setuptools.
6+
This module contains a setuptools command that can be used to compile protocol
7+
buffer files in a project.
8+
9+
It also runs the command as the first sub-command for the build command, so
10+
protocol buffer files are compiled automatically before the project is built.
811
"""
912

1013
import pathlib
1114
import subprocess
1215
import sys
13-
from collections.abc import Iterable
14-
from typing import Any
1516

1617
import setuptools
17-
import setuptools.command.build_py
18-
19-
20-
def build_proto_cmdclass(
21-
*,
22-
proto_path: str = "proto",
23-
proto_glob: str = "*.proto",
24-
include_paths: Iterable[str] = ("submodules/api-common-protos",),
25-
) -> dict[str, Any]:
26-
"""Return a dictionary with the required machinery to build protobuf files.
27-
28-
This dictionary is meant to be passed as the `cmdclass` argument of
29-
`setuptools.setup()`.
30-
31-
It will add the following commands to setuptools:
32-
33-
- `compile_proto`: Adds a command to compile the protobuf files to
34-
Python files.
35-
- `build_py`: Use the `compile_proto` command to build the python files
36-
and run the regular `build_py` command, so the protobuf files are
37-
create automatically when the python package is created.
38-
39-
Unless an explicit `include_paths` is passed, the
40-
`submodules/api-common-protos` wiil be added to the include paths, so your
41-
project should have a submodule with the common google api protos in that
42-
path.
43-
44-
Args:
45-
proto_path: Path of the root directory containing the protobuf files.
46-
proto_glob: The glob pattern to use to find the protobuf files.
47-
include_paths: Paths to include when compiling the protobuf files.
48-
49-
Returns:
50-
Options to pass to `setuptools.setup()` `cmdclass` argument to build
51-
protobuf files.
52-
"""
53-
54-
class CompileProto(setuptools.Command):
55-
"""Build the Python protobuf files."""
56-
57-
description: str = f"compile protobuf files in {proto_path}/**/{proto_glob}/"
58-
"""Description of the command."""
59-
60-
user_options: list[str] = []
61-
"""Options of the command."""
62-
63-
def initialize_options(self) -> None:
64-
"""Initialize options."""
65-
66-
def finalize_options(self) -> None:
67-
"""Finalize options."""
68-
69-
def run(self) -> None:
70-
"""Compile the Python protobuf files."""
71-
proto_files = [str(p) for p in pathlib.Path(proto_path).rglob(proto_glob)]
72-
protoc_cmd = (
73-
[sys.executable, "-m", "grpc_tools.protoc"]
74-
+ [f"-I{p}" for p in [*include_paths, proto_path]]
75-
+ """--python_out=py
76-
--grpc_python_out=py
77-
--mypy_out=py
78-
--mypy_grpc_out=py
79-
""".split()
80-
+ proto_files
81-
)
82-
print(f"Compiling proto files via: {' '.join(protoc_cmd)}")
83-
subprocess.run(protoc_cmd, check=True)
84-
85-
class BuildPy(setuptools.command.build_py.build_py, CompileProto):
86-
"""Build the Python protobuf files and run the regular `build_py` command."""
87-
88-
def run(self) -> None:
89-
"""Compile the Python protobuf files and run regular `build_py`."""
90-
CompileProto.run(self)
91-
setuptools.command.build_py.build_py.run(self)
92-
93-
return {
94-
"compile_proto": CompileProto,
95-
# Compile the proto files to python files. This is done when building
96-
# the wheel, the source distribution (sdist) contains the *.proto files
97-
# only. Check the MANIFEST.in file to see which files are included in
98-
# the sdist, and the tool.setuptools.package-dir,
99-
# tool.setuptools.package-data, and tools.setuptools.packages
100-
# configuration keys in pyproject.toml to see which files are included
101-
# in the wheel package.
102-
"build_py": BuildPy,
103-
}
18+
19+
# The typing stub for this module is missing
20+
import setuptools.command.build # type: ignore[import]
21+
22+
23+
class CompileProto(setuptools.Command):
24+
"""Build the Python protobuf files."""
25+
26+
proto_path: str
27+
"""The path of the root directory containing the protobuf files."""
28+
29+
proto_glob: str
30+
"""The glob pattern to use to find the protobuf files."""
31+
32+
include_paths: str
33+
"""Comma-separated list of paths to include when compiling the protobuf files."""
34+
35+
py_path: str
36+
"""The path of the root directory where the Python files will be generated."""
37+
38+
description: str = "compile protobuf files"
39+
"""Description of the command."""
40+
41+
user_options: list[tuple[str, str | None, str]] = [
42+
(
43+
"proto-path=",
44+
None,
45+
"path of the root directory containing the protobuf files",
46+
),
47+
("proto-glob=", None, "glob pattern to use to find the protobuf files"),
48+
(
49+
"include-paths=",
50+
None,
51+
"comma-separated list of paths to include when compiling the protobuf files",
52+
),
53+
(
54+
"py-path=",
55+
None,
56+
"path of the root directory where the Python files will be generated",
57+
),
58+
]
59+
"""Options of the command."""
60+
61+
def initialize_options(self) -> None:
62+
"""Initialize options."""
63+
self.proto_path = "proto"
64+
self.proto_glob = "*.proto"
65+
self.include_paths = "submodules/api-common-protos"
66+
self.py_path = "py"
67+
68+
def finalize_options(self) -> None:
69+
"""Finalize options."""
70+
71+
def run(self) -> None:
72+
"""Compile the Python protobuf files."""
73+
include_paths = self.include_paths.split(",")
74+
proto_files = [
75+
str(p) for p in pathlib.Path(self.proto_path).rglob(self.proto_glob)
76+
]
77+
78+
if not proto_files:
79+
print(f"No proto files found in {self.proto_path}/**/{self.proto_glob}/")
80+
return
81+
82+
protoc_cmd = (
83+
[sys.executable, "-m", "grpc_tools.protoc"]
84+
+ [f"-I{p}" for p in [*include_paths, self.proto_path]]
85+
+ [
86+
f"--{opt}={self.py_path}"
87+
for opt in "python_out grpc_python_out mypy_out mypy_grpc_out".split()
88+
]
89+
+ proto_files
90+
)
91+
92+
print(f"Compiling proto files via: {' '.join(protoc_cmd)}")
93+
subprocess.run(protoc_cmd, check=True)
94+
95+
96+
# This adds the compile_proto command to the build sub-command.
97+
# The name of the command is mapped to the class name in the pyproject.toml file,
98+
# in the [project.entry-points.distutils.commands] section.
99+
# The None value is an optional function that can be used to determine if the
100+
# sub-command should be executed or not.
101+
setuptools.command.build.build.sub_commands.insert(0, ("compile_proto", None))

0 commit comments

Comments
 (0)