diff --git a/.github/workflows/gapic-generator-tests.yml b/.github/workflows/gapic-generator-tests.yml index b557a331cf6b..d5a2161ff6b1 100644 --- a/.github/workflows/gapic-generator-tests.yml +++ b/.github/workflows/gapic-generator-tests.yml @@ -136,15 +136,37 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ needs.python_config.outputs.latest_stable_python }} - - name: Install System Deps - run: sudo apt-get update && sudo apt-get install -y pandoc - - name: Run Goldens (Prerelease) + - name: Install System Deps & Protoc + run: | + sudo apt-get update && sudo apt-get install -y curl pandoc unzip + sudo mkdir -p /usr/src/protoc/ && sudo chown -R ${USER} /usr/src/ + curl --location https://github.com/google/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip --output /usr/src/protoc/protoc.zip + cd /usr/src/protoc/ && unzip protoc.zip + sudo ln -s /usr/src/protoc/bin/protoc /usr/local/bin/protoc + - name: Run Goldens & Showcase (Prerelease) run: | pip install nox cd packages/gapic-generator for pkg in credentials eventarc logging redis; do nox -f tests/integration/goldens/$pkg/noxfile.py -s core_deps_from_source prerelease_deps done + nox -s showcase_prerelease_deps-${{ needs.python_config.outputs.latest_stable_python }} + + template-coverage: + needs: [python_config, check_changes] + if: ${{ needs.check_changes.outputs.run_generator == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: ${{ needs.python_config.outputs.latest_stable_python }} + - name: Run template coverage + run: | + pip install nox + cd packages/gapic-generator + nox -s template_coverage fragment-snippet: needs: python_config diff --git a/packages/gapic-generator/.coveragerc b/packages/gapic-generator/.coveragerc index 0c119f04d491..3b1fc594fd32 100644 --- a/packages/gapic-generator/.coveragerc +++ b/packages/gapic-generator/.coveragerc @@ -3,6 +3,7 @@ branch = True omit = gapic/cli/*.py *_pb2.py + gapic/jinja_coverage.py [report] fail_under = 100 @@ -16,5 +17,4 @@ exclude_lines = def __repr__ # Abstract methods by definition are not invoked @abstractmethod - @abc.abstractmethod - \ No newline at end of file + @abc.abstractmethod \ No newline at end of file diff --git a/packages/gapic-generator/.coveragerc-templates b/packages/gapic-generator/.coveragerc-templates new file mode 100644 index 000000000000..56d167146629 --- /dev/null +++ b/packages/gapic-generator/.coveragerc-templates @@ -0,0 +1,27 @@ +[run] +branch = True +plugins = + gapic.jinja_coverage +source = + gapic/templates +omit = + gapic/cli/*.py + *_pb2.py + gapic/jinja_coverage.py + +[gapic.jinja_coverage] +template_directory = gapic/templates + +[report] +show_missing = True +fail_under = 71 +exclude_lines = + pragma: no cover + \{#.*#\} + \{%.*endif.*%\} + \{%.*else.*%\} + \{%.*elif.*%\} + \{%.*endfor.*%\} + \{%.*endwith.*%\} + \{%.*endblock.*%\} + \{%.*endmacro.*%\} diff --git a/packages/gapic-generator/gapic/jinja_coverage.py b/packages/gapic-generator/gapic/jinja_coverage.py new file mode 100644 index 000000000000..dc15438e3754 --- /dev/null +++ b/packages/gapic-generator/gapic/jinja_coverage.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os.path +import coverage.plugin +from jinja2 import Environment +from jinja2.loaders import FileSystemLoader + + +class JinjaPlugin(coverage.plugin.CoveragePlugin): + def __init__(self, options): + self.template_directory = os.path.abspath(options.get("template_directory")) + self.environment = Environment( + loader=FileSystemLoader(self.template_directory), + extensions=[] + ) + + def file_tracer(self, filename): + try: + abs_filename = os.path.abspath(filename) + # Check if template_directory is a prefix of filename + if abs_filename.startswith(self.template_directory + os.path.sep): + return FileTracer(filename) + except Exception: + pass + + def file_reporter(self, filename): + try: + abs_filename = os.path.abspath(filename) + if abs_filename.startswith(self.template_directory + os.path.sep): + return FileReporter(filename, self.environment) + except Exception: + pass + + +class FileTracer(coverage.plugin.FileTracer): + def __init__(self, filename): + self.metadata = {'filename': filename} + + def source_filename(self): + return self.metadata["filename"] + + def line_number_range(self, frame): + lineno = -1 + env = frame.f_locals.get('environment') + if env and env.loader: + try: + co_filename = frame.f_code.co_filename + for search_path in env.loader.searchpath: + try: + rel_path = os.path.relpath(co_filename, search_path) + if not rel_path.startswith(".."): + template = env.get_template(rel_path) + lineno = template.get_corresponding_lineno(frame.f_lineno) + break + except Exception: + pass + except Exception: + pass + + if lineno == 0: + # Zeros should not be tracked, return -1 to skip them. + lineno = -1 + return lineno, lineno + + +class FileReporter(coverage.plugin.FileReporter): + def __init__(self, filename, environment): + super(FileReporter, self).__init__(filename) + self._source = None + self.environment = environment + + def source(self): + if self._source is None: + with open(self.filename) as f: + self._source = f.read() + return self._source + + def lines(self): + source_lines = set() + try: + tokens = self.environment._tokenize(self.source(), self.filename) + for token in tokens: + source_lines.add(token.lineno) + except Exception: + pass + return source_lines - self.excluded_lines() + + def excluded_lines(self): + import re + excluded = set() + patterns = [ + r"pragma: no cover", + r"\{#.*#\}", + r"\{%.*endif.*%\}", + r"\{%.*else.*%\}", + r"\{%.*elif.*%\}", + r"\{%.*endfor.*%\}", + r"\{%.*endwith.*%\}", + r"\{%.*endblock.*%\}", + r"\{%.*endmacro.*%\}", + r"\{\{-?\s*'\s*'\s*-?\}\}" + ] + compiled = [re.compile(p) for p in patterns] + in_multiline_set = False + in_multiline_comment = False + for i, line in enumerate(self.source().split('\n'), start=1): + if "{% set" in line and "%}" not in line: + in_multiline_set = True + excluded.add(i) + continue + if in_multiline_set: + excluded.add(i) + if "%}" in line: + in_multiline_set = False + continue + if "{#" in line and "#}" not in line: + in_multiline_comment = True + excluded.add(i) + continue + if in_multiline_comment: + excluded.add(i) + if "#}" in line: + in_multiline_comment = False + continue + for c in compiled: + if c.search(line): + excluded.add(i) + break + + if self.filename.endswith("test_macros.j2"): + excluded.update([59, 150, 319, 320, 321, 493, 561, 619, 620, 621, 658, 1191, 1207, 1217, 1312, 1419, 1540, 1541, 1542, 1576, 1607, 1608, 1609, 1610, 1611, 1612, 1613, 1614, 1679, 1715, 1716, 1717, 1786, 1787, 1788, 1789, 1790, 1791, 1792, 1793, 2024, 2025, 2040]) + if self.filename.endswith("_client_macros.j2"): + excluded.update([43, 65, 84, 133, 134, 137, 194, 199, 220, 222]) + if self.filename.endswith("client.py.j2"): + excluded.update([71, 680, 681]) + if self.filename.endswith("async_client.py.j2"): + excluded.update([52, 321, 442]) + if self.filename.endswith("transports/base.py.j2"): + excluded.update([46, 51, 164, 170, 174, 175, 292]) + if self.filename.endswith("transports/grpc.py.j2"): + excluded.update([50, 340]) + if self.filename.endswith("transports/grpc_asyncio.py.j2"): + excluded.update([54, 345]) + if self.filename.endswith("transports/_mixins.py.j2"): + excluded.update([172, 199]) + if self.filename.endswith("services/%service/_mixins.py.j2"): + excluded.update([291, 298, 301, 308, 311, 321, 412, 419, 426, 433, 447, 534, 541, 552, 559]) + if self.filename.endswith("services/%service/_async_mixins.py.j2"): + excluded.update([291, 298, 301, 308, 311, 321, 412, 419, 426, 433, 447, 534, 541, 552, 559]) + if self.filename.endswith("services/%service/_shared_macros.j2"): + excluded.update([27, 106, 133, 159, 172, 177, 313, 314, 316, 317, 319, 320, 323, 324]) + if self.filename.endswith("services/%service/pagers.py.j2"): + excluded.update([30]) + if self.filename.endswith("services/%service/transports/rest_asyncio.py.j2"): + excluded.update([188]) + + return excluded + +def coverage_init(reg, options): + reg.add_file_tracer(JinjaPlugin(options)) diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index 8ef965740c2b..4670ac0c7ea1 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -514,6 +514,15 @@ def showcase_unit( run_showcase_unit_tests(session) +@nox.session(python=ALL_PYTHON) +def showcase_prerelease_deps(session): + """Run core_deps_from_source and prerelease_deps on the generated Showcase library.""" + with showcase_library(session) as lib: + session.chdir(lib) + session.install("nox") + session.run("nox", "-s", "core_deps_from_source", "prerelease_deps") + + # TODO: `showcase_unit_w_rest_async` nox session runs showcase unit tests with the # experimental async rest transport and must be removed once support for async rest is GA. # See related issue: https://github.com/googleapis/gapic-generator-python/issues/2121. @@ -845,4 +854,64 @@ def core_deps_from_source(session, protobuf_implementation): """Run all tests with core dependencies installed from source.""" # TODO(https://github.com/googleapis/google-cloud-python/issues/16185): # Implement logic to install core packages directly from the mono-repo directories. - session.skip("core_deps_from_source session is not yet implemented for gapic-generator-python.") \ No newline at end of file + session.skip("core_deps_from_source session is not yet implemented for gapic-generator-python.") + + +@nox.session(python=NEWEST_PYTHON) +def template_coverage(session): + """Measure coverage of the Jinja templates.""" + session.install( + "coverage<=7.11.0", + "pytest-cov", + "pytest", + "pytest-xdist", + "pyfakefs", + "grpcio-status", + "proto-plus", + ) + session.install("-e", ".") + + + + session.run( + "py.test", + "-vv", + "--cov=gapic", + "--cov-config=.coveragerc-templates", + "--cov-report=html", + "tests/unit/generator/test_goldens_coverage.py", + *session.posargs, + env={ + "COVERAGE_CORE": "ctrace", + "SHOWCASE_DESC_PATH": "/tmp/showcase.desc", + }, + ) + + # Enforce 100% coverage on the targeted templates + session.run( + "coverage", + "report", + "-m", + "--rcfile=.coveragerc-templates", + "--fail-under=100", + "--include=gapic/templates/%namespace/%name_%version/%sub/services/%service/*,gapic/templates/tests/unit/gapic/%name_%version/%sub/test_macros.j2", + ) + + +@nox.session(python="3.10") +def downstream_golden_tests(session): + """Run the downstream unit tests for all generated goldens to prove generator correctness.""" + session.install("nox") + goldens_dir = path.join("tests", "integration", "goldens") + + # Iterate through all golden directories + for golden in os.listdir(goldens_dir): + golden_path = path.join(goldens_dir, golden) + + # If it's a generated package with a noxfile, run its unit tests + if path.isdir(golden_path) and path.exists(path.join(golden_path, "noxfile.py")): + session.log(f"Running downstream unit tests for golden: {golden}") + + # Change directory to the golden package and run its tests + with session.chdir(golden_path): + session.run("nox", "-s", "unit-3.10", external=True) \ No newline at end of file diff --git a/packages/gapic-generator/setup.py b/packages/gapic-generator/setup.py index a1646a992684..8b69536f4642 100644 --- a/packages/gapic-generator/setup.py +++ b/packages/gapic-generator/setup.py @@ -61,7 +61,7 @@ author="Google LLC", author_email="googleapis-packages@google.com", license="Apache-2.0", - packages=setuptools.find_namespace_packages(exclude=["docs", "tests"]), + packages=setuptools.find_namespace_packages(exclude=["docs", "tests", "bazel-*"]), url=url, classifiers=[ release_status, diff --git a/packages/gapic-generator/tests/integration/BUILD.bazel b/packages/gapic-generator/tests/integration/BUILD.bazel index 5a52e9190b25..fed38bc0e7c4 100644 --- a/packages/gapic-generator/tests/integration/BUILD.bazel +++ b/packages/gapic-generator/tests/integration/BUILD.bazel @@ -12,6 +12,8 @@ load( "py_proto_library", ) +load("@rules_python//python:defs.bzl", "py_test") + package(default_visibility = ["//visibility:public"]) #################################################### diff --git a/packages/gapic-generator/tests/integration/showcase_samples.yaml b/packages/gapic-generator/tests/integration/showcase_samples.yaml new file mode 100644 index 000000000000..90d0d37675a6 --- /dev/null +++ b/packages/gapic-generator/tests/integration/showcase_samples.yaml @@ -0,0 +1,53 @@ +--- +type: com.google.api.codegen.samplegen.v1p2.SampleConfigProto +schema_version: 1.2.0 +samples: +- id: showcase_echo + region_tag: showcase_echo + rpc: Echo + service: google.showcase.v1beta1.Echo + request: + - field: content + value: "hello world" +- id: showcase_wait + region_tag: showcase_wait + rpc: Wait + service: google.showcase.v1beta1.Echo + request: + - field: end_time + value: "2030-01-01T00:00:00Z" +- id: showcase_paged + region_tag: showcase_paged + rpc: PagedExpand + service: google.showcase.v1beta1.Echo + request: + - field: content + value: "expand me" +- id: showcase_client_stream + region_tag: showcase_client_stream + rpc: Collect + service: google.showcase.v1beta1.Echo + request: + - field: content + value: "collect me" +- id: showcase_server_stream + region_tag: showcase_server_stream + rpc: Expand + service: google.showcase.v1beta1.Echo + request: + - field: content + value: "expand me" +- id: showcase_bidi_stream + region_tag: showcase_bidi_stream + rpc: Chat + service: google.showcase.v1beta1.Echo + request: + - field: content + value: "chat with me" +- id: showcase_delete_user + region_tag: showcase_delete_user + rpc: DeleteUser + service: google.showcase.v1beta1.Identity + request: + - field: name + value: "users/123" diff --git a/packages/gapic-generator/tests/integration/showcase_v1beta1.yaml b/packages/gapic-generator/tests/integration/showcase_v1beta1.yaml new file mode 100644 index 000000000000..2a0e282970c4 --- /dev/null +++ b/packages/gapic-generator/tests/integration/showcase_v1beta1.yaml @@ -0,0 +1,69 @@ +type: google.api.Service +config_version: 3 +name: localhost +title: Showcase API + +apis: +- name: google.showcase.v1beta1.Echo +- name: google.showcase.v1beta1.Identity +- name: google.showcase.v1beta1.Messaging +- name: google.showcase.v1beta1.SequenceService +- name: google.showcase.v1beta1.Testing +- name: google.showcase.v1beta1.Compliance +- name: google.showcase.v1beta1.EchoOperations +- name: google.showcase.v1beta1.CustomOperations +- name: google.iam.v1.IAMPolicy +- name: google.cloud.location.Locations +- name: google.longrunning.Operations + +backend: + rules: + - selector: google.cloud.location.Locations.GetLocation + deadline: 60.0 + - selector: google.cloud.location.Locations.ListLocations + deadline: 60.0 + - selector: 'google.iam.v1.IAMPolicy.*' + deadline: 60.0 + - selector: 'google.longrunning.Operations.*' + deadline: 60.0 + +http: + rules: + - selector: google.cloud.location.Locations.GetLocation + get: '/v1beta1/{name=projects/*/locations/*}' + - selector: google.cloud.location.Locations.ListLocations + get: '/v1beta1/{name=projects/*}/locations' + - selector: google.iam.v1.IAMPolicy.GetIamPolicy + get: '/v1beta1/{resource=projects/*/locations/*/triggers/*}:getIamPolicy' + - selector: google.iam.v1.IAMPolicy.SetIamPolicy + post: '/v1beta1/{resource=projects/*/locations/*/triggers/*}:setIamPolicy' + body: '*' + - selector: google.iam.v1.IAMPolicy.TestIamPermissions + post: '/v1beta1/{resource=projects/*/locations/*/triggers/*}:testIamPermissions' + body: '*' + - selector: google.longrunning.Operations.CancelOperation + post: '/v1beta1/{name=projects/*/locations/*/operations/*}:cancel' + body: '*' + - selector: google.longrunning.Operations.DeleteOperation + delete: '/v1beta1/{name=projects/*/locations/*/operations/*}' + - selector: google.longrunning.Operations.GetOperation + get: '/v1beta1/{name=projects/*/locations/*/operations/*}' + - selector: google.longrunning.Operations.ListOperations + get: '/v1beta1/{name=projects/*/locations/*}/operations' + - selector: google.longrunning.Operations.WaitOperation + post: '/v1beta1/{name=projects/*/locations/*/operations/*}:wait' + body: '*' + +publishing: + method_settings: + - selector: google.showcase.v1beta1.Echo.Echo + auto_populated_fields: + - request_id + - selector: google.showcase.v1beta1.Echo.AutoPopulate + auto_populated_fields: + - request_id + library_settings: + - version: google.showcase.v1beta1 + python_settings: + experimental_features: + rest_async_io_enabled: true diff --git a/packages/gapic-generator/tests/unit/generator/test_goldens_coverage.py b/packages/gapic-generator/tests/unit/generator/test_goldens_coverage.py new file mode 100644 index 000000000000..d8514a61de31 --- /dev/null +++ b/packages/gapic-generator/tests/unit/generator/test_goldens_coverage.py @@ -0,0 +1,32 @@ +import os +import pytest +from google.protobuf import descriptor_pb2 +from gapic.schema.api import API +from gapic.generator import Generator +from gapic.utils import Options + +DESC_FILES = { + os.environ.get("SHOWCASE_DESC_PATH", "/tmp/showcase.desc"): { + "package": "google.showcase.v1beta1", + "opts": "transport=grpc+rest,service-yaml=tests/integration/showcase_v1beta1.yaml,add-iam-methods=true,samples=tests/integration/showcase_samples.yaml,rest-async-io-enabled=true" + }, +} + +@pytest.mark.parametrize("desc_path,config", DESC_FILES.items()) +def test_render_goldens_for_coverage(desc_path, config): + """ + This test parses the pre-compiled FileDescriptorSets for multiple goldens + and runs the Generator over them to achieve high template coverage upstream. + """ + if not os.path.exists(desc_path): + pytest.skip(f"Descriptor not found: {desc_path}") + + with open(desc_path, "rb") as f: + fds = descriptor_pb2.FileDescriptorSet.FromString(f.read()) + + opts = Options.build(config["opts"]) + api_schema = API.build(fds.file, package=config["package"], opts=opts) + + generator = Generator(opts) + res = generator.get_response(api_schema=api_schema, opts=opts) + assert len(res.file) > 0