##// END OF EJS Templates
typos: "it's" -> "its"
Martin Geisler -
r9087:f48454a2 default
parent child Browse files
Show More
@@ -1,549 +1,549
1 1 # record.py
2 2 #
3 3 # Copyright 2007 Bryan O'Sullivan <bos@serpentine.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2, incorporated herein by reference.
7 7
8 8 '''commands to interactively select changes for commit/qrefresh'''
9 9
10 10 from mercurial.i18n import gettext, _
11 11 from mercurial import cmdutil, commands, extensions, hg, mdiff, patch
12 12 from mercurial import util
13 13 import copy, cStringIO, errno, operator, os, re, tempfile
14 14
15 15 lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
16 16
17 17 def scanpatch(fp):
18 18 """like patch.iterhunks, but yield different events
19 19
20 20 - ('file', [header_lines + fromfile + tofile])
21 21 - ('context', [context_lines])
22 22 - ('hunk', [hunk_lines])
23 23 - ('range', (-start,len, +start,len, diffp))
24 24 """
25 25 lr = patch.linereader(fp)
26 26
27 27 def scanwhile(first, p):
28 28 """scan lr while predicate holds"""
29 29 lines = [first]
30 30 while True:
31 31 line = lr.readline()
32 32 if not line:
33 33 break
34 34 if p(line):
35 35 lines.append(line)
36 36 else:
37 37 lr.push(line)
38 38 break
39 39 return lines
40 40
41 41 while True:
42 42 line = lr.readline()
43 43 if not line:
44 44 break
45 45 if line.startswith('diff --git a/'):
46 46 def notheader(line):
47 47 s = line.split(None, 1)
48 48 return not s or s[0] not in ('---', 'diff')
49 49 header = scanwhile(line, notheader)
50 50 fromfile = lr.readline()
51 51 if fromfile.startswith('---'):
52 52 tofile = lr.readline()
53 53 header += [fromfile, tofile]
54 54 else:
55 55 lr.push(fromfile)
56 56 yield 'file', header
57 57 elif line[0] == ' ':
58 58 yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
59 59 elif line[0] in '-+':
60 60 yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
61 61 else:
62 62 m = lines_re.match(line)
63 63 if m:
64 64 yield 'range', m.groups()
65 65 else:
66 66 raise patch.PatchError('unknown patch content: %r' % line)
67 67
68 68 class header(object):
69 69 """patch header
70 70
71 71 XXX shoudn't we move this to mercurial/patch.py ?
72 72 """
73 73 diff_re = re.compile('diff --git a/(.*) b/(.*)$')
74 74 allhunks_re = re.compile('(?:index|new file|deleted file) ')
75 75 pretty_re = re.compile('(?:new file|deleted file) ')
76 76 special_re = re.compile('(?:index|new|deleted|copy|rename) ')
77 77
78 78 def __init__(self, header):
79 79 self.header = header
80 80 self.hunks = []
81 81
82 82 def binary(self):
83 83 for h in self.header:
84 84 if h.startswith('index '):
85 85 return True
86 86
87 87 def pretty(self, fp):
88 88 for h in self.header:
89 89 if h.startswith('index '):
90 90 fp.write(_('this modifies a binary file (all or nothing)\n'))
91 91 break
92 92 if self.pretty_re.match(h):
93 93 fp.write(h)
94 94 if self.binary():
95 95 fp.write(_('this is a binary file\n'))
96 96 break
97 97 if h.startswith('---'):
98 98 fp.write(_('%d hunks, %d lines changed\n') %
99 99 (len(self.hunks),
100 100 sum([h.added + h.removed for h in self.hunks])))
101 101 break
102 102 fp.write(h)
103 103
104 104 def write(self, fp):
105 105 fp.write(''.join(self.header))
106 106
107 107 def allhunks(self):
108 108 for h in self.header:
109 109 if self.allhunks_re.match(h):
110 110 return True
111 111
112 112 def files(self):
113 113 fromfile, tofile = self.diff_re.match(self.header[0]).groups()
114 114 if fromfile == tofile:
115 115 return [fromfile]
116 116 return [fromfile, tofile]
117 117
118 118 def filename(self):
119 119 return self.files()[-1]
120 120
121 121 def __repr__(self):
122 122 return '<header %s>' % (' '.join(map(repr, self.files())))
123 123
124 124 def special(self):
125 125 for h in self.header:
126 126 if self.special_re.match(h):
127 127 return True
128 128
129 129 def countchanges(hunk):
130 130 """hunk -> (n+,n-)"""
131 131 add = len([h for h in hunk if h[0] == '+'])
132 132 rem = len([h for h in hunk if h[0] == '-'])
133 133 return add, rem
134 134
135 135 class hunk(object):
136 136 """patch hunk
137 137
138 138 XXX shouldn't we merge this with patch.hunk ?
139 139 """
140 140 maxcontext = 3
141 141
142 142 def __init__(self, header, fromline, toline, proc, before, hunk, after):
143 143 def trimcontext(number, lines):
144 144 delta = len(lines) - self.maxcontext
145 145 if False and delta > 0:
146 146 return number + delta, lines[:self.maxcontext]
147 147 return number, lines
148 148
149 149 self.header = header
150 150 self.fromline, self.before = trimcontext(fromline, before)
151 151 self.toline, self.after = trimcontext(toline, after)
152 152 self.proc = proc
153 153 self.hunk = hunk
154 154 self.added, self.removed = countchanges(self.hunk)
155 155
156 156 def write(self, fp):
157 157 delta = len(self.before) + len(self.after)
158 158 if self.after and self.after[-1] == '\\ No newline at end of file\n':
159 159 delta -= 1
160 160 fromlen = delta + self.removed
161 161 tolen = delta + self.added
162 162 fp.write('@@ -%d,%d +%d,%d @@%s\n' %
163 163 (self.fromline, fromlen, self.toline, tolen,
164 164 self.proc and (' ' + self.proc)))
165 165 fp.write(''.join(self.before + self.hunk + self.after))
166 166
167 167 pretty = write
168 168
169 169 def filename(self):
170 170 return self.header.filename()
171 171
172 172 def __repr__(self):
173 173 return '<hunk %r@%d>' % (self.filename(), self.fromline)
174 174
175 175 def parsepatch(fp):
176 176 """patch -> [] of hunks """
177 177 class parser(object):
178 178 """patch parsing state machine"""
179 179 def __init__(self):
180 180 self.fromline = 0
181 181 self.toline = 0
182 182 self.proc = ''
183 183 self.header = None
184 184 self.context = []
185 185 self.before = []
186 186 self.hunk = []
187 187 self.stream = []
188 188
189 189 def addrange(self, (fromstart, fromend, tostart, toend, proc)):
190 190 self.fromline = int(fromstart)
191 191 self.toline = int(tostart)
192 192 self.proc = proc
193 193
194 194 def addcontext(self, context):
195 195 if self.hunk:
196 196 h = hunk(self.header, self.fromline, self.toline, self.proc,
197 197 self.before, self.hunk, context)
198 198 self.header.hunks.append(h)
199 199 self.stream.append(h)
200 200 self.fromline += len(self.before) + h.removed
201 201 self.toline += len(self.before) + h.added
202 202 self.before = []
203 203 self.hunk = []
204 204 self.proc = ''
205 205 self.context = context
206 206
207 207 def addhunk(self, hunk):
208 208 if self.context:
209 209 self.before = self.context
210 210 self.context = []
211 211 self.hunk = hunk
212 212
213 213 def newfile(self, hdr):
214 214 self.addcontext([])
215 215 h = header(hdr)
216 216 self.stream.append(h)
217 217 self.header = h
218 218
219 219 def finished(self):
220 220 self.addcontext([])
221 221 return self.stream
222 222
223 223 transitions = {
224 224 'file': {'context': addcontext,
225 225 'file': newfile,
226 226 'hunk': addhunk,
227 227 'range': addrange},
228 228 'context': {'file': newfile,
229 229 'hunk': addhunk,
230 230 'range': addrange},
231 231 'hunk': {'context': addcontext,
232 232 'file': newfile,
233 233 'range': addrange},
234 234 'range': {'context': addcontext,
235 235 'hunk': addhunk},
236 236 }
237 237
238 238 p = parser()
239 239
240 240 state = 'context'
241 241 for newstate, data in scanpatch(fp):
242 242 try:
243 243 p.transitions[state][newstate](p, data)
244 244 except KeyError:
245 245 raise patch.PatchError('unhandled transition: %s -> %s' %
246 246 (state, newstate))
247 247 state = newstate
248 248 return p.finished()
249 249
250 250 def filterpatch(ui, chunks):
251 251 """Interactively filter patch chunks into applied-only chunks"""
252 252 chunks = list(chunks)
253 253 chunks.reverse()
254 254 seen = set()
255 255 def consumefile():
256 256 """fetch next portion from chunks until a 'header' is seen
257 257 NB: header == new-file mark
258 258 """
259 259 consumed = []
260 260 while chunks:
261 261 if isinstance(chunks[-1], header):
262 262 break
263 263 else:
264 264 consumed.append(chunks.pop())
265 265 return consumed
266 266
267 267 resp_all = [None] # this two are changed from inside prompt,
268 268 resp_file = [None] # so can't be usual variables
269 269 applied = {} # 'filename' -> [] of chunks
270 270 def prompt(query):
271 271 """prompt query, and process base inputs
272 272
273 273 - y/n for the rest of file
274 274 - y/n for the rest
275 275 - ? (help)
276 276 - q (quit)
277 277
278 278 else, input is returned to the caller.
279 279 """
280 280 if resp_all[0] is not None:
281 281 return resp_all[0]
282 282 if resp_file[0] is not None:
283 283 return resp_file[0]
284 284 while True:
285 285 resps = _('[Ynsfdaq?]')
286 286 choices = (_('&Yes, record this change'),
287 287 _('&No, skip this change'),
288 288 _('&Skip remaining changes to this file'),
289 289 _('Record remaining changes to this &file'),
290 290 _('&Done, skip remaining changes and files'),
291 291 _('Record &all changes to all remaining files'),
292 292 _('&Quit, recording no changes'),
293 293 _('&?'))
294 294 r = ui.promptchoice("%s %s " % (query, resps), choices)
295 295 if r == 7: # ?
296 296 doc = gettext(record.__doc__)
297 297 c = doc.find(_('y - record this change'))
298 298 for l in doc[c:].splitlines():
299 299 if l: ui.write(l.strip(), '\n')
300 300 continue
301 301 elif r == 0: # yes
302 302 ret = 'y'
303 303 elif r == 1: # no
304 304 ret = 'n'
305 305 elif r == 2: # Skip
306 306 ret = resp_file[0] = 'n'
307 307 elif r == 3: # file (Record remaining)
308 308 ret = resp_file[0] = 'y'
309 309 elif r == 4: # done, skip remaining
310 310 ret = resp_all[0] = 'n'
311 311 elif r == 5: # all
312 312 ret = resp_all[0] = 'y'
313 313 elif r == 6: # quit
314 314 raise util.Abort(_('user quit'))
315 315 return ret
316 316 pos, total = 0, len(chunks) - 1
317 317 while chunks:
318 318 chunk = chunks.pop()
319 319 if isinstance(chunk, header):
320 320 # new-file mark
321 321 resp_file = [None]
322 322 fixoffset = 0
323 323 hdr = ''.join(chunk.header)
324 324 if hdr in seen:
325 325 consumefile()
326 326 continue
327 327 seen.add(hdr)
328 328 if resp_all[0] is None:
329 329 chunk.pretty(ui)
330 330 r = prompt(_('examine changes to %s?') %
331 331 _(' and ').join(map(repr, chunk.files())))
332 332 if r == _('y'):
333 333 applied[chunk.filename()] = [chunk]
334 334 if chunk.allhunks():
335 335 applied[chunk.filename()] += consumefile()
336 336 else:
337 337 consumefile()
338 338 else:
339 339 # new hunk
340 340 if resp_file[0] is None and resp_all[0] is None:
341 341 chunk.pretty(ui)
342 342 r = total == 1 and prompt(_('record this change to %r?') %
343 343 chunk.filename()) \
344 344 or prompt(_('record change %d/%d to %r?') %
345 345 (pos, total, chunk.filename()))
346 346 if r == _('y'):
347 347 if fixoffset:
348 348 chunk = copy.copy(chunk)
349 349 chunk.toline += fixoffset
350 350 applied[chunk.filename()].append(chunk)
351 351 else:
352 352 fixoffset += chunk.removed - chunk.added
353 353 pos = pos + 1
354 354 return reduce(operator.add, [h for h in applied.itervalues()
355 355 if h[0].special() or len(h) > 1], [])
356 356
357 357 def record(ui, repo, *pats, **opts):
358 358 '''interactively select changes to commit
359 359
360 360 If a list of files is omitted, all changes reported by "hg status" will be
361 361 candidates for recording.
362 362
363 363 See 'hg help dates' for a list of formats valid for -d/--date.
364 364
365 365 You will be prompted for whether to record changes to each modified file,
366 366 and for files with multiple changes, for each change to use. For each
367 367 query, the following responses are possible:
368 368
369 369 y - record this change
370 370 n - skip this change
371 371
372 372 s - skip remaining changes to this file
373 373 f - record remaining changes to this file
374 374
375 375 d - done, skip remaining changes and files
376 376 a - record all changes to all remaining files
377 377 q - quit, recording no changes
378 378
379 379 ? - display help'''
380 380
381 381 def record_committer(ui, repo, pats, opts):
382 382 commands.commit(ui, repo, *pats, **opts)
383 383
384 384 dorecord(ui, repo, record_committer, *pats, **opts)
385 385
386 386
387 387 def qrecord(ui, repo, patch, *pats, **opts):
388 388 '''interactively record a new patch
389 389
390 390 See 'hg help qnew' & 'hg help record' for more information and usage.
391 391 '''
392 392
393 393 try:
394 394 mq = extensions.find('mq')
395 395 except KeyError:
396 396 raise util.Abort(_("'mq' extension not loaded"))
397 397
398 398 def qrecord_committer(ui, repo, pats, opts):
399 399 mq.new(ui, repo, patch, *pats, **opts)
400 400
401 401 opts = opts.copy()
402 402 opts['force'] = True # always 'qnew -f'
403 403 dorecord(ui, repo, qrecord_committer, *pats, **opts)
404 404
405 405
406 406 def dorecord(ui, repo, committer, *pats, **opts):
407 407 if not ui.interactive():
408 408 raise util.Abort(_('running non-interactively, use commit instead'))
409 409
410 410 def recordfunc(ui, repo, message, match, opts):
411 411 """This is generic record driver.
412 412
413 It's job is to interactively filter local changes, and accordingly
413 Its job is to interactively filter local changes, and accordingly
414 414 prepare working dir into a state, where the job can be delegated to
415 415 non-interactive commit command such as 'commit' or 'qrefresh'.
416 416
417 417 After the actual job is done by non-interactive command, working dir
418 418 state is restored to original.
419 419
420 420 In the end we'll record intresting changes, and everything else will be
421 421 left in place, so the user can continue his work.
422 422 """
423 423
424 424 changes = repo.status(match=match)[:3]
425 425 diffopts = mdiff.diffopts(git=True, nodates=True)
426 426 chunks = patch.diff(repo, changes=changes, opts=diffopts)
427 427 fp = cStringIO.StringIO()
428 428 fp.write(''.join(chunks))
429 429 fp.seek(0)
430 430
431 431 # 1. filter patch, so we have intending-to apply subset of it
432 432 chunks = filterpatch(ui, parsepatch(fp))
433 433 del fp
434 434
435 435 contenders = set()
436 436 for h in chunks:
437 437 try: contenders.update(set(h.files()))
438 438 except AttributeError: pass
439 439
440 440 changed = changes[0] + changes[1] + changes[2]
441 441 newfiles = [f for f in changed if f in contenders]
442 442 if not newfiles:
443 443 ui.status(_('no changes to record\n'))
444 444 return 0
445 445
446 446 modified = set(changes[0])
447 447
448 448 # 2. backup changed files, so we can restore them in the end
449 449 backups = {}
450 450 backupdir = repo.join('record-backups')
451 451 try:
452 452 os.mkdir(backupdir)
453 453 except OSError, err:
454 454 if err.errno != errno.EEXIST:
455 455 raise
456 456 try:
457 457 # backup continues
458 458 for f in newfiles:
459 459 if f not in modified:
460 460 continue
461 461 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
462 462 dir=backupdir)
463 463 os.close(fd)
464 464 ui.debug(_('backup %r as %r\n') % (f, tmpname))
465 465 util.copyfile(repo.wjoin(f), tmpname)
466 466 backups[f] = tmpname
467 467
468 468 fp = cStringIO.StringIO()
469 469 for c in chunks:
470 470 if c.filename() in backups:
471 471 c.write(fp)
472 472 dopatch = fp.tell()
473 473 fp.seek(0)
474 474
475 475 # 3a. apply filtered patch to clean repo (clean)
476 476 if backups:
477 477 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
478 478
479 479 # 3b. (apply)
480 480 if dopatch:
481 481 try:
482 482 ui.debug(_('applying patch\n'))
483 483 ui.debug(fp.getvalue())
484 484 pfiles = {}
485 485 patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
486 486 eolmode=None)
487 487 patch.updatedir(ui, repo, pfiles)
488 488 except patch.PatchError, err:
489 489 s = str(err)
490 490 if s:
491 491 raise util.Abort(s)
492 492 else:
493 493 raise util.Abort(_('patch failed to apply'))
494 494 del fp
495 495
496 496 # 4. We prepared working directory according to filtered patch.
497 497 # Now is the time to delegate the job to commit/qrefresh or the like!
498 498
499 499 # it is important to first chdir to repo root -- we'll call a
500 500 # highlevel command with list of pathnames relative to repo root
501 501 cwd = os.getcwd()
502 502 os.chdir(repo.root)
503 503 try:
504 504 committer(ui, repo, newfiles, opts)
505 505 finally:
506 506 os.chdir(cwd)
507 507
508 508 return 0
509 509 finally:
510 510 # 5. finally restore backed-up files
511 511 try:
512 512 for realname, tmpname in backups.iteritems():
513 513 ui.debug(_('restoring %r to %r\n') % (tmpname, realname))
514 514 util.copyfile(tmpname, repo.wjoin(realname))
515 515 os.unlink(tmpname)
516 516 os.rmdir(backupdir)
517 517 except OSError:
518 518 pass
519 519 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
520 520
521 521 cmdtable = {
522 522 "record":
523 523 (record,
524 524
525 525 # add commit options
526 526 commands.table['^commit|ci'][1],
527 527
528 528 _('hg record [OPTION]... [FILE]...')),
529 529 }
530 530
531 531
532 532 def extsetup():
533 533 try:
534 534 mq = extensions.find('mq')
535 535 except KeyError:
536 536 return
537 537
538 538 qcmdtable = {
539 539 "qrecord":
540 540 (qrecord,
541 541
542 542 # add qnew options, except '--force'
543 543 [opt for opt in mq.cmdtable['qnew'][1] if opt[1] != 'force'],
544 544
545 545 _('hg qrecord [OPTION]... PATCH [FILE]...')),
546 546 }
547 547
548 548 cmdtable.update(qcmdtable)
549 549
@@ -1,140 +1,140
1 1 # changegroup.py - Mercurial changegroup manipulation functions
2 2 #
3 3 # Copyright 2006 Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2, incorporated herein by reference.
7 7
8 8 from i18n import _
9 9 import util
10 10 import struct, os, bz2, zlib, tempfile
11 11
12 12 def getchunk(source):
13 13 """get a chunk from a changegroup"""
14 14 d = source.read(4)
15 15 if not d:
16 16 return ""
17 17 l = struct.unpack(">l", d)[0]
18 18 if l <= 4:
19 19 return ""
20 20 d = source.read(l - 4)
21 21 if len(d) < l - 4:
22 22 raise util.Abort(_("premature EOF reading chunk"
23 23 " (got %d bytes, expected %d)")
24 24 % (len(d), l - 4))
25 25 return d
26 26
27 27 def chunkiter(source):
28 28 """iterate through the chunks in source"""
29 29 while 1:
30 30 c = getchunk(source)
31 31 if not c:
32 32 break
33 33 yield c
34 34
35 35 def chunkheader(length):
36 36 """build a changegroup chunk header"""
37 37 return struct.pack(">l", length + 4)
38 38
39 39 def closechunk():
40 40 return struct.pack(">l", 0)
41 41
42 42 class nocompress(object):
43 43 def compress(self, x):
44 44 return x
45 45 def flush(self):
46 46 return ""
47 47
48 48 bundletypes = {
49 49 "": ("", nocompress),
50 50 "HG10UN": ("HG10UN", nocompress),
51 51 "HG10BZ": ("HG10", lambda: bz2.BZ2Compressor()),
52 52 "HG10GZ": ("HG10GZ", lambda: zlib.compressobj()),
53 53 }
54 54
55 # hgweb uses this list to communicate it's preferred type
55 # hgweb uses this list to communicate its preferred type
56 56 bundlepriority = ['HG10GZ', 'HG10BZ', 'HG10UN']
57 57
58 58 def writebundle(cg, filename, bundletype):
59 59 """Write a bundle file and return its filename.
60 60
61 61 Existing files will not be overwritten.
62 62 If no filename is specified, a temporary file is created.
63 63 bz2 compression can be turned off.
64 64 The bundle file will be deleted in case of errors.
65 65 """
66 66
67 67 fh = None
68 68 cleanup = None
69 69 try:
70 70 if filename:
71 71 fh = open(filename, "wb")
72 72 else:
73 73 fd, filename = tempfile.mkstemp(prefix="hg-bundle-", suffix=".hg")
74 74 fh = os.fdopen(fd, "wb")
75 75 cleanup = filename
76 76
77 77 header, compressor = bundletypes[bundletype]
78 78 fh.write(header)
79 79 z = compressor()
80 80
81 81 # parse the changegroup data, otherwise we will block
82 82 # in case of sshrepo because we don't know the end of the stream
83 83
84 84 # an empty chunkiter is the end of the changegroup
85 85 # a changegroup has at least 2 chunkiters (changelog and manifest).
86 86 # after that, an empty chunkiter is the end of the changegroup
87 87 empty = False
88 88 count = 0
89 89 while not empty or count <= 2:
90 90 empty = True
91 91 count += 1
92 92 for chunk in chunkiter(cg):
93 93 empty = False
94 94 fh.write(z.compress(chunkheader(len(chunk))))
95 95 pos = 0
96 96 while pos < len(chunk):
97 97 next = pos + 2**20
98 98 fh.write(z.compress(chunk[pos:next]))
99 99 pos = next
100 100 fh.write(z.compress(closechunk()))
101 101 fh.write(z.flush())
102 102 cleanup = None
103 103 return filename
104 104 finally:
105 105 if fh is not None:
106 106 fh.close()
107 107 if cleanup is not None:
108 108 os.unlink(cleanup)
109 109
110 110 def unbundle(header, fh):
111 111 if header == 'HG10UN':
112 112 return fh
113 113 elif not header.startswith('HG'):
114 114 # old client with uncompressed bundle
115 115 def generator(f):
116 116 yield header
117 117 for chunk in f:
118 118 yield chunk
119 119 elif header == 'HG10GZ':
120 120 def generator(f):
121 121 zd = zlib.decompressobj()
122 122 for chunk in f:
123 123 yield zd.decompress(chunk)
124 124 elif header == 'HG10BZ':
125 125 def generator(f):
126 126 zd = bz2.BZ2Decompressor()
127 127 zd.decompress("BZ")
128 128 for chunk in util.filechunkiter(f, 4096):
129 129 yield zd.decompress(chunk)
130 130 return util.chunkbuffer(generator(fh))
131 131
132 132 def readbundle(fh, fname):
133 133 header = fh.read(6)
134 134 if not header.startswith('HG'):
135 135 raise util.Abort(_('%s: not a Mercurial bundle file') % fname)
136 136 if not header.startswith('HG10'):
137 137 raise util.Abort(_('%s: unknown bundle version') % fname)
138 138 elif header not in bundletypes:
139 139 raise util.Abort(_('%s: unknown bundle compression type') % fname)
140 140 return unbundle(header, fh)
General Comments 0
You need to be logged in to leave comments. Login now