test_git_archive_all.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. # -*- coding: utf-8 -*-
  2. from __future__ import print_function
  3. from __future__ import unicode_literals
  4. from copy import deepcopy
  5. import errno
  6. from functools import partial
  7. import os
  8. from subprocess import check_call
  9. import sys
  10. from tarfile import TarFile, PAX_FORMAT
  11. import warnings
  12. import pycodestyle
  13. import pytest
  14. import git_archive_all
  15. from git_archive_all import GitArchiver, fspath
  16. def makedirs(p):
  17. try:
  18. os.makedirs(p)
  19. except OSError as e:
  20. if e.errno != errno.EEXIST:
  21. raise
  22. def as_posix(p):
  23. if sys.platform.startswith('win32'):
  24. return p.replace(b'\\', b'/') if isinstance(p, bytes) else p.replace('\\', '/')
  25. else:
  26. return p
  27. def os_path_join(*args):
  28. """
  29. Ensure that all path components are uniformly encoded.
  30. """
  31. return os.path.join(*(fspath(p) for p in args))
  32. @pytest.fixture
  33. def git_env(tmpdir_factory):
  34. """
  35. Return ENV git configured for tests:
  36. 1. Both system and user configs are ignored
  37. 2. Custom git user
  38. 3. .gitmodules file is ignored by default
  39. """
  40. e = {
  41. 'GIT_CONFIG_NOSYSTEM': 'true',
  42. 'HOME': tmpdir_factory.getbasetemp().strpath
  43. }
  44. with tmpdir_factory.getbasetemp().join('.gitconfig').open('wb+') as f:
  45. f.writelines([
  46. b'[core]\n',
  47. 'attributesfile = {0}\n'.format(as_posix(tmpdir_factory.getbasetemp().join('.gitattributes').strpath)).encode(),
  48. b'[user]\n',
  49. b'name = git-archive-all\n',
  50. b'email = git-archive-all@example.com\n',
  51. ])
  52. # .gitmodules's content is dynamic and is maintained by git.
  53. # It's therefore ignored solely to simplify tests.
  54. #
  55. # If test is run with the --no-exclude CLI option (or its exclude=False API equivalent)
  56. # then the file itself is included while its content is discarded for the same reason.
  57. with tmpdir_factory.getbasetemp().join('.gitattributes').open('wb+') as f:
  58. f.writelines([
  59. b'.gitmodules export-ignore\n'
  60. ])
  61. return e
  62. class Record:
  63. def __init__(self, kind, contents, excluded=False):
  64. self.kind = kind
  65. self.contents = contents
  66. self.excluded = excluded
  67. def __getitem__(self, item):
  68. return self.contents[item]
  69. def __setitem__(self, key, value):
  70. self.contents[key] = value
  71. FileRecord = partial(Record, 'file', excluded=False)
  72. DirRecord = partial(Record, 'dir', excluded=False)
  73. SubmoduleRecord = partial(Record, 'submodule', excluded=False)
  74. class Repo:
  75. def __init__(self, path):
  76. self.path = os.path.abspath(fspath(path))
  77. def init(self):
  78. os.mkdir(self.path)
  79. check_call(['git', 'init'], cwd=self.path)
  80. def add(self, rel_path, record):
  81. if record.kind == 'file':
  82. return self.add_file(rel_path, record.contents)
  83. elif record.kind == 'dir':
  84. return self.add_dir(rel_path, record.contents)
  85. elif record.kind == 'submodule':
  86. return self.add_submodule(rel_path, record.contents)
  87. else:
  88. raise ValueError
  89. def add_file(self, rel_path, contents):
  90. file_path = os_path_join(self.path, rel_path)
  91. with open(file_path, 'wb') as f:
  92. f.write(contents)
  93. check_call(['git', 'add', as_posix(os.path.normpath(file_path))], cwd=self.path)
  94. return file_path
  95. def add_dir(self, rel_path, contents):
  96. dir_path = os_path_join(self.path, rel_path)
  97. makedirs(dir_path)
  98. for k, v in contents.items():
  99. self.add(as_posix(os.path.normpath(os_path_join(dir_path, k))), v)
  100. check_call(['git', 'add', dir_path], cwd=self.path)
  101. return dir_path
  102. def add_submodule(self, rel_path, contents):
  103. submodule_path = os_path_join(self.path, rel_path)
  104. r = Repo(submodule_path)
  105. r.init()
  106. r.add_dir('.', contents)
  107. r.commit('init')
  108. check_call(['git', 'submodule', 'add', as_posix(os.path.normpath(submodule_path))], cwd=self.path)
  109. return submodule_path
  110. def commit(self, message):
  111. check_call(['git', 'commit', '-m', 'init'], cwd=self.path)
  112. def archive(self, path, exclude=True):
  113. a = GitArchiver(exclude=exclude, main_repo_abspath=self.path)
  114. a.create(path)
  115. def make_expected_tree(contents, exclude=True):
  116. e = {}
  117. for k, v in contents.items():
  118. if v.kind == 'file' and not (exclude and v.excluded):
  119. e[k] = v.contents
  120. elif v.kind in ('dir', 'submodule') and not (exclude and v.excluded):
  121. # See the comment in git_env.
  122. if v.kind == 'submodule' and not exclude:
  123. e['.gitmodules'] = None
  124. for nested_k, nested_v in make_expected_tree(v.contents, exclude).items():
  125. nested_k = as_posix(os_path_join(k, nested_k))
  126. e[nested_k] = nested_v
  127. return e
  128. def make_actual_tree(tar_file):
  129. a = {}
  130. for m in tar_file.getmembers():
  131. if m.isfile():
  132. name = fspath(m.name)
  133. # See the comment in git_env.
  134. if not name.endswith(fspath('.gitmodules')):
  135. a[name] = tar_file.extractfile(m).read()
  136. else:
  137. a[name] = None
  138. else:
  139. raise NotImplementedError
  140. return a
  141. base = {
  142. 'app': DirRecord({
  143. '__init__.py': FileRecord(b'#Beautiful is better than ugly.'),
  144. }),
  145. 'lib': SubmoduleRecord({
  146. '__init__.py': FileRecord(b'#Explicit is better than implicit.'),
  147. 'extra': SubmoduleRecord({
  148. '__init__.py': FileRecord(b'#Simple is better than complex.'),
  149. })
  150. })
  151. }
  152. base_quoted = deepcopy(base)
  153. base_quoted['data'] = DirRecord({
  154. '\"hello world.dat\"': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  155. '\'hello world.dat\'': FileRecord(b'Although practicality beats purity.')
  156. })
  157. ignore_in_root = deepcopy(base)
  158. ignore_in_root['.gitattributes'] = FileRecord(b'tests/__init__.py export-ignore')
  159. ignore_in_root['tests'] = DirRecord({
  160. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  161. })
  162. ignore_in_submodule = deepcopy(base)
  163. ignore_in_submodule['lib']['.gitattributes'] = FileRecord(b'tests/__init__.py export-ignore')
  164. ignore_in_submodule['lib']['tests'] = DirRecord({
  165. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  166. })
  167. ignore_in_nested_submodule = deepcopy(base)
  168. ignore_in_nested_submodule['lib']['extra']['.gitattributes'] = FileRecord(b'tests/__init__.py export-ignore')
  169. ignore_in_nested_submodule['lib']['extra']['tests'] = DirRecord({
  170. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  171. })
  172. ignore_in_submodule_from_root = deepcopy(base)
  173. ignore_in_submodule_from_root['.gitattributes'] = FileRecord(b'lib/tests/__init__.py export-ignore')
  174. ignore_in_submodule_from_root['lib']['tests'] = DirRecord({
  175. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  176. })
  177. ignore_in_nested_submodule_from_root = deepcopy(base)
  178. ignore_in_nested_submodule_from_root['.gitattributes'] = FileRecord(b'lib/extra/tests/__init__.py export-ignore')
  179. ignore_in_nested_submodule_from_root['lib']['extra']['tests'] = DirRecord({
  180. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  181. })
  182. ignore_in_nested_submodule_from_submodule = deepcopy(base)
  183. ignore_in_nested_submodule_from_submodule['lib']['.gitattributes'] = FileRecord(b'extra/tests/__init__.py export-ignore')
  184. ignore_in_nested_submodule_from_submodule['lib']['extra']['tests'] = DirRecord({
  185. '__init__.py': FileRecord(b'#Complex is better than complicated.', excluded=True)
  186. })
  187. unset_export_ignore = deepcopy(base)
  188. unset_export_ignore['.gitattributes'] = FileRecord(b'.* export-ignore\n*.htaccess -export-ignore', excluded=True)
  189. unset_export_ignore['.a'] = FileRecord(b'Flat is better than nested.', excluded=True)
  190. unset_export_ignore['.b'] = FileRecord(b'Sparse is better than dense.', excluded=True)
  191. unset_export_ignore['.htaccess'] = FileRecord(b'Readability counts.')
  192. unicode_base = deepcopy(base)
  193. unicode_base['data'] = DirRecord({
  194. 'مرحبا بالعالم.dat': FileRecord(b'Special cases aren\'t special enough to break the rules.')
  195. })
  196. unicode_quoted = deepcopy(base)
  197. unicode_quoted['data'] = DirRecord({
  198. '\"مرحبا بالعالم.dat\"': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  199. '\'привет мир.dat\'': FileRecord(b'Although practicality beats purity.')
  200. })
  201. brackets_base = deepcopy(base)
  202. brackets_base['data'] = DirRecord({
  203. '[.dat': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  204. '(.dat': FileRecord(b'Although practicality beats purity.'),
  205. '{.dat': FileRecord(b'Errors should never pass silently.'),
  206. '].dat': FileRecord(b'Unless explicitly silenced.'),
  207. ').dat': FileRecord(b'In the face of ambiguity, refuse the temptation to guess.'),
  208. '}.dat': FileRecord(b'There should be one-- and preferably only one --obvious way to do it.'),
  209. '[].dat': FileRecord(b'Although that way may not be obvious at first unless you\'re Dutch.'),
  210. '().dat': FileRecord(b'Now is better than never.'),
  211. '{}.dat': FileRecord(b'Although never is often better than *right* now.'),
  212. })
  213. brackets_quoted = deepcopy(base)
  214. brackets_quoted['data'] = DirRecord({
  215. '\"[.dat\"': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  216. '\'[.dat\'': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  217. '\"(.dat\"': FileRecord(b'Although practicality beats purity.'),
  218. '\'(.dat\'': FileRecord(b'Although practicality beats purity.'),
  219. '\"{.dat\"': FileRecord(b'Errors should never pass silently.'),
  220. '\'{.dat\'': FileRecord(b'Errors should never pass silently.'),
  221. '\"].dat\"': FileRecord(b'Unless explicitly silenced.'),
  222. '\'].dat\'': FileRecord(b'Unless explicitly silenced.'),
  223. '\").dat\"': FileRecord(b'In the face of ambiguity, refuse the temptation to guess.'),
  224. '\').dat\'': FileRecord(b'In the face of ambiguity, refuse the temptation to guess.'),
  225. '\"}.dat\"': FileRecord(b'There should be one-- and preferably only one --obvious way to do it.'),
  226. '\'}.dat\'': FileRecord(b'There should be one-- and preferably only one --obvious way to do it.'),
  227. '\"[].dat\"': FileRecord(b'Although that way may not be obvious at first unless you\'re Dutch.'),
  228. '\'[].dat\'': FileRecord(b'Although that way may not be obvious at first unless you\'re Dutch.'),
  229. '\"().dat\"': FileRecord(b'Now is better than never.'),
  230. '\'().dat\'': FileRecord(b'Now is better than never.'),
  231. '\"{}.dat\"': FileRecord(b'Although never is often better than *right* now.'),
  232. '\'{}.dat\'': FileRecord(b'Although never is often better than *right* now.'),
  233. })
  234. quote_base = deepcopy(base)
  235. quote_base['data'] = DirRecord({
  236. '\'.dat': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  237. '\".dat': FileRecord(b'Although practicality beats purity.'),
  238. })
  239. quote_quoted = deepcopy(base)
  240. quote_quoted['data'] = DirRecord({
  241. '\"\'.dat\"': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  242. '\'\'.dat\'': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  243. '\"\".dat\"': FileRecord(b'Although practicality beats purity.'),
  244. '\'\".dat\'': FileRecord(b'Although practicality beats purity.'),
  245. })
  246. nonunicode_base = deepcopy(base)
  247. nonunicode_base['data'] = DirRecord({
  248. b'test.\xc2': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  249. })
  250. nonunicode_quoted = deepcopy(base)
  251. nonunicode_quoted['data'] = DirRecord({
  252. b'\'test.\xc2\'': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  253. b'\"test.\xc2\"': FileRecord(b'Although practicality beats purity.'),
  254. })
  255. backslash_base = deepcopy(base)
  256. backslash_base['data'] = DirRecord({
  257. '\\.dat': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  258. })
  259. backslash_quoted = deepcopy(base)
  260. backslash_quoted['data'] = DirRecord({
  261. '\'\\.dat\'': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  262. '\"\\.dat\"': FileRecord(b'Although practicality beats purity.')
  263. })
  264. non_unicode_backslash_base = deepcopy(base)
  265. non_unicode_backslash_base['data'] = DirRecord({
  266. b'\\\xc2.dat': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  267. })
  268. non_unicode_backslash_quoted = deepcopy(base)
  269. non_unicode_backslash_quoted['data'] = DirRecord({
  270. b'\'\\\xc2.dat\'': FileRecord(b'Special cases aren\'t special enough to break the rules.'),
  271. b'\"\\\xc2.dat\"': FileRecord(b'Although practicality beats purity.')
  272. })
  273. ignore_dir = {
  274. '.gitattributes': FileRecord(b'.gitattributes export-ignore\n**/src export-ignore\ndata/src/__main__.py -export-ignore', excluded=True),
  275. '__init__.py': FileRecord(b'#Beautiful is better than ugly.'),
  276. 'data': DirRecord({
  277. 'src': DirRecord({
  278. '__init__.py': FileRecord(b'#Explicit is better than implicit.', excluded=True),
  279. '__main__.py': FileRecord(b'#Simple is better than complex.')
  280. })
  281. })
  282. }
  283. skipif_file_darwin = pytest.mark.skipif(sys.platform.startswith('darwin'), reason='Invalid macOS filename.')
  284. skipif_file_win32 = pytest.mark.skipif(sys.platform.startswith('win32'), reason="Invalid Windows filename.")
  285. @pytest.mark.parametrize('contents', [
  286. pytest.param(base, id='No Ignore'),
  287. pytest.param(base_quoted, id='No Ignore (Quoted)', marks=skipif_file_win32),
  288. pytest.param(ignore_in_root, id='Ignore in Root'),
  289. pytest.param(ignore_in_submodule, id='Ignore in Submodule'),
  290. pytest.param(ignore_in_nested_submodule, id='Ignore in Nested Submodule'),
  291. pytest.param(ignore_in_submodule_from_root, id='Ignore in Submodule from Root'),
  292. pytest.param(ignore_in_nested_submodule_from_root, id='Ignore in Nested Submodule from Root'),
  293. pytest.param(ignore_in_nested_submodule_from_submodule, id='Ignore in Nested Submodule from Submodule'),
  294. pytest.param(unset_export_ignore, id='-export-ignore'),
  295. pytest.param(unicode_base, id='Unicode'),
  296. pytest.param(unicode_quoted, id='Unicode (Quoted)', marks=skipif_file_win32),
  297. pytest.param(brackets_base, id='Brackets'),
  298. pytest.param(brackets_quoted, id="Brackets (Quoted)", marks=skipif_file_win32),
  299. pytest.param(quote_base, id="Quote", marks=skipif_file_win32),
  300. pytest.param(quote_quoted, id="Quote (Quoted)", marks=skipif_file_win32),
  301. pytest.param(nonunicode_base, id="Non-Unicode", marks=[skipif_file_win32, skipif_file_darwin]),
  302. pytest.param(nonunicode_quoted, id="Non-Unicode (Quoted)", marks=[skipif_file_win32, skipif_file_darwin]),
  303. pytest.param(backslash_base, id='Backslash', marks=skipif_file_win32),
  304. pytest.param(backslash_quoted, id='Backslash (Quoted)', marks=skipif_file_win32),
  305. pytest.param(non_unicode_backslash_base, id='Non-Unicode Backslash', marks=[skipif_file_win32, skipif_file_darwin]),
  306. pytest.param(non_unicode_backslash_quoted, id='Non-Unicode Backslash (Quoted)', marks=[skipif_file_win32, skipif_file_darwin]),
  307. pytest.param(ignore_dir, id='Ignore Directory')
  308. ])
  309. @pytest.mark.parametrize('exclude', [
  310. pytest.param(True, id='With export-ignore'),
  311. pytest.param(False, id='Without export-ignore'),
  312. ])
  313. def test_ignore(contents, exclude, tmpdir, git_env, monkeypatch):
  314. """
  315. Ensure that GitArchiver respects export-ignore.
  316. """
  317. # On Python 2.7 contained code raises pytest.PytestWarning warning for no good reason.
  318. with warnings.catch_warnings():
  319. warnings.simplefilter("ignore")
  320. for name, value in git_env.items():
  321. monkeypatch.setenv(name, value)
  322. repo_path = os_path_join(tmpdir.strpath, 'repo')
  323. repo = Repo(repo_path)
  324. repo.init()
  325. repo.add_dir('.', contents)
  326. repo.commit('init')
  327. repo_tar_path = os_path_join(tmpdir.strpath, 'repo.tar')
  328. repo.archive(repo_tar_path, exclude=exclude)
  329. repo_tar = TarFile(repo_tar_path, format=PAX_FORMAT, encoding='utf-8')
  330. expected = make_expected_tree(contents, exclude)
  331. actual = make_actual_tree(repo_tar)
  332. assert actual == expected
  333. def test_cli(tmpdir, git_env, monkeypatch):
  334. contents = base
  335. # On Python 2.7 contained code raises pytest.PytestWarning warning for no good reason.
  336. with warnings.catch_warnings():
  337. warnings.simplefilter("ignore")
  338. for name, value in git_env.items():
  339. monkeypatch.setenv(name, value)
  340. repo_path = os_path_join(tmpdir.strpath, 'repo')
  341. repo = Repo(repo_path)
  342. repo.init()
  343. repo.add_dir('.', contents)
  344. repo.commit('init')
  345. repo_tar_path = os_path_join(tmpdir.strpath, 'repo.tar')
  346. git_archive_all.main(['git_archive_all.py', '--prefix', '', '-C', repo_path, repo_tar_path])
  347. repo_tar = TarFile(repo_tar_path, format=PAX_FORMAT, encoding='utf-8')
  348. expected = make_expected_tree(contents)
  349. actual = make_actual_tree(repo_tar)
  350. assert actual == expected
  351. @pytest.mark.parametrize('version', [
  352. b'git version 2.21.0.0.1',
  353. b'git version 2.21.0.windows.1'
  354. ])
  355. def test_git_version_parse(version, mocker):
  356. mocker.patch.object(GitArchiver, 'run_git_shell', return_value=version)
  357. assert GitArchiver.get_git_version() == (2, 21, 0, 0, 1)
  358. def test_pycodestyle():
  359. style = pycodestyle.StyleGuide(repeat=True, max_line_length=240)
  360. report = style.check_files(['git_archive_all.py'])
  361. assert report.total_errors == 0, "Found code style errors (and warnings)."