Skip to content

Commit 9e46031

Browse files
authored
feat: generate snippet metadata (googleapis#1129)
Follow up to googleapis#1121. **Main changes:** * When snippets are generated, snippet metadata is also generated. * If a method has a snippet, that snippet is included in the method docstring. **Other changes:** * Removed the method docstring in the code snippet file (line right below the method definition) since the same text is already in the comment block at the top of the file. * Removed the concept of a "standalone" sample. All generated samples are expected to be standalone. When someone wants a smaller portion of the sample (e.g., request initialization only) they should fetch it from the file by looking up the line numbers in the snippet metadata file. Other Notes: * ~It doesn't look like it's possible to do type annotations with `_pb2` types, so those are annotated as `Any`.~ It is possible to do mypy checking with https://github.com/dropbox/mypy-protobuf, but I think it will be easier make that change in a separate PR. * There are a lot of golden file updates, [this range of commits](https://github.com/googleapis/gapic-generator-python/pull/1129/files/872c156f5100f1de20631dd59d083206432db374) has _most_ of the generator and test changes.
1 parent 9ad98ca commit 9e46031

File tree

170 files changed

+8943
-566
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

170 files changed

+8943
-566
lines changed

gapic/generator/generator.py

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
import itertools
1818
import re
1919
import os
20+
import pathlib
2021
import typing
21-
from typing import Any, DefaultDict, Dict, Mapping
22+
from typing import Any, DefaultDict, Dict, Mapping, Tuple
2223
from hashlib import sha256
2324
from collections import OrderedDict, defaultdict
2425
from gapic.samplegen_utils.utils import coerce_response_name, is_valid_sample_cfg, render_format_string
2526
from gapic.samplegen_utils.types import DuplicateSample
27+
from gapic.samplegen_utils import snippet_index, snippet_metadata_pb2
2628
from gapic.samplegen import manifest, samplegen
2729
from gapic.generator import formatter
2830
from gapic.schema import api
@@ -93,6 +95,17 @@ def get_response(
9395
self._env.loader.list_templates(), # type: ignore
9496
)
9597

98+
# We generate code snippets *before* the library code so snippets
99+
# can be inserted into method docstrings.
100+
snippet_idx = snippet_index.SnippetIndex(api_schema)
101+
if sample_templates:
102+
sample_output, snippet_idx = self._generate_samples_and_manifest(
103+
api_schema, snippet_idx, self._env.get_template(
104+
sample_templates[0]),
105+
opts=opts,
106+
)
107+
output_files.update(sample_output)
108+
96109
# Iterate over each template and add the appropriate output files
97110
# based on that template.
98111
# Sample templates work differently: there's (usually) only one,
@@ -107,15 +120,8 @@ def get_response(
107120
# Append to the output files dictionary.
108121
output_files.update(
109122
self._render_template(
110-
template_name, api_schema=api_schema, opts=opts)
111-
)
112-
113-
if sample_templates:
114-
sample_output = self._generate_samples_and_manifest(
115-
api_schema, self._env.get_template(sample_templates[0]),
116-
opts=opts,
123+
template_name, api_schema=api_schema, opts=opts, snippet_index=snippet_idx)
117124
)
118-
output_files.update(sample_output)
119125

120126
# Return the CodeGeneratorResponse output.
121127
res = CodeGeneratorResponse(
@@ -124,7 +130,7 @@ def get_response(
124130
return res
125131

126132
def _generate_samples_and_manifest(
127-
self, api_schema: api.API, sample_template: jinja2.Template, *, opts: Options) -> Dict:
133+
self, api_schema: api.API, index: snippet_index.SnippetIndex, sample_template: jinja2.Template, *, opts: Options) -> Tuple[Dict, snippet_index.SnippetIndex]:
128134
"""Generate samples and samplegen manifest for the API.
129135
130136
Arguments:
@@ -133,7 +139,7 @@ def _generate_samples_and_manifest(
133139
opts (Options): Additional generator options.
134140
135141
Returns:
136-
Dict[str, CodeGeneratorResponse.File]: A dict mapping filepath to rendered file.
142+
Tuple[Dict[str, CodeGeneratorResponse.File], snippet_index.SnippetIndex] : A dict mapping filepath to rendered file.
137143
"""
138144
# The two-layer data structure lets us do two things:
139145
# * detect duplicate samples, which is an error
@@ -181,7 +187,7 @@ def _generate_samples_and_manifest(
181187
if not id_is_unique:
182188
spec["id"] += f"_{spec_hash}"
183189

184-
sample = samplegen.generate_sample(
190+
sample, snippet_metadata = samplegen.generate_sample(
185191
spec, api_schema, sample_template,)
186192

187193
fpath = utils.to_snake_case(spec["id"]) + ".py"
@@ -190,36 +196,30 @@ def _generate_samples_and_manifest(
190196
sample,
191197
)
192198

199+
snippet_metadata.file = fpath
200+
201+
index.add_snippet(
202+
snippet_index.Snippet(sample, snippet_metadata))
203+
193204
output_files = {
194205
fname: CodeGeneratorResponse.File(
195206
content=formatter.fix_whitespace(sample), name=fname
196207
)
197208
for fname, (_, sample) in fpath_to_spec_and_rendered.items()
198209
}
199210

200-
# TODO(busunkim): Re-enable manifest generation once metadata
201-
# format has been formalized.
202-
# https://docs.google.com/document/d/1ghBam8vMj3xdoe4xfXhzVcOAIwrkbTpkMLgKc9RPD9k/edit#heading=h.sakzausv6hue
203-
#
204-
# if output_files:
205-
206-
# manifest_fname, manifest_doc = manifest.generate(
207-
# (
208-
# (fname, spec)
209-
# for fname, (spec, _) in fpath_to_spec_and_rendered.items()
210-
# ),
211-
# api_schema,
212-
# )
213-
214-
# manifest_fname = os.path.join(out_dir, manifest_fname)
215-
# output_files[manifest_fname] = CodeGeneratorResponse.File(
216-
# content=manifest_doc.render(), name=manifest_fname
217-
# )
211+
if index.metadata_index.snippets:
212+
# NOTE(busunkim): Not all fields are yet populated in the snippet metadata.
213+
# Expected filename: snippet_metadata_{apishortname}_{apiversion}.json
214+
snippet_metadata_path = str(pathlib.Path(
215+
out_dir) / f"snippet_metadata_{api_schema.naming.name}_{api_schema.naming.version}.json").lower()
216+
output_files[snippet_metadata_path] = CodeGeneratorResponse.File(
217+
content=formatter.fix_whitespace(index.get_metadata_json()), name=snippet_metadata_path)
218218

219-
return output_files
219+
return output_files, index
220220

221221
def _render_template(
222-
self, template_name: str, *, api_schema: api.API, opts: Options,
222+
self, template_name: str, *, api_schema: api.API, opts: Options, snippet_index: snippet_index.SnippetIndex,
223223
) -> Dict[str, CodeGeneratorResponse.File]:
224224
"""Render the requested templates.
225225
@@ -258,7 +258,7 @@ def _render_template(
258258
for subpackage in api_schema.subpackages.values():
259259
answer.update(
260260
self._render_template(
261-
template_name, api_schema=subpackage, opts=opts
261+
template_name, api_schema=subpackage, opts=opts, snippet_index=snippet_index
262262
)
263263
)
264264
skip_subpackages = True
@@ -275,7 +275,7 @@ def _render_template(
275275

276276
answer.update(
277277
self._get_file(
278-
template_name, api_schema=api_schema, proto=proto, opts=opts
278+
template_name, api_schema=api_schema, proto=proto, opts=opts, snippet_index=snippet_index
279279
)
280280
)
281281

@@ -304,14 +304,15 @@ def _render_template(
304304
api_schema=api_schema,
305305
service=service,
306306
opts=opts,
307+
snippet_index=snippet_index,
307308
)
308309
)
309310
return answer
310311

311312
# This file is not iterating over anything else; return back
312313
# the one applicable file.
313314
answer.update(self._get_file(
314-
template_name, api_schema=api_schema, opts=opts))
315+
template_name, api_schema=api_schema, opts=opts, snippet_index=snippet_index))
315316
return answer
316317

317318
def _is_desired_transport(self, template_name: str, opts: Options) -> bool:
@@ -324,8 +325,8 @@ def _get_file(
324325
template_name: str,
325326
*,
326327
opts: Options,
327-
api_schema=api.API,
328-
**context: Mapping,
328+
api_schema: api.API,
329+
**context,
329330
):
330331
"""Render a template to a protobuf plugin File object."""
331332
# Determine the target filename.

gapic/samplegen/samplegen.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@
2424

2525
from gapic import utils
2626

27-
from gapic.samplegen_utils import types
27+
from gapic.samplegen_utils import types, snippet_metadata_pb2 # type: ignore
2828
from gapic.samplegen_utils.utils import is_valid_sample_cfg
2929
from gapic.schema import api
3030
from gapic.schema import wrappers
3131

3232
from collections import defaultdict, namedtuple, ChainMap as chainmap
33-
from typing import Any, ChainMap, Dict, FrozenSet, Generator, List, Mapping, Optional, Sequence
33+
from typing import Any, ChainMap, Dict, FrozenSet, Generator, List, Mapping, Optional, Sequence, Tuple
3434

3535
# There is no library stub file for this module, so ignore it.
3636
from google.api import resource_pb2 # type: ignore
@@ -915,8 +915,6 @@ def _validate_loop(self, loop):
915915
def parse_handwritten_specs(sample_configs: Sequence[str]) -> Generator[Dict[str, Any], None, None]:
916916
"""Parse a handwritten sample spec"""
917917

918-
STANDALONE_TYPE = "standalone"
919-
920918
for config_fpath in sample_configs:
921919
with open(config_fpath) as f:
922920
configs = yaml.safe_load_all(f.read())
@@ -925,13 +923,9 @@ def parse_handwritten_specs(sample_configs: Sequence[str]) -> Generator[Dict[str
925923
valid = is_valid_sample_cfg(cfg)
926924
if not valid:
927925
raise types.InvalidConfig(
928-
"Sample config is invalid", valid)
926+
"Sample config in '{}' is invalid\n\n{}".format(config_fpath, cfg), valid)
929927
for spec in cfg.get("samples", []):
930-
# If unspecified, assume a sample config describes a standalone.
931-
# If sample_types are specified, standalone samples must be
932-
# explicitly enabled.
933-
if STANDALONE_TYPE in spec.get("sample_type", [STANDALONE_TYPE]):
934-
yield spec
928+
yield spec
935929

936930

937931
def _generate_resource_path_request_object(field_name: str, message: wrappers.MessageType) -> List[Dict[str, str]]:
@@ -1050,7 +1044,6 @@ def generate_sample_specs(api_schema: api.API, *, opts) -> Generator[Dict[str, A
10501044
# [{START|END} ${apishortname}_generated_${api}_${apiVersion}_${serviceName}_${rpcName}_{sync|async}_${overloadDisambiguation}]
10511045
region_tag = f"{api_short_name}_generated_{api_schema.naming.versioned_module_name}_{service_name}_{rpc_name}_{transport_type}"
10521046
spec = {
1053-
"sample_type": "standalone",
10541047
"rpc": rpc_name,
10551048
"transport": transport,
10561049
# `request` and `response` is populated in `preprocess_sample`
@@ -1062,7 +1055,7 @@ def generate_sample_specs(api_schema: api.API, *, opts) -> Generator[Dict[str, A
10621055
yield spec
10631056

10641057

1065-
def generate_sample(sample, api_schema, sample_template: jinja2.Template) -> str:
1058+
def generate_sample(sample, api_schema, sample_template: jinja2.Template) -> Tuple[str, Any]:
10661059
"""Generate a standalone, runnable sample.
10671060
10681061
Writing the rendered output is left for the caller.
@@ -1073,7 +1066,7 @@ def generate_sample(sample, api_schema, sample_template: jinja2.Template) -> str
10731066
sample_template (jinja2.Template): The template representing a generic sample.
10741067
10751068
Returns:
1076-
str: The rendered sample.
1069+
Tuple(str, snippet_metadata_pb2.Snippet): The rendered sample.
10771070
"""
10781071
service_name = sample["service"]
10791072
service = api_schema.services.get(service_name)
@@ -1100,11 +1093,22 @@ def generate_sample(sample, api_schema, sample_template: jinja2.Template) -> str
11001093

11011094
v.validate_response(sample["response"])
11021095

1096+
# Snippet Metadata can't be fully filled out in any one function
1097+
# In this function we add information from
1098+
# the API schema and sample dictionary.
1099+
snippet_metadata = snippet_metadata_pb2.Snippet() # type: ignore
1100+
snippet_metadata.region_tag = sample["region_tag"]
1101+
setattr(snippet_metadata.client_method, "async",
1102+
sample["transport"] == api.TRANSPORT_GRPC_ASYNC)
1103+
snippet_metadata.client_method.method.short_name = sample["rpc"]
1104+
snippet_metadata.client_method.method.service.short_name = sample["service"].split(
1105+
".")[-1]
1106+
11031107
return sample_template.render(
11041108
sample=sample,
11051109
imports=[],
11061110
calling_form=calling_form,
11071111
calling_form_enum=types.CallingForm,
11081112
trim_blocks=True,
11091113
lstrip_blocks=True,
1110-
)
1114+
), snippet_metadata

gapic/samplegen_utils/snippet_index.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from typing import Optional, Dict
1615
import re
16+
from typing import Optional, Dict
1717

1818
from google.protobuf import json_format
1919

@@ -88,6 +88,7 @@ def full_snippet(self) -> str:
8888
"""The portion between the START and END region tags."""
8989
start_idx = self._full_snippet.start - 1
9090
end_idx = self._full_snippet.end
91+
self.sample_lines[start_idx] = self.sample_lines[start_idx].strip()
9192
return "".join(self.sample_lines[start_idx:end_idx])
9293

9394

@@ -124,7 +125,7 @@ def add_snippet(self, snippet: Snippet) -> None:
124125
RpcMethodNotFound: If the method indicated by the snippet metadata is not found.
125126
"""
126127
service_name = snippet.metadata.client_method.method.service.short_name
127-
rpc_name = snippet.metadata.client_method.method.full_name
128+
rpc_name = snippet.metadata.client_method.method.short_name
128129

129130
service = self._index.get(service_name)
130131
if service is None:
@@ -172,4 +173,8 @@ def get_snippet(self, service_name: str, rpc_name: str, sync: bool = True) -> Op
172173

173174
def get_metadata_json(self) -> str:
174175
"""JSON representation of Snippet Index."""
176+
177+
# Downstream tools assume the generator will produce the exact
178+
# same output when run over the same API multiple times
179+
self.metadata_index.snippets.sort(key=lambda s: s.region_tag)
175180
return json_format.MessageToJson(self.metadata_index, sort_keys=True)

gapic/schema/wrappers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from google.api import resource_pb2
4242
from google.api_core import exceptions
4343
from google.api_core import path_template
44-
from google.cloud import extended_operations_pb2 as ex_ops_pb2
44+
from google.cloud import extended_operations_pb2 as ex_ops_pb2 # type: ignore
4545
from google.protobuf import descriptor_pb2 # type: ignore
4646
from google.protobuf.json_format import MessageToDict # type: ignore
4747

gapic/templates/%namespace/%name_%version/%sub/services/%service/async_client.py.j2

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class {{ service.async_client_name }}:
207207
{% endif %}
208208
r"""{{ method.meta.doc|rst(width=72, indent=8) }}
209209

210+
{% with snippet = snippet_index.get_snippet(service.name, method.name, sync=True) %}
211+
{% if snippet is not none %}
212+
.. code-block::
213+
214+
{{ snippet.full_snippet|indent(width=12, first=True) }}
215+
{% endif %}
216+
{% endwith %}
217+
210218
Args:
211219
{% if not method.client_streaming %}
212220
request (Union[{{ method.input.ident.sphinx }}, dict]):

gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,15 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
363363
{% endif %}
364364
r"""{{ method.meta.doc|rst(width=72, indent=8) }}
365365

366+
367+
{% with snippet = snippet_index.get_snippet(service.name, method.name, sync=True) %}
368+
{% if snippet is not none %}
369+
.. code-block::
370+
371+
{{ snippet.full_snippet|indent(width=12, first=True) }}
372+
{% endif %}
373+
{% endwith %}
374+
366375
Args:
367376
{% if not method.client_streaming %}
368377
request (Union[{{ method.input.ident.sphinx }}, dict]):

gapic/templates/examples/sample.py.j2

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@ from {{ sample.module_namespace|join(".") }} import {{ sample.module_name }}
3232

3333
{# also need calling form #}
3434
{% if sample.transport == "grpc-async" %}async {% endif %}def sample_{{ frags.render_method_name(sample.rpc)|trim }}({{ frags.print_input_params(sample.request)|trim }}):
35-
"""{{ sample.description }}"""
36-
3735
{{ frags.render_client_setup(sample.module_name, sample.client_name)|indent }}
3836
{{ frags.render_request_setup(sample.request, sample.module_name, sample.request_type, calling_form, calling_form_enum)|indent }}
3937
{% with method_call = frags.render_method_call(sample, calling_form, calling_form_enum, sample.transport) %}

0 commit comments

Comments
 (0)