1# Copyright 2020 Google LLC 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14# ============================================================================== 15"""Setup for Tink package with pip.""" 16from __future__ import absolute_import 17from __future__ import division 18from __future__ import print_function 19 20import glob 21import os 22import posixpath 23import re 24import shutil 25import subprocess 26import textwrap 27 28import setuptools 29from setuptools.command import build_ext 30from setuptools.command import sdist 31 32 33_PROJECT_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 34 35 36def _get_tink_version(): 37 """Parses the version number from VERSION file.""" 38 with open(os.path.join(_PROJECT_BASE_DIR, 'VERSION')) as f: 39 try: 40 version_line = next( 41 line for line in f if line.startswith('TINK_VERSION_LABEL')) 42 except StopIteration: 43 raise ValueError( 44 'Version not defined in {}/VERSION'.format(_PROJECT_BASE_DIR)) 45 else: 46 return version_line.split(' = ')[-1].strip('\n \'"') 47 48 49_TINK_VERSION = _get_tink_version() 50 51 52def _get_bazel_command(): 53 """Finds the bazel command.""" 54 if shutil.which('bazelisk'): 55 return 'bazelisk' 56 elif shutil.which('bazel'): 57 return 'bazel' 58 raise FileNotFoundError('Could not find bazel executable. Please install ' 59 'bazel to compile the Tink Python package.') 60 61 62def _get_protoc_command(): 63 """Finds the protoc command.""" 64 if 'PROTOC' in os.environ and os.path.exists(os.environ['PROTOC']): 65 return os.environ['PROTOC'] 66 protoc_path = shutil.which('protoc') 67 if protoc_path is None: 68 raise FileNotFoundError('Could not find protoc executable. Please install ' 69 'protoc to compile the Tink Python package.') 70 return protoc_path 71 72 73def _generate_proto(protoc, source): 74 """Invokes the Protocol Compiler to generate a _pb2.py.""" 75 76 if not os.path.exists(source): 77 raise FileNotFoundError('Cannot find required file: {}'.format(source)) 78 79 output = source.replace('.proto', '_pb2.py') 80 81 if (os.path.exists(output) and 82 os.path.getmtime(source) < os.path.getmtime(output)): 83 # No need to regenerate if output is newer than source. 84 return 85 86 print('Generating {}...'.format(output)) 87 protoc_args = [protoc, '-I.', '--python_out=.', source] 88 subprocess.run(args=protoc_args, check=True) 89 90 91def _parse_requirements(filename): 92 with open(os.path.join(_PROJECT_BASE_DIR, filename)) as f: 93 return [ 94 line.rstrip() 95 for line in f 96 if not (line.isspace() or line.startswith('#')) 97 ] 98 99 100def _patch_workspace(workspace_file): 101 """Update the Bazel workspace with valid repository references. 102 103 When installing the sdist, e.g., with `pip install tink --no-binary` or 104 `python3 -m pip install -v path/to/sdist.tar.gz`, setuptools unpacks the 105 sdist in a temporary folder that contains only the python/ folder, and then 106 builds it. As a consequence, relative local_repository paths that are set by 107 default in python/WORKSPACE don't exist (or worst, they may exist by 108 chance!). 109 110 By default, the local_repository() rules will be replaced with http_archive() 111 rules which contain URLs that point to an archive of the Tink GitHub 112 repository as of the latest commit as of the master branch. 113 114 This behavior can be modified via the following environment variables, in 115 order of precedence: 116 117 * TINK_PYTHON_SETUPTOOLS_OVERRIDE_BASE_PATH 118 Instead of using http_archive() rules, update the local_repository() 119 rules with the specified alternative local path. This allows for 120 building using a local copy of the project (e.g. for testing). 121 122 * TINK_PYTHON_SETUPTOOLS_TAGGED_VERSION 123 Instead of providing a URL of an archive of the current master branch, 124 instead link to an archive that correspond with the tagged version (e.g. 125 for creating release artifacts). 126 127 Args: 128 workspace_file: The tink/python WORKSPACE. 129 """ 130 131 with open(workspace_file, 'r') as f: 132 workspace_content = f.read() 133 134 if 'TINK_PYTHON_SETUPTOOLS_OVERRIDE_BASE_PATH' in os.environ: 135 base_path = os.environ['TINK_PYTHON_SETUPTOOLS_OVERRIDE_BASE_PATH'] 136 workspace_content = _patch_with_local_path(workspace_content, base_path) 137 138 elif 'TINK_PYTHON_SETUPTOOLS_TAGGED_VERSION' in os.environ: 139 tagged_version = os.environ['TINK_PYTHON_SETUPTOOLS_TAGGED_VERSION'] 140 archive_filename = 'v{}.zip'.format(tagged_version) 141 archive_prefix = 'tink-{}'.format(tagged_version) 142 workspace_content = _patch_with_http_archive(workspace_content, 143 archive_filename, 144 archive_prefix) 145 else: 146 workspace_content = _patch_with_http_archive(workspace_content, 147 'master.zip', 'tink-master') 148 149 with open(workspace_file, 'w') as f: 150 f.write(workspace_content) 151 152 153def _patch_with_local_path(workspace_content, base_path): 154 """Replaces the base paths in the local_repository() rules.""" 155 return re.sub( 156 r'(?<="tink_cc",\n path = ").*(?=\n)', 157 base_path + '/cc", # Modified by setup.py', 158 workspace_content, 159 ) 160 161 162def _patch_with_http_archive(workspace_content, filename, prefix): 163 """Replaces local_repository() rules with http_archive() rules.""" 164 165 workspace_lines = workspace_content.split('\n') 166 http_archive_load = ( 167 'load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")') 168 169 if http_archive_load not in workspace_content: 170 workspace_content = '\n'.join([workspace_lines[0], http_archive_load] + 171 workspace_lines[1:]) 172 173 cc = textwrap.dedent( 174 '''\ 175 local_repository( 176 name = "tink_cc", 177 path = "../cc", 178 ) 179 ''') 180 181 cc_patched = textwrap.dedent( 182 '''\ 183 # Modified by setup.py 184 http_archive( 185 name = "tink_cc", 186 urls = ["https://github.com/google/tink/archive/{}"], 187 strip_prefix = "{}/cc", 188 ) 189 '''.format(filename, prefix)) 190 191 return workspace_content.replace(cc, cc_patched) 192 193 194class BazelExtension(setuptools.Extension): 195 """A C/C++ extension that is defined as a Bazel BUILD target.""" 196 197 def __init__(self, bazel_target, target_name=''): 198 self.bazel_target = bazel_target 199 self.relpath, self.target_name = ( 200 posixpath.relpath(bazel_target, '//').split(':')) 201 if target_name: 202 self.target_name = target_name 203 ext_name = os.path.join( 204 self.relpath.replace(posixpath.sep, os.path.sep), self.target_name) 205 setuptools.Extension.__init__(self, ext_name, sources=[]) 206 207 208class BuildBazelExtension(build_ext.build_ext): 209 """A command that runs Bazel to build a C/C++ extension.""" 210 211 def __init__(self, dist): 212 super(BuildBazelExtension, self).__init__(dist) 213 self.bazel_command = _get_bazel_command() 214 215 def run(self): 216 for ext in self.extensions: 217 self.bazel_build(ext) 218 build_ext.build_ext.run(self) 219 220 def bazel_build(self, ext): 221 # Change WORKSPACE to include tink_cc from an archive 222 _patch_workspace('WORKSPACE') 223 224 if not os.path.exists(self.build_temp): 225 os.makedirs(self.build_temp) 226 227 # Ensure no artifacts from previous builds are reused (i.e. from builds 228 # using a different Python version). 229 bazel_clean_argv = [self.bazel_command, 'clean', '--expunge'] 230 self.spawn(bazel_clean_argv) 231 232 bazel_argv = [ 233 self.bazel_command, 'build', ext.bazel_target, 234 '--compilation_mode=' + ('dbg' if self.debug else 'opt') 235 ] 236 self.spawn(bazel_argv) 237 ext_bazel_bin_path = os.path.join('bazel-bin', ext.relpath, 238 ext.target_name + '.so') 239 ext_dest_path = self.get_ext_fullpath(ext.name) 240 ext_dest_dir = os.path.dirname(ext_dest_path) 241 if not os.path.exists(ext_dest_dir): 242 os.makedirs(ext_dest_dir) 243 shutil.copyfile(ext_bazel_bin_path, ext_dest_path) 244 245 246class SdistCmd(sdist.sdist): 247 """A command that patches the workspace before creating an sdist.""" 248 249 def run(self): 250 _patch_workspace('WORKSPACE') 251 sdist.sdist.run(self) 252 253 254def main(): 255 # Generate compiled protocol buffers. 256 protoc_command = _get_protoc_command() 257 for proto_file in glob.glob('tink/proto/*.proto'): 258 _generate_proto(protoc_command, proto_file) 259 260 setuptools.setup( 261 name='tink', 262 version=_TINK_VERSION, 263 url='https://github.com/google/tink', 264 description='A multi-language, cross-platform library that provides ' 265 'cryptographic APIs that are secure, easy to use correctly, ' 266 'and hard(er) to misuse.', 267 author='Tink Developers', 268 author_email='[email protected]', 269 long_description=open('README.md').read(), 270 long_description_content_type='text/markdown', 271 # Contained modules and scripts. 272 packages=setuptools.find_packages(), 273 install_requires=_parse_requirements('requirements.in'), 274 cmdclass={ 275 'build_ext': BuildBazelExtension, 276 'sdist': SdistCmd, 277 }, 278 ext_modules=[ 279 BazelExtension('//tink/cc/pybind:tink_bindings'), 280 ], 281 zip_safe=False, 282 # PyPI package information. 283 classifiers=[ 284 'Programming Language :: Python :: 3.7', 285 'Programming Language :: Python :: 3.8', 286 'Programming Language :: Python :: 3.9', 287 'Programming Language :: Python :: 3.10', 288 'Topic :: Software Development :: Libraries', 289 ], 290 license='Apache 2.0', 291 keywords='tink cryptography', 292 ) 293 294 295if __name__ == '__main__': 296 main() 297