Some fixes for the release script & other helpers.
[rsync.git] / packaging / pkglib.py
blobb640d734c0eddffbd4ceecca382d2548469e86f6
1 import os, sys, re, subprocess
3 # This python3 library provides a few helpful routines that are
4 # used by the latest packaging scripts.
6 default_encoding = 'utf-8'
8 # Output the msg args to stderr. Accepts all the args that print() accepts.
9 def warn(*msg):
10 print(*msg, file=sys.stderr)
13 # Output the msg args to stderr and die with a non-zero return-code.
14 # Accepts all the args that print() accepts.
15 def die(*msg):
16 warn(*msg)
17 sys.exit(1)
20 # Set this to an encoding name or set it to None to avoid the default encoding idiom.
21 def set_default_encoding(enc):
22 default_encoding = enc
25 # Set shell=True if the cmd is a string; sets a default encoding unless raw=True was specified.
26 def _tweak_opts(cmd, opts, **maybe_set):
27 # This sets any maybe_set value that isn't already set AND creates a copy of opts for us.
28 opts = {**maybe_set, **opts}
30 if type(cmd) == str:
31 opts = {'shell': True, **opts}
33 want_raw = opts.pop('raw', False)
34 if default_encoding and not want_raw:
35 opts = {'encoding': default_encoding, **opts}
37 capture = opts.pop('capture', None)
38 if capture:
39 if capture == 'stdout':
40 opts = {'stdout': subprocess.PIPE, **opts}
41 elif capture == 'stderr':
42 opts = {'stderr': subprocess.PIPE, **opts}
43 elif capture == 'output':
44 opts = {'stdout': subprocess.PIPE, 'stderr': subprocess.PIPE, **opts}
45 elif capture == 'combined':
46 opts = {'stdout': subprocess.PIPE, 'stderr': subprocess.STDOUT, **opts}
48 discard = opts.pop('discard', None)
49 if discard:
50 # We DO want to override any already set stdout|stderr values (unlike above).
51 if discard == 'stdout' or discard == 'output':
52 opts['stdout'] = subprocess.DEVNULL
53 if discard == 'stderr' or discard == 'output':
54 opts['stderr'] = subprocess.DEVNULL
56 return opts
59 # This does a normal subprocess.run() with some auto-args added to make life easier.
60 def cmd_run(cmd, **opts):
61 return subprocess.run(cmd, **_tweak_opts(cmd, opts))
64 # Like cmd_run() with a default check=True specified.
65 def cmd_chk(cmd, **opts):
66 return subprocess.run(cmd, **_tweak_opts(cmd, opts, check=True))
69 # Capture stdout in a string and return the (output, return_code) tuple.
70 # Use capture='combined' opt to get both stdout and stderr together.
71 def cmd_txt_status(cmd, **opts):
72 input = opts.pop('input', None)
73 if input is not None:
74 opts['stdin'] = subprocess.PIPE
75 proc = subprocess.Popen(cmd, **_tweak_opts(cmd, opts, capture='stdout'))
76 out = proc.communicate(input=input)[0]
77 return (out, proc.returncode)
80 # Like cmd_txt_status() but just return the output.
81 def cmd_txt(cmd, **opts):
82 return cmd_txt_status(cmd, **opts)[0]
85 # Capture stdout in a string and return the output if the command has a 0 return code.
86 # Otherwise it throws an exception that indicates the return code and the output.
87 def cmd_txt_chk(cmd, **opts):
88 out, rc = cmd_txt_status(cmd, **opts)
89 if rc != 0:
90 cmd_err = f'Command "{cmd}" returned non-zero exit status "{rc}" and output:\n{out}'
91 raise Exception(cmd_err)
92 return out
95 # Starts a piped-output command of stdout (by default) and leaves it up to you to read
96 # the output and call communicate() on the returned object.
97 def cmd_pipe(cmd, **opts):
98 return subprocess.Popen(cmd, **_tweak_opts(cmd, opts, capture='stdout'))
101 # Runs a "git status" command and dies if the checkout is not clean (the
102 # arg fatal_unless_clean can be used to make that non-fatal. Returns a
103 # tuple of the current branch, the is_clean flag, and the status text.
104 def check_git_status(fatal_unless_clean=True, subdir='.'):
105 status_txt = cmd_txt_chk(f"cd '{subdir}' && git status")
106 is_clean = re.search(r'\nnothing to commit.+working (directory|tree) clean', status_txt) != None
108 if not is_clean and fatal_unless_clean:
109 if subdir == '.':
110 subdir = ''
111 else:
112 subdir = f" *{subdir}*"
113 die(f"The{subdir} checkout is not clean:\n" + status_txt)
115 m = re.match(r'^(?:# )?On branch (.+)\n', status_txt)
116 cur_branch = m[1] if m else None
118 return (cur_branch, is_clean, status_txt)
121 # Calls check_git_status() on the current git checkout and (optionally) a subdir path's
122 # checkout. Use fatal_unless_clean to indicate if an unclean checkout is fatal or not.
123 # The master_branch arg indicates what branch we want both checkouts to be using, and
124 # if the branch is wrong the user is given the option of either switching to the right
125 # branch or aborting.
126 def check_git_state(master_branch, fatal_unless_clean=True, check_extra_dir=None):
127 cur_branch = check_git_status(fatal_unless_clean)[0]
128 branch = re.sub(r'^patch/([^/]+)/[^/]+$', r'\1', cur_branch) # change patch/BRANCH/PATCH_NAME into BRANCH
129 if branch != master_branch:
130 print(f"The checkout is not on the {master_branch} branch.")
131 if master_branch != 'master':
132 sys.exit(1)
133 ans = input(f"Do you want me to continue with --branch={branch}? [n] ")
134 if not ans or not re.match(r'^y', ans, flags=re.I):
135 sys.exit(1)
136 master_branch = branch
138 if check_extra_dir and os.path.isdir(os.path.join(check_extra_dir, '.git')):
139 branch = check_git_status(fatal_unless_clean, check_extra_dir)[0]
140 if branch != master_branch:
141 print(f"The *{check_extra_dir}* checkout is on branch {branch}, not branch {master_branch}.")
142 ans = input(f"Do you want to change it to branch {master_branch}? [n] ")
143 if not ans or not re.match(r'^y', ans, flags=re.I):
144 sys.exit(1)
145 subdir.check_call(f"cd {check_extra_dir} && git checkout '{master_branch}'", shell=True)
147 return (cur_branch, master_branch)
150 # Return the git hash of the most recent commit.
151 def latest_git_hash(branch):
152 out = cmd_txt_chk(['git', 'log', '-1', '--no-color', branch])
153 m = re.search(r'^commit (\S+)', out, flags=re.M)
154 if not m:
155 die(f"Unable to determine commit hash for master branch: {branch}")
156 return m[1]
159 # Return a set of all branch names that have the format "patch/BASE_BRANCH/NAME"
160 # for the given base_branch string. Just the NAME portion is put into the set.
161 def get_patch_branches(base_branch):
162 branches = set()
163 proc = cmd_pipe('git branch -l'.split())
164 for line in proc.stdout:
165 m = re.search(r' patch/([^/]+)/(.+)', line)
166 if m and m[1] == base_branch:
167 branches.add(m[2])
168 proc.communicate()
169 return branches
172 def mandate_gensend_hook():
173 hook = '.git/hooks/pre-push'
174 if not os.path.exists(hook):
175 print('Creating hook file:', hook)
176 cmd_chk(['./rsync', '-a', 'packaging/pre-push', hook])
177 else:
178 out, rc = cmd_txt_status(['fgrep', 'make gensend', hook], discard='output')
179 if rc:
180 die('Please add a "make gensend" into your', hook, 'script.')
183 # Snag the GENFILES values out of the Makefile.in file and return them as a list.
184 def get_gen_files():
185 cont_re = re.compile(r'\\\n')
187 extras = [ ]
189 with open('Makefile.in', 'r', encoding='utf-8') as fh:
190 for line in fh:
191 if not extras:
192 chk = re.sub(r'^GENFILES=', '', line)
193 if line == chk:
194 continue
195 line = chk
196 m = re.search(r'\\$', line)
197 line = re.sub(r'^\s+|\s*\\\n?$|\s+$', '', line)
198 extras += line.split()
199 if not m:
200 break
202 return extras
205 def get_configure_version():
206 with open('configure.ac', 'r', encoding='utf-8') as fh:
207 for line in fh:
208 m = re.match(r'^AC_INIT\(\[rsync\],\s*\[(\d.+?)\]', line)
209 if m:
210 return m[1]
211 die("Unable to find AC_INIT with version in configure.ac")
214 def get_OLDNEWS_version_info():
215 rel_re = re.compile(r'^\| \d{2} \w{3} \d{4}\s+\|\s+(?P<ver>\d+\.\d+\.\d+)\s+\|\s+(?P<pdate>\d{2} \w{3} \d{4}\s+)?\|\s+(?P<pver>\d+)\s+\|')
216 last_version = last_protocol_version = None
217 pdate = { }
219 with open('OLDNEWS.md', 'r', encoding='utf-8') as fh:
220 for line in fh:
221 if not last_version:
222 m = re.search(r'(\d+\.\d+\.\d+)', line)
223 if m:
224 last_version = m[1]
225 m = rel_re.match(line)
226 if m:
227 if m['pdate']:
228 pdate[m['ver']] = m['pdate']
229 if m['ver'] == last_version:
230 last_protocol_version = m['pver']
231 break
233 if not last_protocol_version:
234 die(f"Unable to determine protocol_version for {last_version}.")
236 return last_version, last_protocol_version
239 def get_protocol_versions():
240 protocol_version = subprotocol_version = None
242 with open('rsync.h', 'r', encoding='utf-8') as fh:
243 for line in fh:
244 m = re.match(r'^#define\s+PROTOCOL_VERSION\s+(\d+)', line)
245 if m:
246 protocol_version = m[1]
247 continue
248 m = re.match(r'^#define\s+SUBPROTOCOL_VERSION\s+(\d+)', line)
249 if m:
250 subprotocol_version = m[1]
251 break
253 if not protocol_version:
254 die("Unable to determine the current PROTOCOL_VERSION.")
256 if not subprotocol_version:
257 die("Unable to determine the current SUBPROTOCOL_VERSION.")
259 return protocol_version, subprotocol_version
261 # vim: sw=4 et