|
| 1 | +# Copyright 2021 Cyril Roelandt |
| 2 | +# |
| 3 | +# Licensed under the 3-clause BSD license. See the LICENSE file. |
| 4 | +import logging |
| 5 | +import re |
| 6 | + |
| 7 | +import upt |
| 8 | + |
| 9 | + |
| 10 | +class PortfileUpdater: |
| 11 | + def __init__(self, portfile_fp, pdiff, pkg_class): |
| 12 | + self.portfile_fp = portfile_fp |
| 13 | + self.pdiff = pdiff |
| 14 | + self.logger = logging.getLogger('upt') |
| 15 | + self.macports_pkg = pkg_class() |
| 16 | + |
| 17 | + def update(self): |
| 18 | + new_portfile_content = self._update_portfile_content() |
| 19 | + self.portfile_fp.seek(0) |
| 20 | + self.portfile_fp.write(new_portfile_content) |
| 21 | + self.portfile_fp.truncate() |
| 22 | + |
| 23 | + def _update_portfile_content(self): |
| 24 | + content = self.portfile_fp.read() |
| 25 | + content = self._update_version(content, |
| 26 | + self.pdiff.old_version, |
| 27 | + self.pdiff.new_version) |
| 28 | + content = self._update_revision(content) |
| 29 | + try: |
| 30 | + archive_format = self.macports_pkg.archive_format |
| 31 | + new_archive = self.pdiff.new.get_archive(archive_format) |
| 32 | + content = self._update_checksums(content, new_archive) |
| 33 | + except upt.ArchiveUnavailable: |
| 34 | + self.logger.info('We could not get archives for this package. ' |
| 35 | + 'The checksums/size will be wrong.') |
| 36 | + content = self._update_dependencies(content, self.pdiff, |
| 37 | + self.macports_pkg.jinja2_reqformat) |
| 38 | + return content |
| 39 | + |
| 40 | + @staticmethod |
| 41 | + def _update_checksums(content, new_archive): |
| 42 | + '''Update the checksums block. |
| 43 | +
|
| 44 | + Return CONTENT after replacing the checksums (and size) with updated |
| 45 | + checksums (and size) for NEW_ARCHIVE. |
| 46 | +
|
| 47 | + In the process, completely removes md5 checksum, which are no longer |
| 48 | + required in MacPorts. |
| 49 | + ''' |
| 50 | + m = re.search(r"[^\n]*checksums.*?[^\\]\n", content, re.DOTALL) |
| 51 | + if not m: # This Portfile had no checksums, let's not change anything |
| 52 | + return content |
| 53 | + |
| 54 | + old_archive_block = m.group(0) |
| 55 | + m = re.match(r'(\s*)checksums(\s+)[^\s]+', |
| 56 | + old_archive_block.split('\n')[0]) |
| 57 | + space_before = m.group(1) |
| 58 | + space_after = m.group(2) |
| 59 | + indent = ' ' * len(space_before + 'checksums' + space_after) |
| 60 | + new_archive_block = f'{space_before}checksums{space_after}' |
| 61 | + new_archive_block += f'rmd160 {new_archive.rmd160} \\\n' |
| 62 | + new_archive_block += f'{indent}sha256 {new_archive.sha256} \\\n' |
| 63 | + new_archive_block += f'{indent}size {new_archive.size}\n' |
| 64 | + return content.replace(old_archive_block, new_archive_block) |
| 65 | + |
| 66 | + @staticmethod |
| 67 | + def _update_version(content, old_version, new_version): |
| 68 | + '''Update the version of the package being updated. |
| 69 | +
|
| 70 | + Return CONTENT after replacing the OLD_VERSION with the NEW_VERSION. |
| 71 | + This works for the "version" keyword, and a variety of "*.setup" |
| 72 | + keywords. |
| 73 | + ''' |
| 74 | + keywords = '|'.join([ |
| 75 | + 'version', 'github.setup', 'bitbucket.setup', |
| 76 | + 'ruby.setup', 'perl5.setup', |
| 77 | + ]) |
| 78 | + return re.sub(fr'({keywords})(.*){old_version}', |
| 79 | + fr'\g<1>\g<2>{new_version}', |
| 80 | + content, count=1) |
| 81 | + |
| 82 | + @staticmethod |
| 83 | + def _update_revision(content): |
| 84 | + '''Update the first revision entry in the Portfile.''' |
| 85 | + return re.sub(r'revision(\s+)\d+', r'revision\g<1>0', content, |
| 86 | + count=1) |
| 87 | + |
| 88 | + def _update_dependencies(self, content, pdiff, reqformat_fn): |
| 89 | + for phase in ['build', 'lib', 'test']: |
| 90 | + content = self._update_dependency_phase(content, pdiff, |
| 91 | + reqformat_fn, phase) |
| 92 | + return content |
| 93 | + |
| 94 | + @staticmethod |
| 95 | + def _get_current_dependencies(content, phase): |
| 96 | + '''Return a list of the current dependencies for a given phase. |
| 97 | +
|
| 98 | + Extract all dependencies for PHASE from CONTENT. PHASE must be one of |
| 99 | + 'build', 'lib', 'test'. The dependencies are returned just like they |
| 100 | + are written in the Portfile, for instance: |
| 101 | +
|
| 102 | + ['port:py${python.version}-six', 'port:py${python.version}-xlrd'] |
| 103 | + ''' |
| 104 | + mmm = re.search(fr"[^\n]*depends_{phase}-append.*?[^\\]\n", |
| 105 | + content, re.DOTALL) |
| 106 | + deps = [] |
| 107 | + if mmm: |
| 108 | + old_depends_block = mmm.group(0) |
| 109 | + for line in old_depends_block.split('\n'): |
| 110 | + m = re.match(fr'(\s*)depends_{phase}-append(\s+)(.*)', line) |
| 111 | + if m: |
| 112 | + line = m.group(3) |
| 113 | + if line.endswith('\\'): |
| 114 | + line = line[:-1] |
| 115 | + line = line.strip() |
| 116 | + deps.extend(line.split()) |
| 117 | + else: |
| 118 | + old_depends_block = '' |
| 119 | + |
| 120 | + return old_depends_block, deps |
| 121 | + |
| 122 | + @staticmethod |
| 123 | + def _remove_deleted_dependencies(current_deps, deleted_dependencies): |
| 124 | + '''Remove DELETED_DEPENDENCIES from CURRENT_DEPS. |
| 125 | +
|
| 126 | + CURRENT_DEPS and DELETED_DEPENDENCIES must be lists of dependencies as |
| 127 | + specified in a Portfile. |
| 128 | +
|
| 129 | + Example: |
| 130 | + current_deps = ['port:py${python.version}-six', |
| 131 | + 'port:py${python.version}-xlrd'] |
| 132 | + deleted_dependencies = ['port:py${python.version}-xlrd'] |
| 133 | + This method will return ['port:py${python.version}-six'] |
| 134 | + ''' |
| 135 | + # We could have used operations on sets here, but since sets are |
| 136 | + # unordered, the dependencies would have been "shuffled", which would |
| 137 | + # have caused the diff between the old and the new Portfiles to be |
| 138 | + # bigger and harder to read that they needed to be. |
| 139 | + for deleted_dependency in deleted_dependencies: |
| 140 | + try: |
| 141 | + current_deps.remove(deleted_dependency) |
| 142 | + except ValueError: |
| 143 | + # This particular requirement is no longer marked as needed |
| 144 | + # upstream. Maybe it was never included in the Makefile, which |
| 145 | + # means that trying to remove it may raise this exception. |
| 146 | + pass |
| 147 | + return current_deps |
| 148 | + |
| 149 | + @staticmethod |
| 150 | + def _add_new_dependencies(current_deps, new_dependencies): |
| 151 | + '''Add NEW_DEPENDENCIES to CURRENT_DEPS. |
| 152 | +
|
| 153 | + CURRENT_DEPS and NEW_DEPENDENCIES must be lists of dependencies as |
| 154 | + specified in a Portfile. |
| 155 | +
|
| 156 | + Example: |
| 157 | + current_deps = ['port:py${python.version}-six'] |
| 158 | + new_dependencies = ['port:py${python.version}-xlrd'] |
| 159 | + This method will return = ['port:py${python.version}-six', |
| 160 | + 'port:py${python.version}-xlrd'] |
| 161 | + ''' |
| 162 | + # We could have used operations on sets here, but since sets are |
| 163 | + # unordered, the dependencies would have been "shuffled", which would |
| 164 | + # have caused the diff between the old and the new Portfiles to be |
| 165 | + # bigger and harder to read that they needed to be. |
| 166 | + # |
| 167 | + # Some of the new requirements may already be in the Portfile. This |
| 168 | + # happens when upstream failed to properly specify metadata in the old |
| 169 | + # version and fixed everything in the new one: |
| 170 | + # |
| 171 | + # Old upstream metadata: "required: []" (even though 'foo' is needed) |
| 172 | + # New upstream metadata: "required: ['foo']" |
| 173 | + # |
| 174 | + # In this case, upt will consider that 'foo' is a new requirement. |
| 175 | + # Since it was already required in the old version (even though that |
| 176 | + # was not specified in the metadata), the dependency on 'foo' will |
| 177 | + # already be specified in the Portfile. We need to make sure that we do |
| 178 | + # not duplicate this dependency, hence the if condition in the loop. |
| 179 | + for new_dependency in new_dependencies: |
| 180 | + if new_dependency not in current_deps: |
| 181 | + current_deps.append(new_dependency) |
| 182 | + return current_deps |
| 183 | + |
| 184 | + def _update_dependency_phase(self, content, pdiff, reqformat_fn, phase): |
| 185 | + phases = { |
| 186 | + 'build': 'build', |
| 187 | + 'lib': 'run', |
| 188 | + 'test': 'test', |
| 189 | + } |
| 190 | + # Let's extract the dependencies currently specified in the Portfile |
| 191 | + # for this phase. |
| 192 | + old_depends_block, deps = self._get_current_dependencies(content, |
| 193 | + phase) |
| 194 | + |
| 195 | + # Start by removing the deleted dependencies. |
| 196 | + deleted_dependencies = [ |
| 197 | + f'port:{reqformat_fn(deleted_dependency)}' |
| 198 | + for deleted_dependency in pdiff.deleted_requirements(phases[phase]) |
| 199 | + ] |
| 200 | + deps = self._remove_deleted_dependencies(deps, deleted_dependencies) |
| 201 | + |
| 202 | + # Next, add the new dependencies. |
| 203 | + new_dependencies = [ |
| 204 | + f'port:{reqformat_fn(new_dependency)}' |
| 205 | + for new_dependency in pdiff.new_requirements(phases[phase]) |
| 206 | + ] |
| 207 | + deps = self._add_new_dependencies(deps, new_dependencies) |
| 208 | + |
| 209 | + # Finally, format the new depends block properly. |
| 210 | + new_depends_block = self._format_like(deps, old_depends_block, phase) |
| 211 | + if old_depends_block: |
| 212 | + content = content.replace(old_depends_block, new_depends_block) |
| 213 | + else: |
| 214 | + # This phase had no dependencies, let's add it at the bottom of the |
| 215 | + # Portfile and let the maintainer move it wherever they want. |
| 216 | + if new_depends_block: |
| 217 | + content += '# TODO: Move this\n' |
| 218 | + content += new_depends_block |
| 219 | + return content |
| 220 | + |
| 221 | + @staticmethod |
| 222 | + def _format_like(deps, old_depends_block, phase): |
| 223 | + '''Format a block of dependencies. |
| 224 | +
|
| 225 | + Return a string representing a dependency block for PHASE, containing |
| 226 | + dependencies specified in DEPS, so that it uses the same |
| 227 | + indentation/spacing as OLD_DEPENDS_BLOCK. |
| 228 | + ''' |
| 229 | + if not deps: # No dependencies -> No block in the Portfile |
| 230 | + return '' |
| 231 | + |
| 232 | + # Read |
| 233 | + old_depends_block_lines = old_depends_block.split('\n') |
| 234 | + block_name = f'depends_{phase}-append' |
| 235 | + m = re.match(fr'(\s*)depends_{phase}-append(\s+)(.*)', |
| 236 | + old_depends_block_lines[0]) |
| 237 | + if m: |
| 238 | + first_line_indent = m.group(1) |
| 239 | + space = m.group(2) |
| 240 | + if m.group(3) == '\\': |
| 241 | + # When the first line does not contain a dependency, like this: |
| 242 | + # depends_lib-append \ |
| 243 | + # we use this "hack": this allows us to not handle a "special" |
| 244 | + # case" when crafting the new depends block. |
| 245 | + deps.insert(0, '') |
| 246 | + else: |
| 247 | + # This phase was not included in the original Portfile, we will |
| 248 | + # build it "from scratch". |
| 249 | + first_line_indent = '' |
| 250 | + space = ' ' |
| 251 | + |
| 252 | + next_lines_indent = '' |
| 253 | + if len(old_depends_block_lines) > 1: |
| 254 | + m = re.match(r'(\s+)', old_depends_block_lines[1]) |
| 255 | + if m: |
| 256 | + next_lines_indent = m.group(1) |
| 257 | + |
| 258 | + if next_lines_indent == '': |
| 259 | + next_lines_indent = first_line_indent |
| 260 | + next_lines_indent += ' ' * len(block_name) |
| 261 | + next_lines_indent += space |
| 262 | + |
| 263 | + # Craft the new block |
| 264 | + new_depends = f'{first_line_indent}{block_name}' |
| 265 | + if not deps[0]: |
| 266 | + new_depends += ' ' * (len(space) - 1) |
| 267 | + else: |
| 268 | + new_depends += space |
| 269 | + new_depends += ' \\\n'.join([ |
| 270 | + dep if i == 0 |
| 271 | + else f'{next_lines_indent}{dep}' |
| 272 | + for i, dep in enumerate(deps) |
| 273 | + ]) |
| 274 | + new_depends += '\n' |
| 275 | + return new_depends |
0 commit comments