Skip to content

Commit 19d7b79

Browse files
Steapreneeotten
authored andcommitted
Implement the "update" feature.
1 parent 51d6366 commit 19d7b79

File tree

4 files changed

+756
-0
lines changed

4 files changed

+756
-0
lines changed

upt_macports/portfile_updater.py

+275
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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

upt_macports/tests/test_macports_backend.py

+15
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@
77
class TestMacPortsBackend(unittest.TestCase):
88
def setUp(self):
99
self.macports_backend = MacPortsBackend()
10+
self.macports_backend.frontend = 'pypi'
11+
12+
@mock.patch('upt_macports.upt_macports.MacPortsBackend.package_versions',
13+
return_value=['1.2'])
14+
def test_current_version(self, m_package_versions):
15+
version = self.macports_backend.current_version(mock.Mock(), 'foo')
16+
self.assertEqual(version, '1.2')
17+
18+
@mock.patch('upt_macports.upt_macports.MacPortsBackend.package_versions',
19+
return_value=[])
20+
@mock.patch('upt.Backend.current_version', return_value='1.2')
21+
def test_current_version_fallback(self, m_current_version,
22+
m_package_versions):
23+
version = self.macports_backend.current_version(mock.Mock(), 'foo')
24+
self.assertEqual(version, '1.2')
1025

1126
def test_unhandled_frontend(self):
1227
upt_pkg = upt.Package('foo', '42')

0 commit comments

Comments
 (0)