/ tests / test_circup.py
test_circup.py
  1  # SPDX-FileCopyrightText: 2019 Nicholas Tollervey, written for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  """
  5  Unit tests for the circup module.
  6  
  7  Copyright (c) 2019 Adafruit Industries
  8  
  9  Permission is hereby granted, free of charge, to any person obtaining a copy
 10  of this software and associated documentation files (the "Software"), to deal
 11  in the Software without restriction, including without limitation the rights
 12  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 13  copies of the Software, and to permit persons to whom the Software is
 14  furnished to do so, subject to the following conditions:
 15  
 16  The above copyright notice and this permission notice shall be included in all
 17  copies or substantial portions of the Software.
 18  
 19  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 20  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 21  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 22  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 23  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 24  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 25  SOFTWARE.
 26  """
 27  import os
 28  import ctypes
 29  import json
 30  from unittest import mock
 31  
 32  
 33  from click.testing import CliRunner
 34  import pytest
 35  import requests
 36  
 37  import circup
 38  
 39  
 40  def test_Module_init_file_module():
 41      """
 42      Ensure the Module instance is set up as expected and logged, is if for a
 43      single file Python module.
 44      """
 45      path = os.path.join("foo", "bar", "baz", "module.py")
 46      repo = "https://github.com/adafruit/SomeLibrary.git"
 47      device_version = "1.2.3"
 48      bundle_version = "3.2.1"
 49      with mock.patch("circup.logger.info") as mock_logger, mock.patch(
 50          "circup.os.path.isfile", return_value=True
 51      ), mock.patch("circup.CPY_VERSION", "4.1.2"), mock.patch(
 52          "circup.os.walk", return_value=[["lib", "", ""]]
 53      ) as mock_walk:
 54          m = circup.Module(path, repo, device_version, bundle_version, False)
 55          mock_logger.assert_called_once_with(m)
 56          assert m.path == path
 57          assert m.file == "module.py"
 58          assert m.name == "module"
 59          assert m.repo == repo
 60          assert m.device_version == device_version
 61          assert m.bundle_version == bundle_version
 62          assert m.bundle_path == os.path.join("lib", m.file)
 63          assert m.mpy is False
 64          mock_walk.assert_called_once_with(circup.BUNDLE_DIR.format("py"))
 65  
 66  
 67  def test_Module_init_directory_module():
 68      """
 69      Ensure the Module instance is set up as expected and logged, as if for a
 70      directory based Python module.
 71      """
 72      path = os.path.join("foo", "bar", "modulename", "")
 73      repo = "https://github.com/adafruit/SomeLibrary.git"
 74      device_version = "1.2.3"
 75      bundle_version = "3.2.1"
 76      mpy = True
 77      with mock.patch("circup.logger.info") as mock_logger, mock.patch(
 78          "circup.os.path.isfile", return_value=False
 79      ), mock.patch("circup.CPY_VERSION", "4.1.2"), mock.patch(
 80          "circup.os.walk", return_value=[["lib", "", ""]]
 81      ) as mock_walk:
 82          m = circup.Module(path, repo, device_version, bundle_version, mpy)
 83          mock_logger.assert_called_once_with(m)
 84          assert m.path == path
 85          assert m.file is None
 86          assert m.name == "modulename"
 87          assert m.repo == repo
 88          assert m.device_version == device_version
 89          assert m.bundle_version == bundle_version
 90          assert m.bundle_path == os.path.join("lib", m.name)
 91          assert m.mpy is True
 92          mock_walk.assert_called_once_with(circup.BUNDLE_DIR.format("4mpy"))
 93  
 94  
 95  def test_Module_outofdate():
 96      """
 97      Ensure the ``outofdate`` property on a Module instance returns the expected
 98      boolean value to correctly indicate if the referenced module is, in fact,
 99      out of date.
100      """
101      path = os.path.join("foo", "bar", "baz", "module.py")
102      repo = "https://github.com/adafruit/SomeLibrary.git"
103      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
104      m1 = circup.Module(path, repo, "1.2.3", "3.2.1", bundle_path)
105      m2 = circup.Module(path, repo, "1.2.3", "1.2.3", bundle_path)
106      # shouldn't happen!
107      m3 = circup.Module(path, repo, "3.2.1", "1.2.3", bundle_path)
108      assert m1.outofdate is True
109      assert m2.outofdate is False
110      assert m3.outofdate is False
111  
112  
113  def test_Module_outofdate_bad_versions():
114      """
115      Sometimes, the version is not a valid semver value. In this case, the
116      ``outofdate`` property assumes the module should be updated (to correct
117      this problem). Such a problem should be logged.
118      """
119      path = os.path.join("foo", "bar", "baz", "module.py")
120      repo = "https://github.com/adafruit/SomeLibrary.git"
121      device_version = "hello"
122      bundle_version = "3.2.1"
123      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
124      m = circup.Module(path, repo, device_version, bundle_version, bundle_path)
125      with mock.patch("circup.logger.warning") as mock_logger:
126          assert m.outofdate is True
127          assert mock_logger.call_count == 2
128  
129  
130  def test_Module_major_update_bad_versions():
131      """
132      Sometimes, the version is not a valid semver value. In this case, the
133      ``major_update`` property assumes the module is a major update, so as not
134      to block the user from getting the latest update.
135      Such a problem should be logged.
136      """
137      path = os.path.join("foo", "bar", "baz", "module.py")
138      repo = "https://github.com/adafruit/SomeLibrary.git"
139      device_version = "1.2.3"
140      bundle_version = "version-3"
141      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
142      m = circup.Module(path, repo, device_version, bundle_version, bundle_path)
143      with mock.patch("circup.logger.warning") as mock_logger:
144          assert m.major_update is True
145          assert mock_logger.call_count == 2
146  
147  
148  def test_Module_row():
149      """
150      Ensure the tuple contains the expected items to be correctly displayed in
151      a table of version-related results.
152      """
153      path = os.path.join("foo", "bar", "baz", "module.py")
154      repo = "https://github.com/adafruit/SomeLibrary.git"
155      device_version = "1.2.3"
156      bundle_version = None
157      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
158      with mock.patch("circup.os.path.isfile", return_value=True):
159          m = circup.Module(path, repo, device_version, bundle_version, bundle_path)
160      # print(m.__dict__)
161      # print(m.row)
162      assert m.row == ("module", "1.2.3", "unknown", "True")
163  
164  
165  def test_Module_update_dir():
166      """
167      Ensure if the module is a directory, the expected actions take place to
168      update the module on the connected device.
169      """
170      path = os.path.join("foo", "bar", "baz", "module.py")
171      repo = "https://github.com/adafruit/SomeLibrary.git"
172      device_version = "1.2.3"
173      bundle_version = None
174      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
175      m = circup.Module(path, repo, device_version, bundle_version, bundle_path)
176      with mock.patch("circup.shutil") as mock_shutil, mock.patch(
177          "circup.os.path.isdir", return_value=True
178      ):
179          m.update()
180          mock_shutil.rmtree.assert_called_once_with(m.path, ignore_errors=True)
181          mock_shutil.copytree.assert_called_once_with(m.bundle_path, m.path)
182  
183  
184  def test_Module_update_file():
185      """
186      Ensure if the module is a file, the expected actions take place to
187      update the module on the connected device.
188      """
189      path = os.path.join("foo", "bar", "baz", "module.py")
190      repo = "https://github.com/adafruit/SomeLibrary.git"
191      device_version = "1.2.3"
192      bundle_version = None
193      bundle_path = os.path.join("baz", "bar", "foo", "module.py")
194      m = circup.Module(path, repo, device_version, bundle_version, bundle_path)
195      with mock.patch("circup.shutil") as mock_shutil, mock.patch(
196          "circup.os.remove"
197      ) as mock_remove, mock.patch("circup.os.path.isdir", return_value=False):
198          m.update()
199          mock_remove.assert_called_once_with(m.path)
200          mock_shutil.copyfile.assert_called_once_with(m.bundle_path, m.path)
201  
202  
203  def test_Module_repr():
204      """
205      Ensure the repr(dict) is returned (helps when logging).
206      """
207      path = os.path.join("foo", "bar", "baz", "module.py")
208      repo = "https://github.com/adafruit/SomeLibrary.git"
209      device_version = "1.2.3"
210      bundle_version = "3.2.1"
211      with mock.patch("circup.os.path.isfile", return_value=True), mock.patch(
212          "circup.CPY_VERSION", "4.1.2"
213      ), mock.patch("circup.os.walk", return_value=[["lib", "", ""]]):
214          m = circup.Module(path, repo, device_version, bundle_version, False)
215      assert repr(m) == repr(
216          {
217              "path": path,
218              "file": "module.py",
219              "name": "module",
220              "repo": repo,
221              "device_version": device_version,
222              "bundle_version": bundle_version,
223              "bundle_path": os.path.join("lib", m.file),
224              "mpy": False,
225          }
226      )
227  
228  
229  def test_find_device_posix_exists():
230      """
231      Simulate being on os.name == 'posix' and a call to "mount" returns a
232      record indicating a connected device.
233      """
234      with open("tests/mount_exists.txt", "rb") as fixture_file:
235          fixture = fixture_file.read()
236          with mock.patch("os.name", "posix"):
237              with mock.patch("circup.check_output", return_value=fixture):
238                  assert circup.find_device() == "/media/ntoll/CIRCUITPY"
239  
240  
241  def test_find_device_posix_no_mount_command():
242      """
243      When the user doesn't have administrative privileges on OSX then the mount
244      command isn't on their path. In which case, check circup uses the more
245      explicit /sbin/mount instead.
246      """
247      with open("tests/mount_exists.txt", "rb") as fixture_file:
248          fixture = fixture_file.read()
249      mock_check = mock.MagicMock(side_effect=[FileNotFoundError, fixture])
250      with mock.patch("os.name", "posix"), mock.patch("circup.check_output", mock_check):
251          assert circup.find_device() == "/media/ntoll/CIRCUITPY"
252          assert mock_check.call_count == 2
253          assert mock_check.call_args_list[0][0][0] == "mount"
254          assert mock_check.call_args_list[1][0][0] == "/sbin/mount"
255  
256  
257  def test_find_device_posix_missing():
258      """
259      Simulate being on os.name == 'posix' and a call to "mount" returns no
260      records associated with an Adafruit device.
261      """
262      with open("tests/mount_missing.txt", "rb") as fixture_file:
263          fixture = fixture_file.read()
264          with mock.patch("os.name", "posix"), mock.patch(
265              "circup.check_output", return_value=fixture
266          ):
267              assert circup.find_device() is None
268  
269  
270  def test_find_device_nt_exists():
271      """
272      Simulate being on os.name == 'nt' and a disk with a volume name 'CIRCUITPY'
273      exists indicating a connected device.
274      """
275      mock_windll = mock.MagicMock()
276      mock_windll.kernel32 = mock.MagicMock()
277      mock_windll.kernel32.GetVolumeInformationW = mock.MagicMock()
278      mock_windll.kernel32.GetVolumeInformationW.return_value = None
279      fake_buffer = ctypes.create_unicode_buffer("CIRCUITPY")
280      with mock.patch("os.name", "nt"), mock.patch(
281          "os.path.exists", return_value=True
282      ), mock.patch("ctypes.create_unicode_buffer", return_value=fake_buffer):
283          ctypes.windll = mock_windll
284          assert circup.find_device() == "A:\\"
285  
286  
287  def test_find_device_nt_missing():
288      """
289      Simulate being on os.name == 'nt' and a disk with a volume name 'CIRCUITPY'
290      does not exist for a device.
291      """
292      mock_windll = mock.MagicMock()
293      mock_windll.kernel32 = mock.MagicMock()
294      mock_windll.kernel32.GetVolumeInformationW = mock.MagicMock()
295      mock_windll.kernel32.GetVolumeInformationW.return_value = None
296      fake_buffer = ctypes.create_unicode_buffer(1024)
297      with mock.patch("os.name", "nt"), mock.patch(
298          "os.path.exists", return_value=True
299      ), mock.patch("ctypes.create_unicode_buffer", return_value=fake_buffer):
300          ctypes.windll = mock_windll
301          assert circup.find_device() is None
302  
303  
304  def test_find_device_unknown_os():
305      """
306      Raises a NotImplementedError if the host OS is not supported.
307      """
308      with mock.patch("os.name", "foo"):
309          with pytest.raises(NotImplementedError) as ex:
310              circup.find_device()
311      assert ex.value.args[0] == 'OS "foo" not supported.'
312  
313  
314  def test_get_latest_release_from_url():
315      """
316      Ensure the expected tag value is extracted from the returned URL (resulting
317      from a call to the expected endpoint).
318      """
319      response = mock.MagicMock()
320      response.headers = {
321          "Location": "https://github.com/adafruit"
322          "/Adafruit_CircuitPython_Bundle/releases/tag/20190903"
323      }
324      expected_url = (
325          "https://github.com/adafruit/Adafruit_CircuitPython_Bundle/releases/latest"
326      )
327      with mock.patch("circup.requests.head", return_value=response) as mock_get:
328          result = circup.get_latest_release_from_url(expected_url)
329          assert result == "20190903"
330          mock_get.assert_called_once_with(expected_url)
331  
332  
333  def test_extract_metadata_python():
334      """
335      Ensure the dunder objects assigned in code are extracted into a Python
336      dictionary representing such metadata.
337      """
338      code = (
339          "# A comment\n"
340          '__version__ = "1.1.4"\n'
341          '__repo__ = "https://github.com/adafruit/SomeLibrary.git"\n'
342          'print("Hello, world!")\n'
343      )
344      path = "foo.py"
345      with mock.patch("builtins.open", mock.mock_open(read_data=code)) as mock_open:
346          result = circup.extract_metadata(path)
347          mock_open.assert_called_once_with(path, encoding="utf-8")
348      assert len(result) == 3
349      assert result["__version__"] == "1.1.4"
350      assert result["__repo__"] == "https://github.com/adafruit/SomeLibrary.git"
351      assert result["mpy"] is False
352  
353  
354  def test_extract_metadata_byte_code():
355      """
356      Ensure the __version__ is correctly extracted from the bytecode ".mpy"
357      file. Version in test_module is 0.9.2
358      """
359      result = circup.extract_metadata("tests/test_module.mpy")
360      assert result["__version__"] == "0.9.2"
361      assert result["mpy"] is True
362  
363  
364  def test_find_modules():
365      """
366      Ensure that the expected list of Module instances is returned given the
367      metadata dictionary fixtures for device and bundle modules.
368      """
369      with open("tests/device.json") as f:
370          device_modules = json.load(f)
371      with open("tests/bundle.json") as f:
372          bundle_modules = json.load(f)
373      with mock.patch(
374          "circup.get_device_versions", return_value=device_modules
375      ), mock.patch(
376          "circup.get_bundle_versions", return_value=bundle_modules
377      ), mock.patch(
378          "circup.os.path.isfile", return_value=True
379      ):
380          result = circup.find_modules("")
381      assert len(result) == 1
382      assert result[0].name == "adafruit_74hc595"
383      assert (
384          result[0].repo
385          == "https://github.com/adafruit/Adafruit_CircuitPython_74HC595.git"
386      )
387  
388  
389  def test_find_modules_goes_bang():
390      """
391      Ensure if there's a problem getting metadata an error message is displayed
392      and the utility exists with an error code of 1.
393      """
394      with mock.patch(
395          "circup.get_device_versions", side_effect=Exception("BANG!")
396      ), mock.patch("circup.click") as mock_click, mock.patch(
397          "circup.sys.exit"
398      ) as mock_exit:
399          circup.find_modules("")
400          assert mock_click.echo.call_count == 1
401          mock_exit.assert_called_once_with(1)
402  
403  
404  def test_get_bundle_versions():
405      """
406      Ensure get_modules is called with the path for the library bundle.
407      """
408      dirs = (("foo/bar/lib", "", ""),)
409      with mock.patch("circup.ensure_latest_bundle"), mock.patch(
410          "circup.os.walk", return_value=dirs
411      ) as mock_walk, mock.patch("circup.get_modules", return_value="ok") as mock_gm:
412          assert circup.get_bundle_versions() == "ok"
413          mock_walk.assert_called_once_with(circup.BUNDLE_DIR.format("py"))
414          mock_gm.assert_called_once_with("foo/bar/lib")
415  
416  
417  def test_get_circuitpython_version():
418      """
419      Given valid content of a boot_out.txt file on a connected device, return
420      the version number of CircuitPython running on the board.
421      """
422      data = (
423          "Adafruit CircuitPython 4.1.0 on 2019-08-02; "
424          "Adafruit CircuitPlayground Express with samd21g18"
425      )
426      mock_open = mock.mock_open(read_data=data)
427      device_path = "device"
428      with mock.patch("builtins.open", mock_open):
429          assert circup.get_circuitpython_version(device_path) == "4.1.0"
430          mock_open.assert_called_once_with(os.path.join(device_path, "boot_out.txt"))
431  
432  
433  def test_get_device_versions():
434      """
435      Ensure get_modules is called with the path for the attached device.
436      """
437      with mock.patch("circup.get_modules", return_value="ok") as mock_gm:
438          assert circup.get_device_versions("TESTDIR") == "ok"
439          mock_gm.assert_called_once_with(os.path.join("TESTDIR", "lib"))
440  
441  
442  def test_get_modules_empty_path():
443      """
444      Sometimes a path to a device or bundle may be empty. Ensure, if this is the
445      case, an empty dictionary is returned.
446      """
447      assert circup.get_modules("") == {}
448  
449  
450  def test_get_modules_that_are_files():
451      """
452      Check the expected dictionary containing metadata is returned given the
453      (mocked) results of glob and open on file based Python modules.
454      """
455      path = "tests"  # mocked away in function.
456      mods = [
457          os.path.join("tests", "local_module.py"),
458          os.path.join("tests", ".hidden_module.py"),
459      ]
460      with mock.patch("circup.glob.glob", side_effect=[mods, [], []]):
461          result = circup.get_modules(path)
462          assert len(result) == 1  # Hidden files are ignored.
463          assert "local_module" in result
464          assert result["local_module"]["path"] == os.path.join(
465              "tests", "local_module.py"
466          )
467          assert result["local_module"]["__version__"] == "1.2.3"  # from fixture.
468          repo = "https://github.com/adafruit/SomeLibrary.git"  # from fixture.
469          assert result["local_module"]["__repo__"] == repo
470  
471  
472  def test_get_modules_that_are_directories():
473      """
474      Check the expected dictionary containing metadata is returned given the
475      (mocked) results of glob and open, on directory based Python modules.
476      """
477      path = "tests"  # mocked away in function.
478      mods = [
479          os.path.join("tests", "dir_module", ""),
480          os.path.join("tests", ".hidden_dir", ""),
481      ]
482      mod_files = ["tests/dir_module/my_module.py", "tests/dir_module/__init__.py"]
483      with mock.patch("circup.glob.glob", side_effect=[[], [], mods, mod_files, []]):
484          result = circup.get_modules(path)
485          assert len(result) == 1
486          assert "dir_module" in result
487          assert result["dir_module"]["path"] == os.path.join("tests", "dir_module", "")
488          assert result["dir_module"]["__version__"] == "3.2.1"  # from fixture.
489          repo = "https://github.com/adafruit/SomeModule.git"  # from fixture.
490          assert result["dir_module"]["__repo__"] == repo
491  
492  
493  def test_get_modules_that_are_directories_with_no_metadata():
494      """
495      Check the expected dictionary containing just the path is returned given
496      the (mocked) results of glob and open, on directory based Python modules.
497      """
498      path = "tests"  # mocked away in function.
499      mods = [os.path.join("tests", "bad_module", "")]
500      mod_files = ["tests/bad_module/my_module.py", "tests/bad_module/__init__.py"]
501      with mock.patch("circup.glob.glob", side_effect=[[], [], mods, mod_files, []]):
502          result = circup.get_modules(path)
503          assert len(result) == 1
504          assert "bad_module" in result
505          assert result["bad_module"]["path"] == os.path.join("tests", "bad_module", "")
506          assert "__version__" not in result["bad_module"]
507          assert "__repo__" not in result["bad_module"]
508  
509  
510  def test_ensure_latest_bundle_no_bundle_data():
511      """
512      If there's no BUNDLE_DATA file (containing previous current version of the
513      bundle) then default to update.
514      """
515      with mock.patch("circup.get_latest_tag", return_value="12345"), mock.patch(
516          "circup.os.path.isfile", return_value=False
517      ), mock.patch("circup.get_bundle") as mock_gb, mock.patch(
518          "circup.json"
519      ) as mock_json:
520          circup.ensure_latest_bundle()
521          mock_gb.assert_called_once_with("12345")
522          assert mock_json.dump.call_count == 1  # Current version saved to file.
523  
524  
525  def test_ensure_latest_bundle_bad_bundle_data():
526      """
527      If there's a BUNDLE_DATA file (containing previous current version of the
528      bundle) but it has been corrupted (which has sometimes happened during
529      manual testing) then default to update.
530      """
531      with mock.patch("circup.get_latest_tag", return_value="12345"), mock.patch(
532          "circup.os.path.isfile", return_value=True
533      ), mock.patch("circup.open"), mock.patch(
534          "circup.get_bundle"
535      ) as mock_gb, mock.patch(
536          "circup.json.load", side_effect=json.decoder.JSONDecodeError("BANG!", "doc", 1)
537      ), mock.patch(
538          "circup.json.dump"
539      ), mock.patch(
540          "circup.logger"
541      ) as mock_logger:
542          circup.ensure_latest_bundle()
543          mock_gb.assert_called_once_with("12345")
544          assert mock_logger.error.call_count == 1
545          assert mock_logger.exception.call_count == 1
546  
547  
548  def test_ensure_latest_bundle_to_update():
549      """
550      If the version found in the BUNDLE_DATA is out of date, then cause an
551      update to the bundle.
552      """
553      with mock.patch("circup.get_latest_tag", return_value="54321"), mock.patch(
554          "circup.os.path.isfile", return_value=True
555      ), mock.patch("circup.open"), mock.patch(
556          "circup.get_bundle"
557      ) as mock_gb, mock.patch(
558          "circup.json"
559      ) as mock_json:
560          mock_json.load.return_value = {"tag": "12345"}
561          circup.ensure_latest_bundle()
562          mock_gb.assert_called_once_with("54321")
563          assert mock_json.dump.call_count == 1  # Current version saved to file.
564  
565  
566  def test_ensure_latest_bundle_to_update_http_error():
567      # pylint: disable=pointless-statement
568      # 2nd to last line is deemed pointless.
569      # Don't understand but for now this is an accpetable workaround
570      # Tracking issue will be opened.
571      """
572      If an HTTP error happens during a bundle update, print a friendly
573      error message and exit 1.
574      """
575      with mock.patch("circup.get_latest_tag", return_value="54321"), mock.patch(
576          "circup.os.path.isfile", return_value=True
577      ), mock.patch("circup.open"), mock.patch(
578          "circup.get_bundle", side_effect=requests.exceptions.HTTPError("404")
579      ) as mock_gb, mock.patch(
580          "circup.json"
581      ) as mock_json, mock.patch(
582          "circup.click.secho"
583      ) as mock_click, mock.patch(
584          "circup.sys.exit"
585      ) as mock_exit:
586          mock_json.load.return_value = {"tag": "12345"}
587          circup.ensure_latest_bundle()
588          mock_gb.assert_called_once_with("54321")
589          assert mock_json.dump.call_count == 0  # not saved.
590          mock_click.call_count == 1  # friendly message.
591          mock_exit.assert_called_once_with(1)  # exit 1.
592  
593  
594  def test_ensure_latest_bundle_no_update():
595      """
596      If the version found in the BUNDLE_DATA is NOT out of date, just log the
597      fact and don't update.
598      """
599      with mock.patch("circup.get_latest_tag", return_value="12345"), mock.patch(
600          "circup.os.path.isfile", return_value=True
601      ), mock.patch("circup.open"), mock.patch(
602          "circup.get_bundle"
603      ) as mock_gb, mock.patch(
604          "circup.json"
605      ) as mock_json, mock.patch(
606          "circup.logger"
607      ) as mock_logger:
608          mock_json.load.return_value = {"tag": "12345"}
609          circup.ensure_latest_bundle()
610          assert mock_gb.call_count == 0
611          assert mock_logger.info.call_count == 2
612  
613  
614  def test_get_bundle():
615      """
616      Ensure the expected calls are made to get the referenced bundle and the
617      result is unzipped to the expected location.
618      """
619      # All these mocks stop IO side effects and allow us to spy on the code to
620      # ensure the expected calls are made with the correct values. Warning! Here
621      # Be Dragons! (If in doubt, ask ntoll for details).
622      mock_progress = mock.MagicMock()
623      mock_progress().__enter__ = mock.MagicMock(return_value=["a", "b", "c"])
624      mock_progress().__exit__ = mock.MagicMock()
625      with mock.patch("circup.requests") as mock_requests, mock.patch(
626          "circup.click"
627      ) as mock_click, mock.patch(
628          "circup.open", mock.mock_open()
629      ) as mock_open, mock.patch(
630          "circup.os.path.isdir", return_value=True
631      ), mock.patch(
632          "circup.shutil"
633      ) as mock_shutil, mock.patch(
634          "circup.zipfile"
635      ) as mock_zipfile:
636          mock_click.progressbar = mock_progress
637          mock_requests.get().status_code = mock_requests.codes.ok
638          mock_requests.get.reset_mock()
639          tag = "12345"
640          circup.get_bundle(tag)
641          assert mock_requests.get.call_count == 3
642          assert mock_open.call_count == 3
643          assert mock_shutil.rmtree.call_count == 3
644          assert mock_zipfile.ZipFile.call_count == 3
645          assert mock_zipfile.ZipFile().__enter__().extractall.call_count == 3
646  
647  
648  def test_get_bundle_network_error():
649      """
650      Ensure that if there is a network related error when grabbing the bundle
651      then the error is logged and re-raised for the HTTP status code.
652      """
653      with mock.patch("circup.requests") as mock_requests, mock.patch(
654          "circup.logger"
655      ) as mock_logger:
656          # Force failure with != requests.codes.ok
657          mock_requests.get().status_code = mock_requests.codes.BANG
658          # Ensure raise_for_status actually raises an exception.
659          mock_requests.get().raise_for_status.return_value = Exception("Bang!")
660          mock_requests.get.reset_mock()
661          tag = "12345"
662          with pytest.raises(Exception) as ex:
663              circup.get_bundle(tag)
664              assert ex.value.args[0] == "Bang!"
665          url = (
666              "https://github.com/adafruit/Adafruit_CircuitPython_Bundle"
667              "/releases/download"
668              "/{tag}/adafruit-circuitpython-bundle-py-{tag}.zip".format(tag=tag)
669          )
670          mock_requests.get.assert_called_once_with(url, stream=True)
671          assert mock_logger.warning.call_count == 1
672          mock_requests.get().raise_for_status.assert_called_once_with()
673  
674  
675  def test_show_command():
676      """
677      test_show_command
678      """
679      runner = CliRunner()
680      TEST_BUNDLE_MODULES = ["one.py", "two.py", "three.py"]
681      with mock.patch("circup.get_bundle_versions", return_value=TEST_BUNDLE_MODULES):
682          result = runner.invoke(circup.show)
683      assert result.exit_code == 0
684      assert all([m.replace(".py", "") in result.output for m in TEST_BUNDLE_MODULES])
685  
686  
687  def test_show_match_command():
688      """
689      test_show_match_command
690      """
691      runner = CliRunner()
692      TEST_BUNDLE_MODULES = ["one.py", "two.py", "three.py"]
693      with mock.patch("circup.get_bundle_versions", return_value=TEST_BUNDLE_MODULES):
694          result = runner.invoke(circup.show, ["t"])
695      assert result.exit_code == 0
696      assert "one" not in result.output
697  
698  
699  def test_show_match_py_command():
700      """
701      Check that py does not match the .py extention in the module names
702      """
703      runner = CliRunner()
704      TEST_BUNDLE_MODULES = ["one.py", "two.py", "three.py"]
705      with mock.patch("circup.get_bundle_versions", return_value=TEST_BUNDLE_MODULES):
706          result = runner.invoke(circup.show, ["py"])
707      assert result.exit_code == 0
708      assert "0 shown" in result.output