Repository: ansys/ansys-tools-protoc-helper (language: python)

Issue:
Add testing infrastructure
The tests should have two parts:
- ~~unit tests, which just exercise the proto compilation part and do not involve `pip` or `setuptools`~~ not easily possible, because dependency finding goes via entry points
- integration tests, installing complete test packages
Add type hints, enable strict mypy setting



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad2adef..3a366cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,12 @@
-# check spelling, codestyle
 name: GitHub CI
-on: [push, pull_request]
+# run only on main branch.  This avoids duplicated actions on PRs
+on:
+  pull_request:
+  push:
+    tags:
+      - "*"
+    branches:
+      - main
 jobs:
   pre-commit:
     name: Pre-commit checks
@@ -12,11 +18,35 @@ jobs:
         uses: actions/setup-python@v2
         with:
           python-version: 3.7
-
+          cache: 'pip'
+          cache-dependency-path: 'pyproject.toml'
       - name: Install development requirements
         run: |
-          pip install .[dev]
+          pip install .[pre-commit]
 
       - name: Run pre-commit
         run: |
           pre-commit run --all-files || ( git status --short ; git diff ; exit 1 )
+  test:
+    name: Run tests
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, windows-latest]
+        python-version: ["3.7", "3.8", "3.9", "3.10"]
+      fail-fast: false
+    steps:
+      - uses: actions/checkout@v2
+      - name: Setup Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python-version }}
+          cache: 'pip'
+          cache-dependency-path: 'pyproject.toml'
+      - name: Install development requirements
+        run: |
+          pip install .[test]
+      - name: Run tests
+        run: |
+          cd test
+          pytest -v
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c52cc32..8b5d412 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,5 +29,5 @@ repos:
     language: python
     types: [python]
     require_serial: true
-    exclude: "^(doc/)"
+    exclude: "^(doc/)|(test/)"
     args: []
diff --git a/pyproject.toml b/pyproject.toml
index 093a6ff..8be7901 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,17 +12,21 @@ classifiers = ["License :: OSI Approved :: MIT License"]
 dynamic = ["version", "description"]
 requires-python = ">=3.7"
 dependencies = [
+    "setuptools>=42.0",
     "grpcio-tools==1.17.0",
     "protobuf==3.19.3",
     "mypy-protobuf==3.1.0",
 ]
 
 [project.optional-dependencies]
-dev = [
-    "pre-commit",
+pre-commit = [
+    "pre-commit~=2.16.0",
     "mypy==0.931",
     "types-setuptools==57.4.7",
 ]
+test = [
+    "pytest~=6.0",
+]
 
 [project.urls]
 Source = "https://github.com/ansys/ansys-tools-protoc-helper"
@@ -36,7 +40,7 @@ target-version = ['py37']
 
 [tool.mypy]
 python_version = 3.7
-# strict = true
+strict = true
 
 [[tool.mypy.overrides]]
 module = [
diff --git a/src/ansys/tools/protoc_helper/__init__.py b/src/ansys/tools/protoc_helper/__init__.py
index 244693b..79877c0 100644
--- a/src/ansys/tools/protoc_helper/__init__.py
+++ b/src/ansys/tools/protoc_helper/__init__.py
@@ -1,6 +1,6 @@
 """A utility for compiling '.proto' files to Python source."""
-from ._build_helper import *
+from ._compile_protos import *
 from ._distutils_overrides import *
 
 __version__ = "0.0.0"
-__all__ = _build_helper.__all__ + _distutils_overrides.__all__  # type: ignore
+__all__ = _compile_protos.__all__ + _distutils_overrides.__all__  # type: ignore
diff --git a/src/ansys/tools/protoc_helper/_build_helper.py b/src/ansys/tools/protoc_helper/_build_helper.py
deleted file mode 100644
index 77f12dc..0000000
--- a/src/ansys/tools/protoc_helper/_build_helper.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import glob
-import importlib.resources
-import os
-import pathlib
-import shutil
-import tempfile
-
-import pkg_resources
-
-__all__ = ["build_proto_py"]
-
-
-def build_proto_py(dest_package):
-    from grpc.tools import protoc
-
-    command = [
-        "grpc_tools.protoc",
-        f"--python_out={dest_package}",
-        f"--grpc_python_out={dest_package}",
-        f"--mypy_out={dest_package}",
-        f"--mypy_grpc_out={dest_package}",
-        f"--proto_path={dest_package}",
-    ]
-    with tempfile.TemporaryDirectory() as tmp_dir:
-        for entry_point in pkg_resources.iter_entry_points("pyansys_protoc_helper.proto_provider"):
-            module = entry_point.load()
-            relpath = pathlib.Path(tmp_dir, *(module.__name__.split(".")))
-            relpath.mkdir(exist_ok=True, parents=True)
-            # TODO: handle nested paths
-            for filename in importlib.resources.contents(module):
-                if filename.endswith(".proto"):
-                    with importlib.resources.path(module, filename) as src_path:
-                        shutil.copyfile(src_path, relpath / filename)
-
-        command.append(f"--proto_path={tmp_dir}")
-
-        target_protos = glob.glob(os.path.join(dest_package, "**/*.proto"), recursive=True)
-        command += target_protos
-
-        exit_code = protoc.main(command)
-        if exit_code != 0:
-            raise RuntimeError(f"Proto file compilation failed, command '{' '.join(command)}'.")
diff --git a/src/ansys/tools/protoc_helper/_compile_protos.py b/src/ansys/tools/protoc_helper/_compile_protos.py
new file mode 100644
index 0000000..65f8b70
--- /dev/null
+++ b/src/ansys/tools/protoc_helper/_compile_protos.py
@@ -0,0 +1,59 @@
+import contextlib
+import glob
+import importlib.resources
+import os
+import pathlib
+import shutil
+import tempfile
+import types
+import typing
+
+import pkg_resources
+
+__all__ = ["compile_proto_files"]
+
+
+def compile_proto_files(target_package: str) -> None:
+    from grpc.tools import protoc
+
+    command = [
+        "grpc_tools.protoc",
+        f"--python_out={target_package}",
+        f"--grpc_python_out={target_package}",
+        f"--mypy_out={target_package}",
+        f"--mypy_grpc_out={target_package}",
+        f"--proto_path={target_package}",
+    ]
+    with contextlib.ExitStack() as exit_stack:
+        for module in _dependency_modules():
+            module_proto_dir = exit_stack.enter_context(_proto_directory(module))
+            command.append(f"--proto_path={module_proto_dir}")
+
+        target_protos = glob.glob(os.path.join(target_package, "**/*.proto"), recursive=True)
+        command += target_protos
+
+        exit_code = protoc.main(command)
+        if exit_code != 0:
+            raise RuntimeError(f"Proto file compilation failed, command '{' '.join(command)}'.")
+
+
+def _dependency_modules() -> typing.Iterator[types.ModuleType]:
+    yield from (
+        entry_point.load()
+        for entry_point in pkg_resources.iter_entry_points(
+            "ansys.tools.protoc_helper.proto_provider"
+        )
+    )
+
+
+@contextlib.contextmanager
+def _proto_directory(module: types.ModuleType) -> typing.Iterator[str]:
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        relpath = pathlib.Path(tmp_dir, *(module.__name__.split(".")))
+        for filename in importlib.resources.contents(module):
+            if filename.endswith(".proto"):
+                with importlib.resources.path(module, filename) as src_path:
+                    dest_path = relpath / filename
+                    os.makedirs(dest_path.parent, exist_ok=True)
+                    shutil.copyfile(src_path, relpath / filename)
+        yield tmp_dir
diff --git a/src/ansys/tools/protoc_helper/_distutils_overrides.py b/src/ansys/tools/protoc_helper/_distutils_overrides.py
index 55b309b..458f81a 100644
--- a/src/ansys/tools/protoc_helper/_distutils_overrides.py
+++ b/src/ansys/tools/protoc_helper/_distutils_overrides.py
@@ -1,15 +1,27 @@
+# type: ignore
+# Type checking causes problems with the mixin approach used here. This
+# could be overcome by subclassing '_CompileProtosMixin' from
+# 'distutils.core.Command', but mypy *also* doesn't recognize that
+# this class has a 'distribution' attribute. Overall, it's not worth
+# the extra overhead.
+"""Defines setuptools commands to execute the proto compilation
+
+Define commands which can be used in the setuptools ``cmdclass``
+directive to override the default behavior, and compile the .proto
+files before the command is executed.
+"""
 from setuptools.command.build_py import build_py
 from setuptools.command.develop import develop
 
-from ._build_helper import build_proto_py
+from ._compile_protos import compile_proto_files
 
 __all__ = ["BuildPyCommand", "DevelopCommand", "CMDCLASS_OVERRIDE"]
 
 
 class _CompileProtosMixin:
-    def run(self, *args, **kwargs):
-        build_proto_py(self.distribution.package_dir[""])
-        super().run(*args, **kwargs)
+    def run(self):
+        compile_proto_files(self.distribution.package_dir[""])
+        super().run()
 
 
 class BuildPyCommand(_CompileProtosMixin, build_py):


Test patch:
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..c78c28b
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,5 @@
+import pathlib
+
+TEST_ROOT_DIR = pathlib.Path(__file__).parent
+PKG_ROOT_DIR = TEST_ROOT_DIR.parent
+TEST_DATA_DIR = TEST_ROOT_DIR / "test_data"
diff --git a/test/greeter.json b/test/greeter.json
deleted file mode 100644
index c4335ad..0000000
--- a/test/greeter.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-    "cookiecutter": {
-        "product_name": "greeter",
-        "project_slug": "ansys-greeter-protos",
-        "api_version": "0",
-        "protos_dir": "../greeter",
-        "proto_dependencies": {
-            "modules": ["ansys-hello-protos"]
-        }
-    }
-}
diff --git a/test/test_data/.gitignore b/test/test_data/.gitignore
new file mode 100644
index 0000000..ad208a1
--- /dev/null
+++ b/test/test_data/.gitignore
@@ -0,0 +1,2 @@
+*_pb2*.py
+*.pyi
diff --git a/test/test_data/testpkg-greeter-protos/pyproject.toml b/test/test_data/testpkg-greeter-protos/pyproject.toml
new file mode 100644
index 0000000..b23ac15
--- /dev/null
+++ b/test/test_data/testpkg-greeter-protos/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=42.0", "wheel", "ansys-tools-protoc-helper", "testpkg-hello-protos"]
+build-backend = "setuptools.build_meta:__legacy__"
diff --git a/test/test_data/testpkg-greeter-protos/setup.py b/test/test_data/testpkg-greeter-protos/setup.py
new file mode 100644
index 0000000..c901df2
--- /dev/null
+++ b/test/test_data/testpkg-greeter-protos/setup.py
@@ -0,0 +1,22 @@
+import setuptools
+from ansys.tools.protoc_helper import CMDCLASS_OVERRIDE
+
+
+if __name__ == "__main__":
+    setuptools.setup(
+        name="testpkg-greeter-protos",
+        author="ANSYS, Inc.",
+        python_requires=">=3.7",
+        install_requires=["grpcio~=1.0", "protobuf~=3.0", "testpkg-hello-protos"],
+        packages=["testpkg.api.greeter.v0"],
+        package_dir={"": "src"},
+        package_data={
+            "": ["*.proto", "*.pyi", "py.typed"],
+        },
+        entry_points={
+            "ansys.tools.protoc_helper.proto_provider": {
+                "testpkg.api.greeter.v0=testpkg.api.greeter.v0"
+            },
+        },
+        cmdclass=CMDCLASS_OVERRIDE,
+    )
diff --git a/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/__init__.py b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/greeter/ansys/api/greeter/v0/greeter.proto b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter.proto
similarity index 65%
rename from test/greeter/ansys/api/greeter/v0/greeter.proto
rename to test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter.proto
index 4e8470c..5e8715b 100644
--- a/test/greeter/ansys/api/greeter/v0/greeter.proto
+++ b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter.proto
@@ -1,6 +1,6 @@
 syntax = "proto3";
 
-import "ansys/api/hello/v0/hello.proto";
+import "testpkg/api/hello/v0/hello.proto";
 
 service Greeter {
     rpc SayHello(Hello) returns (Hello);
diff --git a/test/greeter/ansys/api/greeter/v0/greeter2.proto b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter2.proto
similarity index 65%
rename from test/greeter/ansys/api/greeter/v0/greeter2.proto
rename to test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter2.proto
index 287ba7e..974a1a6 100644
--- a/test/greeter/ansys/api/greeter/v0/greeter2.proto
+++ b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/greeter2.proto
@@ -1,6 +1,6 @@
 syntax = "proto3";
 
-import "ansys/api/hello/v0/hello2.proto";
+import "testpkg/api/hello/v0/hello2.proto";
 
 service Greeter2 {
     rpc SayHello(Hello2) returns (Hello2);
diff --git a/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/py.typed b/test/test_data/testpkg-greeter-protos/src/testpkg/api/greeter/v0/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_data/testpkg-hello-protos/pyproject.toml b/test/test_data/testpkg-hello-protos/pyproject.toml
new file mode 100644
index 0000000..d837659
--- /dev/null
+++ b/test/test_data/testpkg-hello-protos/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=42.0", "wheel", "ansys-tools-protoc-helper"]
+build-backend = "setuptools.build_meta:__legacy__"
diff --git a/test/test_data/testpkg-hello-protos/setup.py b/test/test_data/testpkg-hello-protos/setup.py
new file mode 100644
index 0000000..253625f
--- /dev/null
+++ b/test/test_data/testpkg-hello-protos/setup.py
@@ -0,0 +1,22 @@
+import setuptools
+from ansys.tools.protoc_helper import CMDCLASS_OVERRIDE
+
+
+if __name__ == "__main__":
+    setuptools.setup(
+        name="testpkg-hello-protos",
+        author="ANSYS, Inc.",
+        python_requires=">=3.7",
+        install_requires=["grpcio~=1.0", "protobuf~=3.0"],
+        packages=["testpkg.api.hello.v0"],
+        package_dir={"": "src"},
+        package_data={
+            "": ["*.proto", "*.pyi", "py.typed"],
+        },
+        entry_points={
+            "ansys.tools.protoc_helper.proto_provider": {
+                "testpkg.api.hello.v0=testpkg.api.hello.v0"
+            },
+        },
+        cmdclass=CMDCLASS_OVERRIDE,
+    )
diff --git a/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/__init__.py b/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/hello/ansys/api/hello/v0/hello.proto b/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/hello.proto
similarity index 100%
rename from test/hello/ansys/api/hello/v0/hello.proto
rename to test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/hello.proto
diff --git a/test/hello/ansys/api/hello/v0/hello2.proto b/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/hello2.proto
similarity index 100%
rename from test/hello/ansys/api/hello/v0/hello2.proto
rename to test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/hello2.proto
diff --git a/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/py.typed b/test/test_data/testpkg-hello-protos/src/testpkg/api/hello/v0/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_integration.py b/test/test_integration.py
new file mode 100644
index 0000000..c341329
--- /dev/null
+++ b/test/test_integration.py
@@ -0,0 +1,95 @@
+"""Integration tests for end-to-end tests of the protoc-helper."""
+import os
+import pathlib
+import subprocess
+import tempfile
+import types
+import venv
+
+import pytest
+from conftest import PKG_ROOT_DIR
+from conftest import TEST_DATA_DIR
+
+
+@pytest.fixture
+def test_venv():
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        venv_dir = pathlib.Path(tmp_dir) / "venv"
+        venv.main([str(venv_dir)])
+        if os.name == "nt":  # Windows
+            script_dir = venv_dir / "Scripts"
+            python_bin = script_dir / "python.exe"
+            pip_bin = script_dir / "pip.exe"
+        else:  # Linux
+            script_dir = venv_dir / "bin"
+            python_bin = script_dir / "python"
+            pip_bin = script_dir / "pip"
+        test_venv = types.SimpleNamespace(python=python_bin, pip=pip_bin)
+        subprocess.check_call([str(test_venv.python), "--version"])  # for info only
+        subprocess.check_call([str(test_venv.pip), "install", "wheel"])
+        yield test_venv
+
+
+@pytest.fixture
+def local_pkg_index(test_venv):
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        extra_deps = ["wheel"]
+        subprocess.check_call(
+            [
+                str(test_venv.pip),
+                "download",
+                "-d",
+                tmp_dir,
+            ]
+            + extra_deps
+        )
+        subprocess.check_call(
+            [
+                str(test_venv.pip),
+                "wheel",
+                "-w",
+                f"{tmp_dir}",
+                f"{PKG_ROOT_DIR}",
+            ]
+        )
+        yield tmp_dir
+
+
+@pytest.fixture
+def create_test_pkg_wheel(local_pkg_index, test_venv):
+    def inner(pkgname):
+        subprocess.check_call(
+            [
+                str(test_venv.pip),
+                "wheel",
+                "--no-index",
+                f"--find-links={local_pkg_index}",
+                "-w",
+                f"{local_pkg_index}",
+                f"{TEST_DATA_DIR / pkgname}",
+            ]
+        )
+
+    return inner
+
+
+@pytest.fixture
+def install_test_pkg(local_pkg_index, test_venv):
+    def inner(pkgname):
+        subprocess.check_call(
+            [
+                str(test_venv.pip),
+                "install",
+                "--no-index",
+                f"--find-links={local_pkg_index}",
+                f"{TEST_DATA_DIR / pkgname}",
+            ]
+        )
+
+    return inner
+
+
+def test_dependency_compile(create_test_pkg_wheel, install_test_pkg, test_venv):
+    create_test_pkg_wheel("testpkg-hello-protos")
+    install_test_pkg("testpkg-greeter-protos")
+    subprocess.check_call([str(test_venv.python), "-c", "import testpkg.api.greeter.v0"])