1#!/usr/bin/env python3 2 3"""Manage site and releases. 4 5Usage: 6 manage.py release [<branch>] 7 manage.py site 8 9For the release command $FMT_TOKEN should contain a GitHub personal access token 10obtained from https://github.com/settings/tokens. 11""" 12 13from __future__ import print_function 14import datetime, docopt, errno, fileinput, json, os 15import re, requests, shutil, sys 16from contextlib import contextmanager 17from distutils.version import LooseVersion 18from subprocess import check_call 19 20 21class Git: 22 def __init__(self, dir): 23 self.dir = dir 24 25 def call(self, method, args, **kwargs): 26 return check_call(['git', method] + list(args), **kwargs) 27 28 def add(self, *args): 29 return self.call('add', args, cwd=self.dir) 30 31 def checkout(self, *args): 32 return self.call('checkout', args, cwd=self.dir) 33 34 def clean(self, *args): 35 return self.call('clean', args, cwd=self.dir) 36 37 def clone(self, *args): 38 return self.call('clone', list(args) + [self.dir]) 39 40 def commit(self, *args): 41 return self.call('commit', args, cwd=self.dir) 42 43 def pull(self, *args): 44 return self.call('pull', args, cwd=self.dir) 45 46 def push(self, *args): 47 return self.call('push', args, cwd=self.dir) 48 49 def reset(self, *args): 50 return self.call('reset', args, cwd=self.dir) 51 52 def update(self, *args): 53 clone = not os.path.exists(self.dir) 54 if clone: 55 self.clone(*args) 56 return clone 57 58 59def clean_checkout(repo, branch): 60 repo.clean('-f', '-d') 61 repo.reset('--hard') 62 repo.checkout(branch) 63 64 65class Runner: 66 def __init__(self, cwd): 67 self.cwd = cwd 68 69 def __call__(self, *args, **kwargs): 70 kwargs['cwd'] = kwargs.get('cwd', self.cwd) 71 check_call(args, **kwargs) 72 73 74def create_build_env(): 75 """Create a build environment.""" 76 class Env: 77 pass 78 env = Env() 79 80 # Import the documentation build module. 81 env.fmt_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 82 sys.path.insert(0, os.path.join(env.fmt_dir, 'doc')) 83 import build 84 85 env.build_dir = 'build' 86 env.versions = build.versions 87 88 # Virtualenv and repos are cached to speed up builds. 89 build.create_build_env(os.path.join(env.build_dir, 'virtualenv')) 90 91 env.fmt_repo = Git(os.path.join(env.build_dir, 'fmt')) 92 return env 93 94 95@contextmanager 96def rewrite(filename): 97 class Buffer: 98 pass 99 buffer = Buffer() 100 if not os.path.exists(filename): 101 buffer.data = '' 102 yield buffer 103 return 104 with open(filename) as f: 105 buffer.data = f.read() 106 yield buffer 107 with open(filename, 'w') as f: 108 f.write(buffer.data) 109 110 111fmt_repo_url = 'git@github.com:fmtlib/fmt' 112 113 114def update_site(env): 115 env.fmt_repo.update(fmt_repo_url) 116 117 doc_repo = Git(os.path.join(env.build_dir, 'fmtlib.github.io')) 118 doc_repo.update('git@github.com:fmtlib/fmtlib.github.io') 119 120 for version in env.versions: 121 clean_checkout(env.fmt_repo, version) 122 target_doc_dir = os.path.join(env.fmt_repo.dir, 'doc') 123 # Remove the old theme. 124 for entry in os.listdir(target_doc_dir): 125 path = os.path.join(target_doc_dir, entry) 126 if os.path.isdir(path): 127 shutil.rmtree(path) 128 # Copy the new theme. 129 for entry in ['_static', '_templates', 'basic-bootstrap', 'bootstrap', 130 'conf.py', 'fmt.less']: 131 src = os.path.join(env.fmt_dir, 'doc', entry) 132 dst = os.path.join(target_doc_dir, entry) 133 copy = shutil.copytree if os.path.isdir(src) else shutil.copyfile 134 copy(src, dst) 135 # Rename index to contents. 136 contents = os.path.join(target_doc_dir, 'contents.rst') 137 if not os.path.exists(contents): 138 os.rename(os.path.join(target_doc_dir, 'index.rst'), contents) 139 # Fix issues in reference.rst/api.rst. 140 for filename in ['reference.rst', 'api.rst', 'index.rst']: 141 pattern = re.compile('doxygenfunction.. (bin|oct|hexu|hex)$', re.M) 142 with rewrite(os.path.join(target_doc_dir, filename)) as b: 143 b.data = b.data.replace('std::ostream &', 'std::ostream&') 144 b.data = re.sub(pattern, r'doxygenfunction:: \1(int)', b.data) 145 b.data = b.data.replace('std::FILE*', 'std::FILE *') 146 b.data = b.data.replace('unsigned int', 'unsigned') 147 #b.data = b.data.replace('operator""_', 'operator"" _') 148 b.data = b.data.replace( 149 'format_to_n(OutputIt, size_t, string_view, Args&&', 150 'format_to_n(OutputIt, size_t, const S&, const Args&') 151 b.data = b.data.replace( 152 'format_to_n(OutputIt, std::size_t, string_view, Args&&', 153 'format_to_n(OutputIt, std::size_t, const S&, const Args&') 154 if version == ('3.0.2'): 155 b.data = b.data.replace( 156 'fprintf(std::ostream&', 'fprintf(std::ostream &') 157 if version == ('5.3.0'): 158 b.data = b.data.replace( 159 'format_to(OutputIt, const S&, const Args&...)', 160 'format_to(OutputIt, const S &, const Args &...)') 161 if version.startswith('5.') or version.startswith('6.'): 162 b.data = b.data.replace(', size_t', ', std::size_t') 163 if version.startswith('7.'): 164 b.data = b.data.replace(', std::size_t', ', size_t') 165 b.data = b.data.replace('join(It, It', 'join(It, Sentinel') 166 if version.startswith('7.1.'): 167 b.data = b.data.replace(', std::size_t', ', size_t') 168 b.data = b.data.replace('join(It, It', 'join(It, Sentinel') 169 b.data = b.data.replace( 170 'fmt::format_to(OutputIt, const S&, Args&&...)', 171 'fmt::format_to(OutputIt, const S&, Args&&...) -> ' + 172 'typename std::enable_if<enable, OutputIt>::type') 173 b.data = b.data.replace('aa long', 'a long') 174 b.data = b.data.replace('serveral', 'several') 175 if version.startswith('6.2.'): 176 b.data = b.data.replace( 177 'vformat(const S&, basic_format_args<' + 178 'buffer_context<Char>>)', 179 'vformat(const S&, basic_format_args<' + 180 'buffer_context<type_identity_t<Char>>>)') 181 # Fix a broken link in index.rst. 182 index = os.path.join(target_doc_dir, 'index.rst') 183 with rewrite(index) as b: 184 b.data = b.data.replace( 185 'doc/latest/index.html#format-string-syntax', 'syntax.html') 186 # Fix issues in syntax.rst. 187 index = os.path.join(target_doc_dir, 'syntax.rst') 188 with rewrite(index) as b: 189 b.data = b.data.replace( 190 '..productionlist:: sf\n', '.. productionlist:: sf\n ') 191 b.data = b.data.replace('Examples:\n', 'Examples::\n') 192 # Build the docs. 193 html_dir = os.path.join(env.build_dir, 'html') 194 if os.path.exists(html_dir): 195 shutil.rmtree(html_dir) 196 include_dir = env.fmt_repo.dir 197 if LooseVersion(version) >= LooseVersion('5.0.0'): 198 include_dir = os.path.join(include_dir, 'include', 'fmt') 199 elif LooseVersion(version) >= LooseVersion('3.0.0'): 200 include_dir = os.path.join(include_dir, 'fmt') 201 import build 202 build.build_docs(version, doc_dir=target_doc_dir, 203 include_dir=include_dir, work_dir=env.build_dir) 204 shutil.rmtree(os.path.join(html_dir, '.doctrees')) 205 # Create symlinks for older versions. 206 for link, target in {'index': 'contents', 'api': 'reference'}.items(): 207 link = os.path.join(html_dir, link) + '.html' 208 target += '.html' 209 if os.path.exists(os.path.join(html_dir, target)) and \ 210 not os.path.exists(link): 211 os.symlink(target, link) 212 # Copy docs to the website. 213 version_doc_dir = os.path.join(doc_repo.dir, version) 214 try: 215 shutil.rmtree(version_doc_dir) 216 except OSError as e: 217 if e.errno != errno.ENOENT: 218 raise 219 shutil.move(html_dir, version_doc_dir) 220 221 222def release(args): 223 env = create_build_env() 224 fmt_repo = env.fmt_repo 225 226 branch = args.get('<branch>') 227 if branch is None: 228 branch = 'master' 229 if not fmt_repo.update('-b', branch, fmt_repo_url): 230 clean_checkout(fmt_repo, branch) 231 232 # Update the date in the changelog and extract the version and the first 233 # section content. 234 changelog = 'ChangeLog.md' 235 changelog_path = os.path.join(fmt_repo.dir, changelog) 236 is_first_section = True 237 first_section = [] 238 for i, line in enumerate(fileinput.input(changelog_path, inplace=True)): 239 if i == 0: 240 version = re.match(r'# (.*) - TBD', line).group(1) 241 line = '# {} - {}\n'.format( 242 version, datetime.date.today().isoformat()) 243 elif not is_first_section: 244 pass 245 elif line.startswith('#'): 246 is_first_section = False 247 else: 248 first_section.append(line) 249 sys.stdout.write(line) 250 if first_section[0] == '\n': 251 first_section.pop(0) 252 253 changes = '' 254 code_block = False 255 stripped = False 256 for line in first_section: 257 if re.match(r'^\s*```', line): 258 code_block = not code_block 259 changes += line 260 stripped = False 261 continue 262 if code_block: 263 changes += line 264 continue 265 if line == '\n': 266 changes += line 267 if stripped: 268 changes += line 269 stripped = False 270 continue 271 if stripped: 272 line = ' ' + line.lstrip() 273 changes += line.rstrip() 274 stripped = True 275 276 cmakelists = 'CMakeLists.txt' 277 for line in fileinput.input(os.path.join(fmt_repo.dir, cmakelists), 278 inplace=True): 279 prefix = 'set(FMT_VERSION ' 280 if line.startswith(prefix): 281 line = prefix + version + ')\n' 282 sys.stdout.write(line) 283 284 # Add the version to the build script. 285 script = os.path.join('doc', 'build.py') 286 script_path = os.path.join(fmt_repo.dir, script) 287 for line in fileinput.input(script_path, inplace=True): 288 m = re.match(r'( *versions \+= )\[(.+)\]', line) 289 if m: 290 line = '{}[{}, \'{}\']\n'.format(m.group(1), m.group(2), version) 291 sys.stdout.write(line) 292 293 fmt_repo.checkout('-B', 'release') 294 fmt_repo.add(changelog, cmakelists, script) 295 fmt_repo.commit('-m', 'Update version') 296 297 # Build the docs and package. 298 run = Runner(fmt_repo.dir) 299 run('cmake', '.') 300 run('make', 'doc', 'package_source') 301 update_site(env) 302 303 # Create a release on GitHub. 304 fmt_repo.push('origin', 'release') 305 auth_headers = {'Authorization': 'token ' + os.getenv('FMT_TOKEN')} 306 r = requests.post('https://api.github.com/repos/fmtlib/fmt/releases', 307 headers=auth_headers, 308 data=json.dumps({'tag_name': version, 309 'target_commitish': 'release', 310 'body': changes, 'draft': True})) 311 if r.status_code != 201: 312 raise Exception('Failed to create a release ' + str(r)) 313 id = r.json()['id'] 314 uploads_url = 'https://uploads.github.com/repos/fmtlib/fmt/releases' 315 package = 'fmt-{}.zip'.format(version) 316 r = requests.post( 317 '{}/{}/assets?name={}'.format(uploads_url, id, package), 318 headers={'Content-Type': 'application/zip'} | auth_headers, 319 data=open('build/fmt/' + package, 'rb')) 320 if r.status_code != 201: 321 raise Exception('Failed to upload an asset ' + str(r)) 322 323 324if __name__ == '__main__': 325 args = docopt.docopt(__doc__) 326 if args.get('release'): 327 release(args) 328 elif args.get('site'): 329 update_site(create_build_env()) 330