OXYGENHUB
◆ main openmsx / build / patch.py
9.3 KiB · Python · last changed chore(): initial commit 1 month ago source blame history raw
1
# Applies a unified diff to a directory tree.
2
3
from io import open
4
from os.path import abspath, isdir, join as joinpath, sep
5
import re
6
import sys
7
8
class LineScanner(object):
9
10
	def __init__(self, stream, lineFilter = None):
11
		'''Scans the given stream.
12
		Any object providing readline() can be passed as stream, such as file
13
		objects. The line scanner does not close the stream.
14
		The optional line filter is a function that will be called for every
15
		line read from the stream; iff the filter returns False, the line is
16
		skipped. This can be useful for example for skipping comment lines.
17
		'''
18
		if not hasattr(stream, 'readline'):
19
			raise TypeError(
20
				'Invalid type for stream: %s' % type(stream).__name__
21
				)
22
		self.__stream = stream
23
		self.__filter = lineFilter
24
		self.__currLine = None
25
		self.__lineNo = 0
26
		self.nextLine()
27
28
	def end(self):
29
		'''Returns True iff the end of the stream has been reached.
30
		'''
31
		return self.__currLine is None
32
33
	def peek(self):
34
		'''Returns the current line.
35
		Like readline(), the returned string includes the newline characteter
36
		if it is present in the stream.
37
		'''
38
		return self.__currLine
39
40
	def nextLine(self):
41
		'''Moves on to the next line.
42
		Raises OSError if there is a problem reading from the stream.
43
		'''
44
		stream = self.__stream
45
		lineFilter = self.__filter
46
47
		while True:
48
			line = stream.readline()
49
			self.__lineNo += 1
50
			if line:
51
				if lineFilter is None or lineFilter(line):
52
					break
53
			else:
54
				line = None
55
				break
56
		self.__currLine = line
57
58
	def getLineNumber(self):
59
		'''Returns the line number of the current line.
60
		The first line read from the stream is considered line 1.
61
		'''
62
		return self.__lineNo
63
64
	def parseError(self, msg, lineNo = None):
65
		'''Returns a ParseError object with a descriptive message.
66
		Raising the exception is left to the caller, to make sure the backtrace
67
		is meaningful.
68
		If a line number is given, that line number is used in the message,
69
		otherwise the current line number is used.
70
		'''
71
		stream = self.__stream
72
		if lineNo is None:
73
			lineNo = self.getLineNumber()
74
		return ParseError(
75
			lineNo,
76
			'Error parsing %s at line %d: %s' % (
77
				'"%s"' % stream.name if hasattr(stream, 'name') else 'stream',
78
				lineNo,
79
				msg
80
				)
81
			)
82
83
class ParseError(Exception):
84
85
	def __init__(self, lineNo, msg):
86
		Exception.__init__(self, msg)
87
		self.lineNo = lineNo
88
89
class _Change(object):
90
	oldInc = None
91
	newInc = None
92
	action = None
93
94
	def __init__(self, content):
95
		self.content = content
96
97
	def __str__(self):
98
		return self.action + self.content.rstrip()
99
100
class _Context(_Change):
101
	oldInc = 1
102
	newInc = 1
103
	action = ' '
104
105
class _Add(_Change):
106
	oldInc = 0
107
	newInc = 1
108
	action = '+'
109
110
class _Remove(_Change):
111
	oldInc = 1
112
	newInc = 0
113
	action = '-'
114
115
class Hunk(object):
116
	'''Contains the differences between two versions of a single section within
117
	a file.
118
	'''
119
	reDeltaLine = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@')
120
	changeClasses = dict( (cc.action, cc) for cc in (_Context, _Add, _Remove) )
121
122
	@classmethod
123
	def parse(cls, scanner):
124
		delta = cls.reDeltaLine.match(scanner.peek())
125
		if delta is None:
126
			raise scanner.parseError('invalid delta line')
127
		oldLine, oldLen, newLine, newLen = ( int(n) for n in delta.groups() )
128
		deltaLineNo = scanner.getLineNumber()
129
		scanner.nextLine()
130
		def lineCountMismatch(oldOrNew, declared, actual):
131
			return scanner.parseError(
132
				'hunk %s size mismatch: %d declared, %d actual' % (
133
					oldOrNew, declared, actual
134
					),
135
				deltaLineNo
136
				)
137
		def iterChanges():
138
			oldCount = 0
139
			newCount = 0
140
			while not scanner.end():
141
				line = scanner.peek()
142
				if (line.startswith('---') or line.startswith('+++')) \
143
						and oldCount == oldLen and newCount == newLen:
144
					# Hunks for a new file start here.
145
					# Since a change line could start with "---" or "+++"
146
					# as well we also have to check whether we are at the
147
					# declared end of this hunk.
148
					break
149
				if len(line) == 1:
150
					# Assume this is an empty context line that had its single
151
					# space character removed.
152
					line = ' '
153
				changeClass = cls.changeClasses.get(line[0])
154
				if changeClass is None:
155
					break
156
				content = line[1 : ]
157
				scanner.nextLine()
158
				if not scanner.end() and scanner.peek().startswith('\\'):
159
					# No newline at end of file.
160
					assert content[-1] == '\n'
161
					content = content[ : -1]
162
					scanner.nextLine()
163
				change = changeClass(content)
164
				yield change
165
				oldCount += change.oldInc
166
				newCount += change.newInc
167
			if oldCount != oldLen:
168
				raise lineCountMismatch('old', oldLen, oldCount)
169
			if newCount != newLen:
170
				raise lineCountMismatch('new', newLen, newCount)
171
		return cls(oldLine, newLine, iterChanges())
172
173
	def __init__(self, oldLine, newLine, changes):
174
		self.__oldLine = oldLine
175
		self.__newLine = newLine
176
		self.__changes = tuple(changes)
177
178
	def __str__(self):
179
		return 'hunk(old=%d,new=%d)' % (self.__oldLine, self.__newLine)
180
181
	def getOldLine(self):
182
		return self.__oldLine
183
184
	def getNewLine(self):
185
		return self.__newLine
186
187
	def iterChanges(self):
188
		return iter(self.__changes)
189
190
class Diff(object):
191
	'''Contains the differences between two versions of a single file.
192
	'''
193
194
	@classmethod
195
	def load(cls, path):
196
		'''Iterates through the differences contained in the given diff file.
197
		Only the unified diff format is supported.
198
		Each element returned is a Diff object containing the differences to
199
		a single file.
200
		'''
201
		with open(path, 'r', encoding='utf-8') as inp:
202
			scanner = LineScanner(inp, lambda line: not line.startswith('#'))
203
			error = scanner.parseError
204
			def parseHunks():
205
				while not scanner.end():
206
					if scanner.peek().startswith('@@'):
207
						yield Hunk.parse(scanner)
208
					else:
209
						break
210
			while not scanner.end():
211
				if scanner.peek().startswith('diff '):
212
					scanner.nextLine()
213
				diffLineNo = scanner.getLineNumber()
214
				if not scanner.peek().startswith('--- '):
215
					raise error('"---" expected (not a unified diff?)')
216
				scanner.nextLine()
217
				newFileLine = scanner.peek()
218
				if not newFileLine.startswith('+++ '):
219
					raise error('"+++" expected (not a unified diff?)')
220
				index = 4
221
				length = len(newFileLine)
222
				while index < length and not newFileLine[index].isspace():
223
					index += 1
224
				filePath = newFileLine[4 : index]
225
				scanner.nextLine()
226
				try:
227
					yield cls(filePath, parseHunks())
228
				except ValueError as ex:
229
					raise error('inconsistent hunks: %s' % ex, diffLineNo)
230
231
	def __init__(self, path, hunks):
232
		self.__path = path
233
		self.__hunks = sorted(hunks, key = lambda hunk: hunk.getOldLine())
234
235
		# Sanity check on line numbers.
236
		offset = 0
237
		for hunk in self.__hunks:
238
			declaredOffset = hunk.getNewLine() - hunk.getOldLine()
239
			if offset != declaredOffset:
240
				raise ValueError(
241
					'hunk offset mismatch: %d declared, %d actual' % (
242
						declaredOffset, offset
243
						)
244
					)
245
			offset += sum(
246
				change.newInc - change.oldInc for change in hunk.iterChanges()
247
				)
248
249
	def __str__(self):
250
		return 'diff of %d hunks to "%s"' % (len(self.__hunks), self.__path)
251
252
	def getPath(self):
253
		return self.__path
254
255
	def iterHunks(self):
256
		'''Iterates through the hunks in this diff in order of increasing
257
		old line numbers.
258
		'''
259
		return iter(self.__hunks)
260
261
def patch(diff, targetDir):
262
	'''Applies the given Diff to the given target directory.
263
	No fuzzy matching is done: if the diff does not apply exactly, ValueError
264
	is raised.
265
	'''
266
	absTargetDir = abspath(targetDir) + sep
267
	absFilePath = abspath(joinpath(absTargetDir, diff.getPath()))
268
	if not absFilePath.startswith(absTargetDir):
269
		raise ValueError(
270
			'Refusing to patch file "%s" outside destination directory'
271
			% diff.getPath()
272
			)
273
274
	# Read entire file into memory.
275
	# The largest file we expect to patch is the "configure" script, which is
276
	# typically about 1MB.
277
	with open(absFilePath, 'r', encoding='utf-8') as inp:
278
		lines = inp.readlines()
279
280
	for hunk in diff.iterHunks():
281
		# We will be modifying "lines" at index "newLine", while "oldLine" is
282
		# only used to produce error messages if there is a context or remove
283
		# mismatch. As a result, "newLine" is 0-based and "oldLine" is 1-based.
284
		oldLine = hunk.getOldLine()
285
		newLine = hunk.getNewLine() - 1
286
		for change in hunk.iterChanges():
287
			if change.oldInc == 0:
288
				lines.insert(newLine, change.content)
289
			else:
290
				assert change.oldInc == 1
291
				if change.content.rstrip() != lines[newLine].rstrip():
292
					raise ValueError('mismatch at line %d' % oldLine)
293
				if change.newInc == 0:
294
					del lines[newLine]
295
			oldLine += change.oldInc
296
			newLine += change.newInc
297
298
	with open(absFilePath, 'w', encoding='utf-8') as out:
299
		out.writelines(lines)
300
301
def main(diffPath, targetDir):
302
	try:
303
		differences = list(Diff.load(diffPath))
304
	except OSError as ex:
305
		print('Error reading diff:', ex, file=sys.stderr)
306
		sys.exit(1)
307
	except ParseError as ex:
308
		print(ex, file=sys.stderr)
309
		sys.exit(1)
310
311
	if not isdir(targetDir):
312
		print('Destination directory "%s" does not exist' % targetDir, file=sys.stderr)
313
		sys.exit(1)
314
	for diff in differences:
315
		targetPath = joinpath(targetDir, diff.getPath())
316
		try:
317
			patch(diff, targetDir)
318
		except OSError as ex:
319
			print('I/O error patching "%s": %s' % (
320
				targetPath, ex
321
				), file=sys.stderr)
322
			sys.exit(1)
323
		except ValueError as ex:
324
			print('Patch could not be applied to "%s": %s' % (
325
				targetPath, ex
326
				), file=sys.stderr)
327
			sys.exit(1)
328
		else:
329
			print('Patched:', targetPath)
330
331
if __name__ == '__main__':
332
	if len(sys.argv) == 3:
333
		main(*sys.argv[1 : ])
334
	else:
335
		print('Usage: python3 patch.py diff target', file=sys.stderr)
336
		sys.exit(2)