Import python modules by their hash.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

762 lines
20 KiB

  1. # Copyright 2020 John-Mark Gurney.
  2. # All rights reserved.
  3. #
  4. # Redistribution and use in source and binary forms, with or without
  5. # modification, are permitted provided that the following conditions
  6. # are met:
  7. # 1. Redistributions of source code must retain the above copyright
  8. # notice, this list of conditions and the following disclaimer.
  9. # 2. Redistributions in binary form must reproduce the above copyright
  10. # notice, this list of conditions and the following disclaimer in the
  11. # documentation and/or other materials provided with the distribution.
  12. #
  13. # THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
  14. # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
  15. # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
  16. # ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
  17. # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
  18. # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
  19. # OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
  20. # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
  21. # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
  22. # OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
  23. # SUCH DAMAGE.
  24. import contextlib
  25. import filecmp
  26. import functools
  27. import glob
  28. import hashlib
  29. import importlib.resources
  30. import mock
  31. import os.path
  32. import pathlib
  33. import shutil
  34. import sys
  35. import tempfile
  36. import urllib.request
  37. from importlib.abc import MetaPathFinder, Loader
  38. from importlib.machinery import ModuleSpec
  39. __author__ = 'John-Mark Gurney'
  40. __copyright__ = 'Copyright 2020 John-Mark Gurney. All rights reserved.'
  41. __license__ = '2-clause BSD license'
  42. __version__ = '0.1.0.dev'
  43. def _printanyexc(f): # pragma: no cover
  44. '''Prints any exception that gets raised by the wrapped function.'''
  45. @functools.wraps(f)
  46. def wrapper(*args, **kwargs):
  47. try:
  48. return f(*args, **kwargs)
  49. except Exception:
  50. import traceback
  51. traceback.print_exc()
  52. raise
  53. return wrapper
  54. @contextlib.contextmanager
  55. def tempset(obj, key, value):
  56. '''A context (with) manager for changing the value of an item in a
  57. dictionary, and restoring it after the with block.
  58. Example usage:
  59. ```
  60. d = dict(a=5, b=10)
  61. with tempset(d, 'a', 15):
  62. print(repr(d['a'])
  63. print(repr(d['a'])
  64. ```
  65. '''
  66. try:
  67. oldvalue = obj[key]
  68. obj[key] = value
  69. yield
  70. finally:
  71. obj[key] = oldvalue
  72. @contextlib.contextmanager
  73. def tempattrset(obj, key, value):
  74. '''A context (with) manager for changing the value of an attribute
  75. of an object, and restoring it after the with block.
  76. If the attribute does not exist, it will be deleted afterward.
  77. Example usage:
  78. ```
  79. with tempattrset(someobj, 'a', 15):
  80. print(repr(someobj.a)
  81. print(repr(someobj.a)
  82. ```
  83. '''
  84. try:
  85. dodelattr = False
  86. if hasattr(obj, key):
  87. oldvalue = getattr(obj, key)
  88. else:
  89. dodelattr = True
  90. setattr(obj, key, value)
  91. yield
  92. finally:
  93. if not dodelattr:
  94. setattr(obj, key, oldvalue)
  95. else:
  96. delattr(obj, key)
  97. def urlfetch(url):
  98. with urllib.request.urlopen(url) as req:
  99. if req.status // 100 != 2:
  100. raise RuntimeError('bad fetch')
  101. return req.read()
  102. class HTTPSCAS(object):
  103. def fetch_data(self, url):
  104. if url.scheme != 'https':
  105. raise ValueError('cannot handle scheme %s' %
  106. repr(url.scheme))
  107. url = urllib.parse.urlunparse(url)
  108. return urlfetch(url)
  109. class IPFSCAS(object):
  110. gwhost = 'gateway.ipfs.io'
  111. gwhost = 'cloudflare-ipfs.com'
  112. def make_url(self, url):
  113. return urllib.parse.urlunparse(('https', self.gwhost,
  114. '/ipfs/' + url.netloc) + ('', ) * 3)
  115. def fetch_data(self, url):
  116. if url.scheme != 'ipfs':
  117. raise ValueError('cannot handle scheme %s' %
  118. repr(url.scheme))
  119. gwurl = self.make_url(url)
  120. return urlfetch(gwurl)
  121. class FileDirCAS(object):
  122. '''A file loader for CAS that operates on a directory. It looks
  123. at files, caches their hash, and loads them upon request.'''
  124. def __init__(self, path):
  125. self._path = pathlib.Path(path)
  126. self._path.mkdir(exist_ok=True)
  127. self._hashes = {}
  128. def refresh_dir(self):
  129. '''Internal method to refresh the internal cache of
  130. hashes.'''
  131. for i in glob.glob(os.path.join(self._path, '*.py')):
  132. _, hash = self.read_hash_file(i)
  133. self._hashes[hash] = i
  134. @staticmethod
  135. def read_hash_file(fname):
  136. '''Helper function that will read the file at fname, and
  137. return the tuple of it's contents and it's hash.'''
  138. with open(fname, 'rb') as fp:
  139. data = fp.read()
  140. hash = hashlib.sha256(data).hexdigest()
  141. return data, hash
  142. def is_package(self, hash):
  143. '''Decode the provided hash, and decide if it's a package
  144. or not.'''
  145. return False
  146. def fetch_data(self, url):
  147. '''Given the URL (must be a hash URL), return the code for
  148. it.'''
  149. self.refresh_dir()
  150. hashurl = url
  151. if hashurl.scheme != 'hash' or hashurl.netloc != 'sha256':
  152. raise ValueError('invalid hash url')
  153. hash = hashurl.path[1:]
  154. fname = self._hashes[hash]
  155. data, fhash = self.read_hash_file(fname)
  156. if fhash != hash:
  157. raise ValueError('file no longer matches hash on disk')
  158. return data
  159. class CASFinder(MetaPathFinder, Loader):
  160. '''Overall class for using Content Addressable Storage to load
  161. Python modules into your code. It contains code to dispatch to
  162. the various loaders to attempt to load the hash.'''
  163. def __init__(self):
  164. self._loaders = []
  165. self._aliases = {}
  166. if [ x for x in sys.meta_path if
  167. isinstance(x, self.__class__) ]:
  168. raise RuntimeError(
  169. 'cannot register more than on CASFinder')
  170. sys.meta_path.append(self)
  171. def __enter__(self):
  172. return self
  173. def __exit__(self, exc_type, exc_value, traceback):
  174. self.disconnect()
  175. def load_aliases(self, data):
  176. self._aliases.update(self._parsealiases(data))
  177. def load_mod_aliases(self, name):
  178. '''Load the aliases from the module with the passed in name.'''
  179. aliases = importlib.resources.read_text(sys.modules[name],
  180. 'cas_aliases.txt')
  181. self.load_aliases(aliases)
  182. @staticmethod
  183. def _makebasichashurl(url):
  184. try:
  185. hashurl = urllib.parse.urlparse(url)
  186. except AttributeError:
  187. hashurl = url
  188. return urllib.parse.urlunparse(hashurl[:3] + ('', '', ''))
  189. @classmethod
  190. def _parsealiases(cls, data):
  191. ret = {}
  192. lines = data.split('\n')
  193. for i in lines:
  194. if not i:
  195. continue
  196. name, hash = i.split()
  197. ret.setdefault(name, []).append(hash)
  198. # split out the hashes
  199. for items in list(ret.values()):
  200. lst = [ x for x in items if
  201. not x.startswith('hash://') ]
  202. for h in [ x for x in items if
  203. x.startswith('hash://') ]:
  204. h = cls._makebasichashurl(h)
  205. ret[h] = lst
  206. return ret
  207. def disconnect(self):
  208. '''Disconnect this Finder from being used to load modules.
  209. As this claims an entire namespace, only the first loaded
  210. one will work, and any others will be hidden until the
  211. first one is disconnected.
  212. This can be used w/ a with block to automatically
  213. disconnect when no longer needed. This is mostly useful
  214. for testing.'''
  215. try:
  216. sys.meta_path.remove(self)
  217. except ValueError:
  218. pass
  219. def register(self, loader):
  220. '''Register a loader w/ this finder. This will attempt
  221. to load the hash passed to it. It is also (currently)
  222. responsible for executing the code in the module.'''
  223. self._loaders.append(loader)
  224. # MetaPathFinder methods
  225. def find_spec(self, fullname, path, target=None):
  226. if path is None:
  227. ms = ModuleSpec(fullname, self, is_package=True)
  228. else:
  229. parts = fullname.split('.')
  230. ver, typ, arg = parts[1].split('_')
  231. if typ == 'f':
  232. # make hash url:
  233. hashurl = ('hash://sha256/%s' %
  234. bytes.fromhex(arg).hex())
  235. hashurl = urllib.parse.urlparse(hashurl)
  236. for l in self._loaders:
  237. ispkg = l.is_package(hashurl)
  238. break
  239. else:
  240. return None
  241. else:
  242. # an alias
  243. for i in self._aliases[arg]:
  244. hashurl = urllib.parse.urlparse(i)
  245. if hashurl.scheme == 'hash':
  246. break
  247. else:
  248. raise ValueError('unable to find base hash url for alias %s' % repr(arg))
  249. # fix up the full name:
  250. fullname = 'cas.v1_f_%s' % hashurl.path[1:]
  251. ms = ModuleSpec(fullname, self, is_package=False,
  252. loader_state=(hashurl,))
  253. return ms
  254. def invalidate_caches(self):
  255. return None
  256. # Loader methods
  257. def exec_module(self, module):
  258. if module.__name__ == 'cas':
  259. pass
  260. else:
  261. (url,) = module.__spec__.loader_state
  262. for load in self._loaders:
  263. try:
  264. data = load.fetch_data(url)
  265. break
  266. except Exception:
  267. pass
  268. else:
  269. for url in self._aliases[
  270. self._makebasichashurl(url)]:
  271. url = urllib.parse.urlparse(url)
  272. for load in self._loaders:
  273. try:
  274. data = load.fetch_data(url)
  275. break
  276. except Exception:
  277. pass
  278. else:
  279. continue
  280. break
  281. else:
  282. raise ValueError('unable to find loader for url %s' % repr(urllib.parse.urlunparse(url)))
  283. exec(data, module.__dict__)
  284. def defaultinit(casf):
  285. basedir = pathlib.Path.home() / '.casimport'
  286. basedir.mkdir(exist_ok=True)
  287. conffile = basedir / 'casimport.conf'
  288. if not conffile.exists():
  289. import casimport
  290. with importlib.resources.path(casimport,
  291. 'default.conf') as defconf:
  292. shutil.copy(defconf, conffile)
  293. cachedir = basedir / 'cache'
  294. cachedir.mkdir(parents=True, exist_ok=True)
  295. casf.register(FileDirCAS(cachedir))
  296. casf.register(IPFSCAS())
  297. casf.register(HTTPSCAS())
  298. # The global version
  299. _casfinder = CASFinder()
  300. load_mod_aliases = _casfinder.load_mod_aliases
  301. defaultinit(_casfinder)
  302. import unittest
  303. class TestHelpers(unittest.TestCase):
  304. def test_testset(self):
  305. origobj = object()
  306. d = dict(a=origobj, b=10)
  307. # that when we temporarily set it
  308. with tempset(d, 'a', 15):
  309. # the new value is there
  310. self.assertEqual(d['a'], 15)
  311. # and that the original object is restored
  312. self.assertIs(d['a'], origobj)
  313. def test_testattrset(self):
  314. class TestObj(object):
  315. pass
  316. testobj = TestObj()
  317. # that when we temporarily set it
  318. with tempattrset(testobj, 'a', 15):
  319. # the new value is there
  320. self.assertEqual(testobj.a, 15)
  321. # and that there is no object
  322. self.assertFalse(hasattr(testobj, 'a'))
  323. origobj = object()
  324. newobj = object()
  325. testobj.b = origobj
  326. # that when we temporarily set it
  327. with tempattrset(testobj, 'b', newobj):
  328. # the new value is there
  329. self.assertIs(testobj.b, newobj)
  330. # and the original value is restored
  331. self.assertIs(testobj.b, origobj)
  332. class Test(unittest.TestCase):
  333. def setUp(self):
  334. # clear out the default casfinder if there is one
  335. self.old_meta_path = sys.meta_path
  336. sys.meta_path = [ x for x in sys.meta_path if
  337. not isinstance(x, CASFinder) ]
  338. # setup temporary directory
  339. d = pathlib.Path(os.path.realpath(tempfile.mkdtemp()))
  340. self.basetempdir = d
  341. self.tempdir = d / 'subdir'
  342. self.tempdir.mkdir()
  343. self.fixtures = \
  344. pathlib.Path(__file__).parent.parent / 'fixtures'
  345. def tearDown(self):
  346. # restore environment
  347. sys.meta_path = self.old_meta_path
  348. importlib.invalidate_caches()
  349. # clean up sys.modules
  350. [ sys.modules.pop(x) for x in list(sys.modules.keys()) if
  351. x == 'cas' or x.startswith('cas.') ]
  352. shutil.rmtree(self.basetempdir)
  353. self.tempdir = None
  354. def test_filedircas(self):
  355. cachedir = self.tempdir / 'cache'
  356. fd = FileDirCAS(cachedir)
  357. self.assertTrue(cachedir.exists())
  358. def test_filedircas_limit_refresh(self):
  359. # XXX - only refresh when the dir has changed, and each
  360. # file has changed
  361. pass
  362. def test_casimport(self):
  363. # That a CASFinder
  364. f = CASFinder()
  365. # make sure that we can't import anything at first
  366. with self.assertRaises(ImportError):
  367. import cas.v1_f_2398472398
  368. # when registering the fixtures directory
  369. f.register(FileDirCAS(self.fixtures))
  370. # can import the function
  371. from cas.v1_f_330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3 import hello
  372. name = 'Olof'
  373. # and run the code
  374. self.assertEqual(hello(name), 'hello ' + name)
  375. # and when finished, can disconnect
  376. f.disconnect()
  377. # and is no longer in the meta_path
  378. self.assertNotIn(f, sys.meta_path)
  379. # and when disconnected as second time, nothing happens
  380. f.disconnect()
  381. def test_defaultinit(self):
  382. temphome = self.tempdir / 'home'
  383. temphome.mkdir()
  384. defcachedir = temphome / '.casimport' / 'cache'
  385. # testing w/ default config
  386. with tempset(os.environ, 'HOME', str(temphome)):
  387. with CASFinder() as f:
  388. # Setup the defaults
  389. defaultinit(f)
  390. # That the default.conf file got copied over.
  391. filecmp.cmp(defcachedir.parent /
  392. 'casimport.conf',
  393. pathlib.Path(__file__).parent / 'default.conf')
  394. # that the cache got created
  395. self.assertTrue(defcachedir.is_dir())
  396. # and that when hello.py is copied to the cache
  397. shutil.copy(self.fixtures / 'hello.py', defcachedir)
  398. # it can be imported
  399. from cas.v1_f_330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3 import hello
  400. # and that the second loader is the IPFSCAS
  401. self.assertIsInstance(f._loaders[1], IPFSCAS)
  402. # and that the third loader is the HTTPSCAS
  403. self.assertIsInstance(f._loaders[2], HTTPSCAS)
  404. with CASFinder() as f:
  405. defaultinit(f)
  406. # and that a new CASFinder can still find it
  407. from cas.v1_f_330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3 import hello
  408. def test_multiplecas(self):
  409. # that once we have one
  410. with CASFinder() as f:
  411. # if we try to create a second, it fails
  412. self.assertRaises(RuntimeError, CASFinder)
  413. def test_parsealiases(self):
  414. with open(self.fixtures / 'randpkg' / 'cas_aliases.txt') as fp:
  415. aliasdata = fp.read()
  416. res = CASFinder._parsealiases(aliasdata)
  417. self.assertEqual(res, {
  418. 'hello': [
  419. 'hash://sha256/330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3?type=text/x-python',
  420. 'ipfs://bafkreibtbcckul7lwxqz7nyzj3dknhwrdxj5o4jc6gsroxxjhzz46albym',
  421. 'https://www.funkthat.com/gitea/jmg/casimport/raw/commit/753e64f53c73d9d1afc4d8a617edb9d3542dcea2/fixtures/hello.py',
  422. ],
  423. 'hash://sha256/330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3': [
  424. 'ipfs://bafkreibtbcckul7lwxqz7nyzj3dknhwrdxj5o4jc6gsroxxjhzz46albym',
  425. 'https://www.funkthat.com/gitea/jmg/casimport/raw/commit/753e64f53c73d9d1afc4d8a617edb9d3542dcea2/fixtures/hello.py',
  426. ],
  427. })
  428. def test_aliasmulti(self):
  429. # setup the cache
  430. cachedir = self.tempdir / 'cache'
  431. cachedir.mkdir()
  432. with CASFinder() as f, \
  433. tempattrset(sys.modules[__name__],
  434. 'load_mod_aliases', f.load_mod_aliases):
  435. f.register(FileDirCAS(cachedir))
  436. # and that hello.py is in the cache
  437. shutil.copy(self.fixtures / 'hello.py',
  438. cachedir)
  439. # and that the aliases are loaded
  440. with open(self.fixtures / 'randpkg' / 'cas_aliases.txt') as fp:
  441. f.load_aliases(fp.read())
  442. # that when we load the alias first
  443. from cas.v1_a_hello import hello as hello_alias
  444. # and then load the same module via hash
  445. from cas.v1_f_330884aa2febb5e19fb7194ec6a69ed11dd3d77122f1a5175ee93e73cf0161c3 import hello as hello_hash
  446. # they are the same
  447. self.assertIs(hello_alias, hello_hash)
  448. def test_aliasimports(self):
  449. # setup the cache
  450. cachedir = self.tempdir / 'cache'
  451. cachedir.mkdir()
  452. # add the test module's path
  453. fixdir = str(self.fixtures)
  454. sys.path.append(fixdir)
  455. try:
  456. with CASFinder() as f, \
  457. tempattrset(sys.modules[__name__],
  458. 'load_mod_aliases', f.load_mod_aliases):
  459. f.register(FileDirCAS(cachedir))
  460. # and that hello.py is in the cache
  461. shutil.copy(self.fixtures / 'hello.py',
  462. cachedir)
  463. self.assertNotIn('randpkg', sys.modules)
  464. # that the import is successful
  465. import randpkg
  466. # and pulled in the method
  467. self.assertTrue(hasattr(randpkg, 'hello'))
  468. del sys.modules['randpkg']
  469. finally:
  470. sys.path.remove(fixdir)
  471. def test_aliasipfsimports(self):
  472. # add the test module's path
  473. fixdir = str(self.fixtures)
  474. sys.path.append(fixdir)
  475. # that a fake ipfsloader
  476. with open(self.fixtures / 'hello.py') as fp:
  477. # that returns the correct data
  478. fakedata = fp.read()
  479. def fakeload(url, fd=fakedata):
  480. if url.scheme != 'ipfs' or url.netloc != 'bafkreibtbcckul7lwxqz7nyzj3dknhwrdxj5o4jc6gsroxxjhzz46albym':
  481. raise ValueError
  482. return fd
  483. fakeipfsloader = mock.MagicMock()
  484. fakeipfsloader.fetch_data = fakeload
  485. try:
  486. with CASFinder() as f, \
  487. tempattrset(sys.modules[__name__], 'load_mod_aliases',
  488. f.load_mod_aliases):
  489. f.register(fakeipfsloader)
  490. self.assertNotIn('randpkg', sys.modules)
  491. # that the import is successful
  492. import randpkg
  493. # and pulled in the method
  494. self.assertTrue(hasattr(randpkg, 'hello'))
  495. del sys.modules['randpkg']
  496. finally:
  497. sys.path.remove(fixdir)
  498. @mock.patch('urllib.request.urlopen')
  499. def test_ipfscasloader(self, uomock):
  500. # prep return test data
  501. with open(self.fixtures / 'hello.py') as fp:
  502. # that returns the correct data
  503. ipfsdata = fp.read()
  504. # that the ipfs CAS loader
  505. ipfs = IPFSCAS()
  506. # that the request is successfull
  507. uomock.return_value.__enter__.return_value.status = 200
  508. # and returns the correct data
  509. uomock.return_value.__enter__.return_value.read.return_value = ipfsdata
  510. # that when called
  511. hashurl = urllib.parse.urlparse('ipfs://bafkreibtbcckul7lwxqz7nyzj3dknhwrdxj5o4jc6gsroxxjhzz46albym')
  512. data = ipfs.fetch_data(hashurl)
  513. # it opens the correct url
  514. uomock.assert_called_with('https://cloudflare-ipfs.com/ipfs/bafkreibtbcckul7lwxqz7nyzj3dknhwrdxj5o4jc6gsroxxjhzz46albym')
  515. # and returns the correct data
  516. self.assertEqual(data, ipfsdata)
  517. with self.assertRaises(ValueError):
  518. # that a hash url fails
  519. ipfs.fetch_data(urllib.parse.urlparse('hash://sha256/asldfkj'))
  520. # that when the request fails
  521. uomock.return_value.__enter__.return_value.status = 400
  522. # it raises a RuntimeError
  523. with self.assertRaises(RuntimeError):
  524. ipfs.fetch_data(hashurl)
  525. # Note: mostly copied from above, test_ipfscasloader
  526. @mock.patch('urllib.request.urlopen')
  527. def test_httpscasloader(self, uomock):
  528. # prep return test data
  529. with open(self.fixtures / 'hello.py') as fp:
  530. # that returns the correct data
  531. httpsdata = fp.read()
  532. # that the https CAS loader
  533. httpsldr = HTTPSCAS()
  534. # that the request is successfull
  535. uomock.return_value.__enter__.return_value.status = 200
  536. # and returns the correct data
  537. uomock.return_value.__enter__.return_value.read.return_value = httpsdata
  538. # that when called
  539. hashurl = urllib.parse.urlparse('https://www.funkthat.com/gitea/jmg/casimport/raw/commit/753e64f53c73d9d1afc4d8a617edb9d3542dcea2/fixtures/hello.py')
  540. data = httpsldr.fetch_data(hashurl)
  541. # it opens the correct url
  542. uomock.assert_called_with('https://www.funkthat.com/gitea/jmg/casimport/raw/commit/753e64f53c73d9d1afc4d8a617edb9d3542dcea2/fixtures/hello.py')
  543. # and returns the correct data
  544. self.assertEqual(data, httpsdata)
  545. with self.assertRaises(ValueError):
  546. # that a hash url fails
  547. httpsldr.fetch_data(urllib.parse.urlparse('hash://sha256/asldfkj'))
  548. # that when the request fails
  549. uomock.return_value.__enter__.return_value.status = 400
  550. # it raises a RuntimeError
  551. with self.assertRaises(RuntimeError):
  552. httpsldr.fetch_data(hashurl)
  553. @unittest.skip('todo')
  554. def test_overlappingaliases(self):
  555. # make sure that an aliases file is consistent and does not
  556. # override other urls. That is that any hashes are
  557. # consistent, and that they have at least one root hash that
  558. # is the same, and will be used for fetching.
  559. #
  560. # Likely will also have to deal w/ an issue where two
  561. # aliases share sha256, and a third shares sha512, which in
  562. # this case, BOTH hashse have to be checked.
  563. pass
  564. @unittest.skip('todo')
  565. def test_loaderpriority(self):
  566. # XXX - write test to allow you to specify the priority of
  567. # a loader, to ensure that cache stays at top.
  568. # Maybe also think of a way to say local/remote, because
  569. # some loaders may be "more local" than others, like using
  570. # a local ipfs gateway makes more sense than hitting a
  571. # public gateway
  572. pass
  573. def test_filecorruption(self):
  574. cachedir = self.tempdir / 'cachedir'
  575. cachedir.mkdir()
  576. # that an existing file
  577. shutil.copy(self.fixtures / 'hello.py', cachedir)
  578. # is in the cache
  579. fdcas = FileDirCAS(cachedir)
  580. # that when refresh is surpressed
  581. fdcas.refresh_dir = lambda: None
  582. # and has a bogus hash
  583. fdcas._hashes['0000'] = cachedir / 'hello.py'
  584. # that when read raises an exception
  585. with self.assertRaises(ValueError):
  586. fdcas.fetch_data(urllib.parse.urlparse('hash://sha256/0000'))
  587. # that when passed an invalid url
  588. with self.assertRaises(ValueError):
  589. fdcas.fetch_data(urllib.parse.urlparse('https://sha256/0000'))