#!/usr/bin/env python
# -*- coding: latin-1 -*-

# <-- removing this magic comment breaks Python 3.4 on Windows
"""
1. Dump binary data to the following text format:

00000000: 00 00 00 5B 68 65 78 64  75 6D 70 5D 00 00 00 00  ...[hexdump]....
00000010: 00 11 22 33 44 55 66 77  88 99 AA BB CC DD EE FF  .."3DUfw........

It is similar to the one used by:
Scapy
00 00 00 5B 68 65 78 64 75 6D 70 5D 00 00 00 00  ...[hexdump]....
00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF  .."3DUfw........

Far Manager
000000000: 00 00 00 5B 68 65 78 64  75 6D 70 5D 00 00 00 00     [hexdump]
000000010: 00 11 22 33 44 55 66 77  88 99 AA BB CC DD EE FF   ?"3DUfw


2. Restore binary data from the formats above as well
   as from less exotic strings of raw hex

"""

__version__ = '3.3'
__author__  = 'anatoly techtonik <techtonik@gmail.com>'
__license__ = 'Public Domain'

__history__ = \
"""
3.3 (2015-01-22)
 * accept input from sys.stdin if "-" is specified
   for both dump and restore (issue #1)
 * new normalize_py() helper to set sys.stdout to
   binary mode on Windows

3.2 (2015-07-02)
 * hexdump is now packaged as .zip on all platforms
   (on Linux created archive was tar.gz)
 * .zip is executable! try `python hexdump-3.2.zip`
 * dump() now accepts configurable separator, patch
   by Ian Land (PR #3)

3.1 (2014-10-20)
 * implemented workaround against mysterious coding
   issue with Python 3 (see revision 51302cf)
 * fix Python 3 installs for systems where UTF-8 is
   not default (Windows), thanks to George Schizas
   (the problem was caused by reading of README.txt)

3.0 (2014-09-07)
 * remove unused int2byte() helper
 * add dehex(text) helper to convert hex string
   to binary data
 * add 'size' argument to dump() helper to specify
   length of chunks

2.0 (2014-02-02)
 * add --restore option to command line mode to get
   binary data back from hex dump
 * support saving test output with `--test logfile`
 * restore() from hex strings without spaces
 * restore() now raises TypeError if input data is
   not string
 * hexdump() and dumpgen() now don't return unicode
   strings in Python 2.x when generator is requested

1.0 (2013-12-30)
 * length of address is reduced from 10 to 8
 * hexdump() got new 'result' keyword argument, it
   can be either 'print', 'generator' or 'return'
 * actual dumping logic is now in new dumpgen()
   generator function
 * new dump(binary) function that takes binary data
   and returns string like "66 6F 72 6D 61 74"
 * new genchunks(mixed, size) function that chunks
   both sequences and file like objects

0.5 (2013-06-10)
 * hexdump is now also a command line utility (no
   restore yet)

0.4 (2013-06-09)
 * fix installation with Python 3 for non English
   versions of Windows, thanks to George Schizas

0.3 (2013-04-29)
 * fully Python 3 compatible

0.2 (2013-04-28)
 * restore() to recover binary data from a hex dump in
   native, Far Manager and Scapy text formats (others
   might work as well)
 * restore() is Python 3 compatible

0.1 (2013-04-28)
 * working hexdump() function for Python 2
"""

import binascii  # binascii is required for Python 3
import sys

# --- constants
PY3K = sys.version_info >= (3, 0)

# --- workaround against Python consistency issues
def normalize_py():
  ''' Problem 001 - sys.stdout in Python is by default opened in
      text mode, and writes to this stdout produce corrupted binary
      data on Windows

          python -c "import sys; sys.stdout.write('_\n_')" > file
          python -c "print(repr(open('file', 'rb').read()))"
  '''
  if sys.platform == "win32":
    # set sys.stdout to binary mode on Windows
    import os, msvcrt
    msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)

# --- - chunking helpers
def chunks(seq, size):
  '''Generator that cuts sequence (bytes, memoryview, etc.)
     into chunks of given size. If `seq` length is not multiply
     of `size`, the lengh of the last chunk returned will be
     less than requested.

     >>> list( chunks([1,2,3,4,5,6,7], 3) )
     [[1, 2, 3], [4, 5, 6], [7]]
  '''
  d, m = divmod(len(seq), size)
  for i in range(d):
    yield seq[i*size:(i+1)*size]
  if m:
    yield seq[d*size:]

def chunkread(f, size):
  '''Generator that reads from file like object. May return less
     data than requested on the last read.'''
  c = f.read(size)
  while len(c):
    yield c
    c = f.read(size)

def genchunks(mixed, size):
  '''Generator to chunk binary sequences or file like objects.
     The size of the last chunk returned may be less than
     requested.'''
  if hasattr(mixed, 'read'):
    return chunkread(mixed, size)
  else:
    return chunks(mixed, size)
# --- - /chunking helpers


def dehex(hextext):
  """
  Convert from hex string to binary data stripping
  whitespaces from `hextext` if necessary.
  """
  if PY3K:
    return bytes.fromhex(hextext)
  else:
    hextext = "".join(hextext.split())
    return hextext.decode('hex')

def dump(binary, size=2, sep=' '):
  '''
  Convert binary data (bytes in Python 3 and str in
  Python 2) to hex string like '00 DE AD BE EF'.
  `size` argument specifies length of text chunks
  and `sep` sets chunk separator.
  '''
  hexstr = binascii.hexlify(binary)
  if PY3K:
    hexstr = hexstr.decode('ascii')
  return sep.join(chunks(hexstr.upper(), size))

def dumpgen(data):
  '''
  Generator that produces strings:

  '00000000: 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................'
  '''
  generator = genchunks(data, 16)
  for addr, d in enumerate(generator):
    # 00000000:
    line = '%08X: ' % (addr*16)
    # 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00 
    dumpstr = dump(d)
    line += dumpstr[:8*3]
    if len(d) > 8:  # insert separator if needed
      line += ' ' + dumpstr[8*3:]
    # ................
    # calculate indentation, which may be different for the last line
    pad = 2
    if len(d) < 16:
      pad += 3*(16 - len(d))
    if len(d) <= 8:
      pad += 1
    line += ' '*pad

    for byte in d:
      # printable ASCII range 0x20 to 0x7E
      if not PY3K:
        byte = ord(byte)
      if 0x20 <= byte <= 0x7E:
        line += chr(byte)
      else:
        line += '.'
    yield line
  
def hexdump(data, result='print'):
  '''
  Transform binary data to the hex dump text format:

  00000000: 00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................

    [x] data argument as a binary string
    [x] data argument as a file like object

  Returns result depending on the `result` argument:
    'print'     - prints line by line
    'return'    - returns single string
    'generator' - returns generator that produces lines
  '''
  if PY3K and type(data) == str:
    raise TypeError('Abstract unicode data (expected bytes sequence)')

  gen = dumpgen(data)
  if result == 'generator':
    return gen
  elif result == 'return':
    return '\n'.join(gen)
  elif result == 'print':
    for line in gen:
      print(line)
  else:
    raise ValueError('Unknown value of `result` argument')

def restore(dump):
  '''
  Restore binary data from a hex dump.
    [x] dump argument as a string
    [ ] dump argument as a line iterator

  Supported formats:
    [x] hexdump.hexdump
    [x] Scapy
    [x] Far Manager
  '''
  minhexwidth = 2*16    # minimal width of the hex part - 00000... style
  bytehexwidth = 3*16-1 # min width for a bytewise dump - 00 00 ... style

  result = bytes() if PY3K else ''
  if type(dump) != str:
    raise TypeError('Invalid data for restore')

  text = dump.strip()  # ignore surrounding empty lines
  for line in text.split('\n'):
    # strip address part
    addrend = line.find(':')
    if 0 < addrend < minhexwidth:  # : is not in ascii part
      line = line[addrend+1:]
    line = line.lstrip()
    # check dump type
    if line[2] == ' ':  # 00 00 00 ...  type of dump
      # check separator
      sepstart = (2+1)*7+2  # ('00'+' ')*7+'00'
      sep = line[sepstart:sepstart+3]
      if sep[:2] == '  ' and sep[2:] != ' ':  # ...00 00  00 00...
        hexdata = line[:bytehexwidth+1]
      elif sep[2:] == ' ':  # ...00 00 | 00 00...  - Far Manager
        hexdata = line[:sepstart] + line[sepstart+3:bytehexwidth+2]
      else:                 # ...00 00 00 00... - Scapy, no separator
        hexdata = line[:bytehexwidth]
      line = hexdata
    result += dehex(line)
  return result


def runtest(logfile=None):
  '''Run hexdump tests. Requires hexfile.bin to be in the same
     directory as hexdump.py itself'''

  class TeeOutput(object):
    def __init__(self, stream1, stream2):
      self.outputs = [stream1, stream2]

    # -- methods from sys.stdout / sys.stderr
    def write(self, data):
      for stream in self.outputs:
        if PY3K:
          if 'b' in stream.mode:
            data = data.encode('utf-8')
        stream.write(data)
        stream.flush()

    def tell(self):
      raise IOError

    def flush(self):
      for stream in self.outputs:
        stream.flush()
    # --/ sys.stdout

  if logfile:
    openlog = open(logfile, 'wb')
    # copy stdout and stderr streams to log file
    savedstd = sys.stderr, sys.stdout
    sys.stderr = TeeOutput(sys.stderr, openlog)
    sys.stdout = TeeOutput(sys.stdout, openlog)
    

  def echo(msg, linefeed=True):
    sys.stdout.write(msg)
    if linefeed:
      sys.stdout.write('\n')

  expected = '''\
00000000: 00 00 00 5B 68 65 78 64  75 6D 70 5D 00 00 00 00  ...[hexdump]....
00000010: 00 11 22 33 44 55 66 77  88 99 0A BB CC DD EE FF  .."3DUfw........\
'''

  # get path to hexfile.bin
  # this doesn't work from .zip
  #   import os.path as osp
  #   hexfile = osp.dirname(osp.abspath(__file__)) + '/hexfile.bin'
  # this doesn't work either
  #   hexfile = osp.dirname(sys.modules[__name__].__file__) + '/hexfile.bin'
  # this works
  import pkgutil
  bin = pkgutil.get_data('hexdump', 'data/hexfile.bin')

  # varios length of input data
  hexdump(b'zzzz'*12)
  hexdump(b'o'*17)
  hexdump(b'p'*24)
  hexdump(b'q'*26)
  # allowable character set filter
  hexdump(b'line\nfeed\r\ntest')
  hexdump(b'\x00\x00\x00\x5B\x68\x65\x78\x64\x75\x6D\x70\x5D\x00\x00\x00\x00'
          b'\x00\x11\x22\x33\x44\x55\x66\x77\x88\x99\x0A\xBB\xCC\xDD\xEE\xFF')
  print('---')
  # dumping file-like binary object to screen (default behavior)
  hexdump(bin)
  print('return output')
  hexout = hexdump(bin, result='return')
  assert hexout == expected, 'returned hex didn\'t match'
  print('return generator')
  hexgen = hexdump(bin, result='generator')
  assert next(hexgen) == expected.split('\n')[0], 'hex generator 1 didn\'t match'
  assert next(hexgen) == expected.split('\n')[1], 'hex generator 2 didn\'t match'

  # binary restore test
  bindata = restore(
'''
00000000: 00 00 00 5B 68 65 78 64  75 6D 70 5D 00 00 00 00  ...[hexdump]....
00000010: 00 11 22 33 44 55 66 77  88 99 0A BB CC DD EE FF  .."3DUfw........
''')
  echo('restore check ', linefeed=False)
  assert bin == bindata, 'restore check failed'
  echo('passed')

  far = \
'''
000000000: 00 00 00 5B 68 65 78 64  75 6D 70 5D 00 00 00 00     [hexdump]
000000010: 00 11 22 33 44 55 66 77  88 99 0A BB CC DD EE FF   ?"3DUfw
'''
  echo('restore far format ', linefeed=False)
  assert bin == restore(far), 'far format check failed'
  echo('passed')

  scapy = '''\
00 00 00 5B 68 65 78 64 75 6D 70 5D 00 00 00 00  ...[hexdump]....
00 11 22 33 44 55 66 77 88 99 0A BB CC DD EE FF  .."3DUfw........
'''
  echo('restore scapy format ', linefeed=False)
  assert bin == restore(scapy), 'scapy format check failed'
  echo('passed')

  if not PY3K:
    assert restore('5B68657864756D705D') == '[hexdump]', 'no space check failed'
    assert dump('\\\xa1\xab\x1e', sep='').lower() == '5ca1ab1e'
  else:
    assert restore('5B68657864756D705D') == b'[hexdump]', 'no space check failed'
    assert dump(b'\\\xa1\xab\x1e', sep='').lower() == '5ca1ab1e'

  print('---[test file hexdumping]---')

  import os
  import tempfile
  hexfile = tempfile.NamedTemporaryFile(delete=False)
  try:
    hexfile.write(bin)
    hexfile.close()
    hexdump(open(hexfile.name, 'rb'))
  finally:
    os.remove(hexfile.name)
  if logfile:
    sys.stderr, sys.stdout = savedstd
    openlog.close()


def main():
  from optparse import OptionParser
  parser = OptionParser(usage='''
  %prog [binfile|-]
  %prog -r hexfile
  %prog --test [logfile]''', version=__version__)
  parser.add_option('-r', '--restore', action='store_true',
                                       help='restore binary from hex dump')
  parser.add_option('--test', action='store_true', help='run hexdump sanity checks')

  options, args = parser.parse_args()

  if options.test:
    if args:
      runtest(logfile=args[0])
    else:
      runtest()
  elif not args or len(args) > 1:
    parser.print_help()
    sys.exit(-1)
  else:
    ## dump file
    if not options.restore:
      # [x] memory effective dump
      if args[0] == '-':
        if not PY3K:
          hexdump(sys.stdin)
        else:
          hexdump(sys.stdin.buffer)
      else:
        hexdump(open(args[0], 'rb'))

    ## restore file
    else:
      # prepare input stream
      if args[0] == '-':
        instream = sys.stdin
      else:
        if PY3K:
          instream = open(args[0])
        else:
          instream = open(args[0], 'rb')

      # output stream
      # [ ] memory efficient restore
      if PY3K:
        sys.stdout.buffer.write(restore(instream.read()))
      else:
        # Windows - binary mode for sys.stdout to prevent data corruption
        normalize_py()
        sys.stdout.write(restore(instream.read()))

if __name__ == '__main__':
  main()

# [x] file restore from command line utility
# [ ] write dump with LF on Windows for consistency
# [ ] encoding param for hexdump()ing Python 3 str if anybody requests that

# [ ] document chunking API
# [ ] document hexdump API
# [ ] blog about sys.stdout text mode problem on Windows
