pax_global_header00006660000000000000000000000064121641215470014515gustar00rootroot0000000000000052 comment=4805c15a3c858798dbe4e40393fca679fa850768 arpy-1.1.1/000077500000000000000000000000001216412154700124705ustar00rootroot00000000000000arpy-1.1.1/.gitignore000066400000000000000000000001051216412154700144540ustar00rootroot00000000000000__pycache__ *.pyc *.sw[op] *,cover .tox .coverage arpy.egg-info dist arpy-1.1.1/.hgignore000066400000000000000000000001011216412154700142630ustar00rootroot00000000000000syntax: glob *.pyc *.sw[op] *,cover .tox .coverage arpy.egg-info arpy-1.1.1/.travis.yml000066400000000000000000000001241216412154700145760ustar00rootroot00000000000000language: python python: - "2.6" - "2.7" - "3.3" - "pypy" script: nosetests arpy-1.1.1/MANIFEST000066400000000000000000000000211216412154700136120ustar00rootroot00000000000000arpy.py setup.py arpy-1.1.1/MANIFEST.in000066400000000000000000000000221216412154700142200ustar00rootroot00000000000000include test/*.ar arpy-1.1.1/README.md000066400000000000000000000021351216412154700137500ustar00rootroot00000000000000Arpy ==== This library can be used to access **ar** files from python. It's tested to work with python 2.6, 2.7, 3.3 and pypy. Travis status: [![Build Status](https://travis-ci.org/viraptor/arpy.png)](https://travis-ci.org/viraptor/arpy) It supports both GNU and BSD formats and exposes the archived files using the standard python **file** interface. Usage ===== Standard file usage: -------------------- ar = arpy.Archive('file.ar')) ar.read_all_headers() # check all available files ar.archived_files.keys() # get the contents of the archived file ar.archived_files[b'some_file'].read() Stream / pipe / ... usage: -------------------------- ar = arpy.Archive('file.ar')) for f in ar: print("got file name: %s" % f.header.name) print("with contents: %s" % f.read()) Contributions ============= All contributions welcome. Just make sure that: * tests are provided * all current platforms are passing (tox configuration is provided) * coverage is close to 100% (currently only missing statements are those depending on python version being used) arpy-1.1.1/arpy.py000066400000000000000000000233621216412154700140230ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright 2011 Stanisław Pitucha. All rights reserved. # Copyright 2013 Helmut Grohne. All rights reserved. # # Redistribution and use in source and binary forms, with or without modification, are # permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, this list of # conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, this list # of conditions and the following disclaimer in the documentation and/or other materials # provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY Stanisław Pitucha ``AS IS'' AND ANY EXPRESS OR IMPLIED # WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND # FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Stanisław Pitucha OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF # ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # The views and conclusions contained in the software and documentation are those of the # authors and should not be interpreted as representing official policies, either expressed # or implied, of Stanisław Pitucha. # """ arpy module can be used for reading `ar` files' headers, as well as accessing the data contained in the archive. Archived files are accessible via file-like objects. Support for both GNU and BSD extended length filenames is included. In order to read the file, create a new proxy with: ar = arpy.Archive('some_ar_file') ar.read_all_headers() The list of file names can be listed through: ar.archived_files.keys() Files themselves can be opened by getting the value of: f = ar.archived_files['filename'] and read through: f.read([length]) random access through seek and tell functions is supported on the archived files """ HEADER_BSD = 1 HEADER_GNU = 2 HEADER_GNU_TABLE = 3 HEADER_GNU_SYMBOLS = 4 HEADER_NORMAL = 5 HEADER_TYPES = { HEADER_BSD: 'BSD', HEADER_GNU: 'GNU', HEADER_GNU_TABLE: 'GNU_TABLE', HEADER_GNU_SYMBOLS: 'GNU_SYMBOLS', HEADER_NORMAL: 'NORMAL'} GLOBAL_HEADER_LEN = 8 HEADER_LEN = 60 class ArchiveFormatError(Exception): """ Raised on problems with parsing the archive headers """ pass class ArchiveAccessError(IOError): """ Raised on problems with accessing the archived files """ pass class ArchiveFileHeader(object): """ File header of an archived file, or a special data segment """ def __init__(self, header, offset): """ Creates a new header from binary data starting at a specified offset """ import struct name, timestamp, uid, gid, mode, size, magic = struct.unpack( "16s 12s 6s 6s 8s 10s 2s", header) if magic != b"\x60\x0a": raise ArchiveFormatError("file header magic doesn't match") if name.startswith(b"#1/"): self.type = HEADER_BSD elif name.startswith(b"//"): self.type = HEADER_GNU_TABLE elif name.strip() == b"/": self.type = HEADER_GNU_SYMBOLS elif name.startswith(b"/"): self.type = HEADER_GNU else: self.type = HEADER_NORMAL try: self.size = int(size) if self.type in (HEADER_NORMAL, HEADER_BSD, HEADER_GNU): self.timestamp = int(timestamp) self.uid = int(uid) self.gid = int(gid) self.mode = int(mode, 8) except ValueError as err: raise ArchiveFormatError( "cannot convert file header fields to integers", err) self.offset = offset name = name.rstrip() if len(name) > 1: name = name.rstrip(b'/') if self.type == HEADER_NORMAL: self.name = name self.file_offset = offset + HEADER_LEN else: self.name = None self.proxy_name = name self.file_offset = None def __repr__(self): """ Creates a human-readable summary of a header """ return '''''' % (self.name, HEADER_TYPES[self.type], self.size) class ArchiveFileData(object): """ File-like object used for reading an archived file """ def __init__(self, ar_obj, header): """ Creates a new proxy for the archived file, reusing the archive's file descriptor """ self.header = header self.arobj = ar_obj self.last_offset = 0 def read(self, size = None): """ Reads the data from the archived file, simulates file.read """ if size is None: size = self.header.size if self.header.size < self.last_offset + size: size = self.header.size - self.last_offset self.arobj._seek(self.header.file_offset + self.last_offset) data = self.arobj._read(size) if len(data) < size: raise ArchiveAccessError("incorrect archive file") self.last_offset += size return data def tell(self): """ Returns the position in archived file, simulates file.tell """ return self.last_offset def seek(self, offset, whence = 0): """ Sets the position in archived file, simulates file.seek """ if whence == 0: pass # absolute elif whence == 1: offset += self.last_offset elif whence == 2: offset += self.header.size else: raise ArchiveAccessError("invalid argument") if offset < 0 or offset > self.header.size: raise ArchiveAccessError("incorrect file position") self.last_offset = offset class Archive(object): """ Archive object allowing reading of *.ar files """ def __init__(self, filename=None, fileobj=None): self.headers = [] self.file = fileobj or open(filename, "rb") self.position = 0 self._detect_seekable() if self._read(GLOBAL_HEADER_LEN) != b"!\n": raise ArchiveFormatError("file is missing the global header") self.next_header_offset = GLOBAL_HEADER_LEN self.gnu_table = None self.archived_files = {} def _detect_seekable(self): if hasattr(self.file, 'seekable'): self.seekable = self.file.seekable() else: try: # .tell() will raise an exception as well self.file.tell() self.seekable = True except: self.seekable = False def _read(self, length): data = self.file.read(length) self.position += len(data) return data def _seek(self, offset): if self.seekable: self.file.seek(offset) self.position = self.file.tell() elif offset < self.position: raise ArchiveAccessError("cannot go back when reading archive from a stream") else: # emulate seek while self.position < offset: if not self._read(min(4096, offset - self.position)): # reached EOF before target offset return def __read_file_header(self, offset): """ Reads and returns a single new file header """ self._seek(offset) header = self._read(HEADER_LEN) if len(header) == 0: return None if len(header) < HEADER_LEN: raise ArchiveFormatError("file header too short") file_header = ArchiveFileHeader(header, offset) if file_header.type == HEADER_GNU_TABLE: self.__read_gnu_table(file_header.size) add_len = self.__fix_name(file_header) file_header.file_offset = offset + HEADER_LEN + add_len if offset == self.next_header_offset: new_offset = file_header.file_offset + file_header.size self.next_header_offset = Archive.__pad2(new_offset) return file_header def __read_gnu_table(self, size): """ Reads the table of filenames specific to GNU ar format """ table_string = self._read(size) if len(table_string) != size: raise ArchiveFormatError("file too short to fit the names table") self.gnu_table = {} position = 0 for filename in table_string.split(b"\n"): self.gnu_table[position] = filename[:-1] # remove trailing '/' position += len(filename) + 1 def __fix_name(self, header): """ Corrects the long filename using the format-specific method. That means either looking up the name in GNU filename table, or reading past the header in BSD ar files. """ if header.type == HEADER_NORMAL: pass elif header.type == HEADER_BSD: filename_len = Archive.__get_bsd_filename_len(header.proxy_name) # BSD format includes the filename in the file size header.size -= filename_len self._seek(header.offset + HEADER_LEN) header.name = self._read(filename_len) return filename_len elif header.type == HEADER_GNU_TABLE: header.name = "*GNU_TABLE*" elif header.type == HEADER_GNU: gnu_position = int(header.proxy_name[1:]) if gnu_position not in self.gnu_table: raise ArchiveFormatError("file references a name not present in the index") header.name = self.gnu_table[gnu_position] elif header.type == HEADER_GNU_SYMBOLS: pass return 0 @staticmethod def __pad2(num): """ Returns a 2-aligned offset """ if num % 2 == 0: return num else: return num+1 @staticmethod def __get_bsd_filename_len(name): """ Returns the length of the filename for a BSD style header """ filename_len = name[3:] return int(filename_len) def read_next_header(self): """ Reads a single new header, returning a its representation, or None at the end of file """ header = self.__read_file_header(self.next_header_offset) if header is not None: self.headers.append(header) if header.type in (HEADER_BSD, HEADER_NORMAL, HEADER_GNU): self.archived_files[header.name] = ArchiveFileData(self, header) return header def __next__(self): while True: header = self.read_next_header() if header is None: raise StopIteration if header.type in (HEADER_BSD, HEADER_NORMAL, HEADER_GNU): return self.archived_files[header.name] next = __next__ def __iter__(self): return self def read_all_headers(self): """ Reads all headers """ while self.read_next_header() is not None: pass def close(self): """ Closes the archive file descriptor """ self.file.close() arpy-1.1.1/setup.py000077500000000000000000000021441216412154700142060ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- from setuptools import setup setup(name='arpy', version='1.1.1', description='Library for accessing "ar" files', author=u'Stanisław Pitucha', author_email='viraptor@gmail.com', url='https://github.com/viraptor/arpy', py_modules=['arpy'], license="Simplified BSD", test_suite='test', long_description="""'arpy' is a library for accessing the archive files and reading the contents. It supports extended long filenames in both GNU and BSD format. Right now it does not support the symbol tables, but can ignore them gracefully. Usage instructions are included in the module docstring. Works with python >=2.6 and >=3.3, as well as pypy.""", classifiers=[ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: CPython", "Topic :: System :: Archiving", ] ) arpy-1.1.1/test/000077500000000000000000000000001216412154700134475ustar00rootroot00000000000000arpy-1.1.1/test/__init__.py000066400000000000000000000000001216412154700155460ustar00rootroot00000000000000arpy-1.1.1/test/bsd_mixed.ar000066400000000000000000000005461216412154700157360ustar00rootroot00000000000000! // 86 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ #1/84 1364081531 1000 100 100644 84 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_lengthshort 1364081531 1000 100 100644 0 ` arpy-1.1.1/test/bsd_multi_names.ar000066400000000000000000000010501216412154700171340ustar00rootroot00000000000000! // 184 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length_with_space / #1/84 1364081511 1000 100 100644 84 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length#1/96 1364081511 1000 100 100644 96 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length_with_space arpy-1.1.1/test/bsd_single_name.ar000066400000000000000000000004521216412154700171050ustar00rootroot00000000000000! // 86 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ #1/84 1364081456 1000 100 100644 84 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_lengtharpy-1.1.1/test/contents.ar000066400000000000000000000002401216412154700156240ustar00rootroot00000000000000! file1/ 1364071329 1000 100 100644 15 ` test_in_file_1 file2/ 1364071325 1000 100 100644 15 ` test_in_file_2 arpy-1.1.1/test/empty.ar000066400000000000000000000000101216412154700151200ustar00rootroot00000000000000! arpy-1.1.1/test/gnu_mixed.ar000066400000000000000000000004221216412154700157500ustar00rootroot00000000000000! // 86 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ /0 1297730011 1000 1000 100644 0 ` short/ 1297731567 1000 1000 100644 0 ` arpy-1.1.1/test/gnu_multi_names.ar000066400000000000000000000005641216412154700171660ustar00rootroot00000000000000! // 184 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length_with_space / /0 1297730011 1000 1000 100644 0 ` /86 1297731385 1000 1000 100644 0 ` arpy-1.1.1/test/gnu_single_name.ar000066400000000000000000000003261216412154700171260ustar00rootroot00000000000000! // 86 ` a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length/ /0 1297730011 1000 1000 100644 0 ` arpy-1.1.1/test/normal.ar000066400000000000000000000001041216412154700152560ustar00rootroot00000000000000! short/ 1297731967 1000 1000 100644 0 ` arpy-1.1.1/test/sym.ar000066400000000000000000000002041216412154700145770ustar00rootroot00000000000000! / 1364086461 0 0 0 4 ` a.o/ 1364086297 1000 100 100644 0 ` arpy-1.1.1/test/test_bsd.py000066400000000000000000000022651216412154700156350ustar00rootroot00000000000000import arpy import unittest, os class BSDExtendedNames(unittest.TestCase): def test_single_name(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'bsd_single_name.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length'], list(ar.archived_files.keys())) self.assertEqual(2, len(ar.headers)) ar.close() def test_multi_name_with_space(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'bsd_multi_names.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length', b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length_with_space '], sorted(ar.archived_files.keys())) self.assertEqual(3, len(ar.headers)) ar.close() def test_mixed_names(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'bsd_mixed.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length', b'short'], sorted(ar.archived_files.keys())) self.assertEqual(3, len(ar.headers)) ar.close() arpy-1.1.1/test/test_contents.py000066400000000000000000000060751216412154700167250ustar00rootroot00000000000000import arpy import unittest, os, io class ArContents(unittest.TestCase): def test_archive_contents(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'contents.ar')) ar.read_all_headers() f1_contents = ar.archived_files[b'file1'].read() f2_contents = ar.archived_files[b'file2'].read() self.assertEqual(b'test_in_file_1\n', f1_contents) self.assertEqual(b'test_in_file_2\n', f2_contents) ar.close() class ArContentsSeeking(unittest.TestCase): def setUp(self): self.ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'contents.ar')) self.ar.read_all_headers() self.f1 = self.ar.archived_files[b'file1'] def tearDown(self): self.ar.close() def test_content_opens_at_zero(self): self.assertEqual(0, self.f1.tell()) def test_seek_absolute(self): contents_before = self.f1.read() self.f1.seek(0) contents_after = self.f1.read() self.f1.seek(3) contents_shifted = self.f1.read() self.assertEqual(contents_before, contents_after) self.assertEqual(contents_before[3:], contents_shifted) def test_seek_relative(self): contents_before = self.f1.read() self.f1.seek(1) self.f1.seek(1, 1) contents_after = self.f1.read() self.assertEqual(contents_before[2:], contents_after) def test_seek_from_end(self): contents_before = self.f1.read() self.f1.seek(-4, 2) contents_after = self.f1.read() self.assertEqual(contents_before[-4:], contents_after) def test_seek_failure(self): self.assertRaises(arpy.ArchiveAccessError, self.f1.seek, 10, 10) def test_seek_position_failure(self): self.assertRaises(arpy.ArchiveAccessError, self.f1.seek, -1) class NonSeekableIO(io.BytesIO): def seek(self, *args): raise Exception("Not seekable") def seekable(self): return False def force_seek(self, *args): io.BytesIO.seek(self, *args) class ArContentsNoSeeking(unittest.TestCase): def setUp(self): big_archive = NonSeekableIO() big_archive.write(b"!\n") big_archive.write(b"file1/ 1364071329 1000 100 100644 5000 `\n") big_archive.write(b" "*5000) big_archive.write(b"file2/ 1364071329 1000 100 100644 2 `\n") big_archive.write(b"xx") big_archive.force_seek(0) self.big_archive = big_archive def test_stream_read(self): # make sure all contents can be read without seeking ar = arpy.Archive(fileobj=self.big_archive) f = ar.next() contents = f.read() self.assertEqual(b'file1', f.header.name) self.assertEqual(b' '*5000, contents) f = ar.next() contents = f.read() self.assertEqual(b'file2', f.header.name) self.assertEqual(b'xx', contents) ar.close() def test_stream_skip_file(self): # make sure skipping contents is possible without seeking ar = arpy.Archive(fileobj=self.big_archive) f = ar.next() self.assertEqual(b'file1', f.header.name) f = ar.next() contents = f.read() self.assertEqual(b'file2', f.header.name) self.assertEqual(b'xx', contents) ar.close() def test_seek_fail(self): ar = arpy.Archive(fileobj=self.big_archive) f1 = ar.next() f2 = ar.next() self.assertRaises(arpy.ArchiveAccessError, f1.read) ar.close() arpy-1.1.1/test/test_failures.py000066400000000000000000000032651216412154700167000ustar00rootroot00000000000000import arpy import io import unittest, os class SimpleNames(unittest.TestCase): def test_not_ar_file(self): self.assertRaises(arpy.ArchiveFormatError, arpy.Archive, fileobj=io.BytesIO(b'not an ar file')) def test_bad_file_header_magic(self): bad_ar = b'!\nfile1/ 1364071329 1000 100 100644 15 qq' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) self.assertRaises(arpy.ArchiveFormatError, ar.read_all_headers) def test_bad_file_header_short(self): bad_ar = b'!\nfile1/ 1364071329 1000' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) self.assertRaises(arpy.ArchiveFormatError, ar.read_all_headers) def test_bad_file_header_nums(self): bad_ar = b'!\nfile1/ aaaa071329 1000 100 100644 15 `\n' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) self.assertRaises(arpy.ArchiveFormatError, ar.read_all_headers) def test_bad_file_size(self): bad_ar = b'!\nfile1/ 1364071329 1000 100 100644 15 `\nabc' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) ar.read_all_headers() f1 = ar.archived_files[b'file1'] self.assertRaises(arpy.ArchiveAccessError, f1.read) def test_bad_table_size(self): bad_ar = b'!\n// 10 `\n' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) self.assertRaises(arpy.ArchiveFormatError, ar.read_all_headers) def test_bad_table_reference(self): bad_ar = b'!\n// 0 `\n' \ b'/9 1297730011 1000 1000 100644 0 `\n' ar = arpy.Archive(fileobj=io.BytesIO(bad_ar)) self.assertRaises(arpy.ArchiveFormatError, ar.read_all_headers) arpy-1.1.1/test/test_gnu.py000066400000000000000000000022651216412154700156560ustar00rootroot00000000000000import arpy import unittest, os class GNUExtendedNames(unittest.TestCase): def test_single_name(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'gnu_single_name.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length'], list(ar.archived_files.keys())) self.assertEqual(2, len(ar.headers)) ar.close() def test_multi_name_with_space(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'gnu_multi_names.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length', b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length_with_space '], sorted(ar.archived_files.keys())) self.assertEqual(3, len(ar.headers)) ar.close() def test_mixed_names(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'gnu_mixed.ar')) ar.read_all_headers() self.assertEqual([b'a_very_long_name_for_the_gnu_type_header_so_it_can_overflow_the_standard_name_length', b'short'], sorted(ar.archived_files.keys())) self.assertEqual(3, len(ar.headers)) ar.close() arpy-1.1.1/test/test_normal.py000066400000000000000000000033501216412154700163510ustar00rootroot00000000000000import arpy import io import unittest, os class SimpleNames(unittest.TestCase): def test_single_name(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'normal.ar')) ar.read_all_headers() self.assertEqual([b'short'], list(ar.archived_files.keys())) self.assertEqual(1, len(ar.headers)) ar.close() def test_header_description(self): ar = arpy.Archive(os.path.join(os.path.dirname(__file__), 'normal.ar')) header = ar.read_next_header() self.assertTrue(repr(header).startswith('