1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
|
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
import os
import sys
import re
from typing import List, Tuple
import requests
import jinja2
from poet.poet import make_graph, RESOURCE_TEMPLATE, research_package
from collections import OrderedDict
import bisect
import argparse
TEMPLATE_FILE_NAME = 'formula_template.txt'
CLI_VERSION = os.environ['CLI_VERSION']
HOMEBREW_UPSTREAM_URL = os.environ['HOMEBREW_UPSTREAM_URL']
HOMEBREW_FORMULAR_LATEST = "https://raw.githubusercontent.com/Homebrew/homebrew-core/master/Formula/a/azure-cli.rb"
PYTHON_VERSION = '3.13'
def main():
print('Generate formula for Azure CLI homebrew release.')
parser = argparse.ArgumentParser(prog='formula_generator.py')
parser.set_defaults(func=generate_formula)
parser.add_argument('-b', dest='build_method', choices=['update_existing', 'use_template'],
help='The build method, default is update_existing, the other option is use_template.')
args = parser.parse_args()
args.func(**vars(args))
def generate_formula(build_method: str, **_):
content = ''
if build_method is None or build_method == 'update_existing':
content = update_formula()
elif build_method == 'use_template':
content = generate_formula_with_template()
with open('azure-cli.rb', mode='w') as fq:
fq.write(content)
def generate_formula_with_template() -> str:
"""Generate a brew formula by using a template"""
template_path = os.path.join(os.path.dirname(__file__), TEMPLATE_FILE_NAME)
with open(template_path, mode='r') as fq:
template_content = fq.read()
template = jinja2.Template(template_content)
content = template.render(
cli_version=CLI_VERSION,
upstream_url=HOMEBREW_UPSTREAM_URL,
upstream_sha=compute_sha256(HOMEBREW_UPSTREAM_URL),
resources=collect_resources(),
bottle_hash=last_bottle_hash()
)
if not content.endswith('\n'):
content += '\n'
return content
def compute_sha256(resource_url: str) -> str:
import hashlib
sha256 = hashlib.sha256()
resp = requests.get(resource_url)
resp.raise_for_status()
sha256.update(resp.content)
return sha256.hexdigest()
def collect_resources() -> str:
nodes = make_graph('azure-cli')
nodes_render = []
for node_name in sorted(nodes):
if not resource_filter(node_name):
continue
nodes_render.append(RESOURCE_TEMPLATE.render(resource=nodes[node_name]))
return '\n\n'.join(nodes_render)
def collect_resources_dict() -> dict:
nodes = make_graph('azure-cli')
# Homebrew does not install pip and setuptools after Python 3.12
# see https://github.com/Azure/azure-cli/pull/29887
extra_dependencies = ['pip', 'setuptools']
for dependency in extra_dependencies:
nodes[dependency] = research_package(dependency)
filtered_nodes = {nodes[node_name]['name']: nodes[node_name] for node_name in sorted(nodes) if
resource_filter(node_name)}
return filtered_nodes
def resource_filter(name: str) -> bool:
# TODO remove need for any filters and delete this method.
return not name.startswith('azure-cli') and name not in ('futures', 'jeepney', 'entrypoints')
def last_bottle_hash():
"""Fetch the bottle do ... end from the latest brew formula"""
resp = requests.get(HOMEBREW_FORMULAR_LATEST)
resp.raise_for_status()
lines = resp.text.split('\n')
look_for_end = False
start = 0
end = 0
for idx, content in enumerate(lines):
if look_for_end:
if 'end' in content:
end = idx
break
else:
if 'bottle do' in content:
start = idx
look_for_end = True
return '\n'.join(lines[start: end + 1])
def update_formula() -> str:
"""Generate a brew formula by updating the existing one"""
nodes = collect_resources_dict()
resp = requests.get(HOMEBREW_FORMULAR_LATEST)
resp.raise_for_status()
text = resp.text
# update python version
text = re.sub('depends_on "python@.*"', f'depends_on "python@{PYTHON_VERSION}"', text, 1)
venv_str = f'venv = virtualenv_create(libexec, "python{PYTHON_VERSION}", system_site_packages: false)'
text = re.sub(r'venv = virtualenv_create.*', venv_str, text, 1)
# update url and sha256 of azure-cli
text = re.sub('url ".*"', 'url "{}"'.format(HOMEBREW_UPSTREAM_URL), text, 1)
upstream_sha = compute_sha256(HOMEBREW_UPSTREAM_URL)
text = re.sub('sha256 ".*"', 'sha256 "{}"'.format(upstream_sha), text, 1)
text = re.sub('.*revision.*\n', '', text, 1) # remove revision for previous version if exists
pack = None
packs_to_remove = set()
lines = text.split('\n')
node_index_dict = OrderedDict()
line_idx_to_remove = set()
upgrade = False
for idx, line in enumerate(lines):
# In released formula, the url is in the release tag format, such as
# "https://github.com/Azure/azure-cli/archive/azure-cli-2.17.1.tar.gz".
# version is extracted from url. During build, the url is in the format like
# "https://codeload.github.com/Azure/azure-cli/legacy.tar.gz/7e09fd50c9ef02e1ed7d4709c7ab1a71acd3840b".
# We need to add the version explicitly after url.
# We will change the url in our release pipeline and remove version.
if line.startswith(" url"):
lines[idx] = lines[idx] + '\n' + ' version "{}"'.format(CLI_VERSION)
elif line.strip().startswith("resource"):
m = re.search(r'resource "(.*)" do', line)
if m is not None:
pack = m.group(1)
node_index_dict[pack] = idx
elif pack is not None:
if line.startswith(" url"):
# update the url of package
if pack in nodes.keys():
url_match = re.search(r'url "(.*)"', line)
if url_match is not None and nodes[pack]['url'] != url_match.group(1):
lines[idx] = re.sub('url ".*"', 'url "{}"'.format(nodes[pack]['url']), line, 1)
upgrade = True
else:
packs_to_remove.add(pack)
elif line.startswith(" sha256"):
# update the sha256 of package
if pack in nodes.keys():
lines[idx] = re.sub('sha256 ".*"', 'sha256 "{}"'.format(nodes[pack]['checksum']), line, 1)
del nodes[pack]
elif line.startswith(" end"):
pack = None
upgrade = False
elif upgrade: # In case of upgrading, remove any patch following url and sha256 but before end
line_idx_to_remove.add(idx)
elif line.strip().startswith('def install'):
if nodes:
# add new dependency packages
for node_name, node in nodes.items():
# find the right place to insert the new resource per alphabetic order
i = bisect.bisect_left(list(node_index_dict.keys()), node_name)
line_idx = list(node_index_dict.items())[i][1]
resource = RESOURCE_TEMPLATE.render(resource=node)
lines[line_idx] = resource + '\n\n' + lines[line_idx]
lines = [line for idx, line in enumerate(lines) if idx not in line_idx_to_remove]
new_text = "\n".join(lines)
# remove dependency packages that are no longer needed
for pack in packs_to_remove:
new_text = re.sub(r'resource "{}" do.*?\n end\n\s+'.format(pack), '', new_text, flags=re.DOTALL)
return new_text
if __name__ == '__main__':
main()
|