# Copyright 2018 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 abc
import dataclasses
import os
import re
from typing import cast, List, Match, Tuple
from google.protobuf import descriptor_pb2
from gapic import utils
from gapic.generator import options
# See https://github.com/python/mypy/issues/5374 for details on the mypy false
# positive.
[docs]@dataclasses.dataclass(frozen=True) # type: ignore
class Naming(abc.ABC):
"""Naming data for an API.
This class contains the naming nomenclature used for this API
within templates.
An concrete child of this object is made available to every template
(as ``api.naming``).
"""
name: str = ''
namespace: Tuple[str, ...] = dataclasses.field(default_factory=tuple)
version: str = ''
product_name: str = ''
proto_package: str = ''
def __post_init__(self):
if not self.product_name:
self.__dict__['product_name'] = self.name
[docs] @staticmethod
def build(
*file_descriptors: descriptor_pb2.FileDescriptorProto,
opts: options.Options = options.Options(),
) -> 'Naming':
"""Return a full Naming instance based on these file descriptors.
This is pieced together from the proto package names as well as the
``google.api.metadata`` file annotation. This information may be
present in one or many files; this method is tolerant as long as
the data does not conflict.
Args:
file_descriptors (Iterable[~.FileDescriptorProto]): A list of
file descriptor protos. This list should only include the
files actually targeted for output (not their imports).
Returns:
~.Naming: A :class:`~.Naming` instance which is provided to
templates as part of the :class:`~.API`.
Raises:
ValueError: If the provided file descriptors contain contradictory
information.
"""
# Determine the set of proto packages.
proto_packages = {fd.package for fd in file_descriptors}
root_package = os.path.commonprefix(tuple(proto_packages)).rstrip('.')
# Sanity check: If there is no common ground in the package,
# we are obviously in trouble.
if not root_package:
raise ValueError(
'The protos provided do not share a common root package. '
'Ensure that all explicitly-specified protos are for a '
'single API. '
f'The packages we got are: {", ".join(proto_packages)}'
)
# Define the valid regex to split the package.
#
# It is not necessary for the regex to be as particular about package
# name validity (e.g. avoiding .. or segments starting with numbers)
# because protoc is guaranteed to give us valid package names.
pattern = r'^((?P<namespace>[a-z0-9_.]+)\.)?(?P<name>[a-z0-9_]+)'
# Only require the version portion of the regex if the version is
# present.
#
# This code may look counter-intuitive (why not use ? to make it
# optional), but the engine's greediness routine will decide that
# the version is the name, which is not what we want.
version = r'\.(?P<version>v[0-9]+(p[0-9]+)?((alpha|beta)[0-9]+)?)'
if re.search(version, root_package):
pattern += version
# Okay, do the match
match = cast(Match,
re.search(pattern=pattern, string=root_package)).groupdict()
match['namespace'] = match['namespace'] or ''
klass = OldNaming if opts.old_naming else NewNaming
package_info = klass(
name=match['name'].capitalize(),
namespace=tuple(
i.capitalize() for i in match['namespace'].split('.') if i
),
product_name=match['name'].capitalize(),
proto_package=root_package,
version=match.get('version', ''),
)
# Sanity check: Ensure that the package directives all inferred
# the same information.
if not package_info.version and len(proto_packages) > 1:
raise ValueError('All protos must have the same proto package '
'up to and including the version.')
# If a naming information was provided on the CLI, override the naming
# value.
#
# We are liberal about what formats we take on the CLI; it will
# likely make sense to many users to use dot-separated namespaces and
# snake case, so handle that and do the right thing.
if opts.name:
package_info = dataclasses.replace(package_info, name=' '.join((
i.capitalize() for i in opts.name.replace('_', ' ').split(' ')
)))
if opts.namespace:
package_info = dataclasses.replace(package_info, namespace=tuple(
# The join-and-split on "." here causes us to expand out
# dot notation that we may have been sent; e.g. a one-tuple
# with ('x.y',) will become a two-tuple: ('x', 'y')
i.capitalize() for i in '.'.join(opts.namespace).split('.')
))
# Done; return the naming information.
return package_info
def __bool__(self):
"""Return True if any of the fields are truthy, False otherwise."""
return any(
(getattr(self, i.name) for i in dataclasses.fields(self)),
)
@property
def long_name(self) -> str:
"""Return an appropriate title-cased long name."""
return ' '.join(tuple(self.namespace) + (self.name,))
@property
def module_name(self) -> str:
"""Return the appropriate Python module name."""
return utils.to_valid_module_name(self.name)
@property
def module_namespace(self) -> Tuple[str, ...]:
"""Return the appropriate Python module namespace as a tuple."""
return tuple(utils.to_valid_module_name(i) for i in self.namespace)
@property
def namespace_packages(self) -> Tuple[str, ...]:
"""Return the appropriate Python namespace packages."""
answer: List[str] = []
for cursor in (i.lower() for i in self.namespace):
answer.append(f'{answer[-1]}.{cursor}' if answer else cursor)
return tuple(answer)
@property
@abc.abstractmethod
def versioned_module_name(self) -> str:
"""Return the versiond module name (e.g. ``apiname_v1``).
If there is no version, this is the same as ``module_name``.
"""
raise NotImplementedError
@property
def warehouse_package_name(self) -> str:
"""Return the appropriate Python package name for Warehouse."""
# Piece the name and namespace together to come up with the
# proper package name.
answer = list(self.namespace) + self.name.split(' ')
return '-'.join(answer).lower()
[docs]class NewNaming(Naming):
@property
def versioned_module_name(self) -> str:
"""Return the versiond module name (e.g. ``apiname_v1``).
If there is no version, this is the same as ``module_name``.
"""
return self.module_name + (f'_{self.version}' if self.version else '')
[docs]class OldNaming(Naming):
@property
def versioned_module_name(self) -> str:
"""Return the versiond module name (e.g. ``apiname_v1``).
If there is no version, this is the same as ``module_name``.
"""
return self.module_name + (f'.{self.version}' if self.version else '')