From 2a7c72bcb84f9ea64693b0622ffeff92fcee664f Mon Sep 17 00:00:00 2001 From: "Nicholas H.Tollervey" Date: Wed, 4 Sep 2019 17:41:01 +0100 Subject: [PATCH] Refactor to use Dan's suggestion of using bundle library as canonical source of version information. See #9. --- README.rst | 2 +- circup.py | 335 ++++++++++++++++++++++++++++++------------- setup.py | 2 +- tests/bundle.json | 12 ++ tests/device.json | 7 + tests/test_circup.py | 294 +++++++++++++++++++++++++------------ 6 files changed, 454 insertions(+), 198 deletions(-) create mode 100644 tests/bundle.json create mode 100644 tests/device.json diff --git a/README.rst b/README.rst index af80212..1e9308e 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ Usage Example usage:: - circup list --outdated + circup list Package Version Latest ----------- ------- ------ diff --git a/circup.py b/circup.py index 62b41e0..a075e04 100644 --- a/circup.py +++ b/circup.py @@ -22,7 +22,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import logging -import tempfile +import appdirs import os import sys import ctypes @@ -30,19 +30,49 @@ import glob import re import requests import click +import shutil +import json +import zipfile from datetime import datetime -from semver import compare, parse +from semver import compare from subprocess import check_output +# Useful constants. +#: The unique USB vendor ID for Adafruit boards. +VENDOR_ID = 9114 +#: The regex used to extract ``__version__`` and ``__repo__`` assignments. +DUNDER_ASSIGN_RE = re.compile(r"""^__\w+__\s*=\s*['"].+['"]$""") +#: Flag to indicate if the command is being run in verbose mode. +VERBOSE = False +#: The location of data files used by circup (following OS conventions). +DATA_DIR = appdirs.user_data_dir(appname="circup", appauthor="adafruit") +#: The path to the JSON file containing the metadata about the current bundle. +BUNDLE_DATA = os.path.join(DATA_DIR, "circup.json") +#: The path to the zip file containing the current library bundle. +BUNDLE_ZIP = os.path.join(DATA_DIR, "adafruit-circuitpython-bundle-py.zip") +#: The path to the directory into which the current bundle is unzipped. +BUNDLE_DIR = os.path.join(DATA_DIR, "adafruit_circuitpython_bundle") +#: The directory containing the utility's log file. +LOG_DIR = appdirs.user_log_dir(appname="circup", appauthor="adafruit") +#: The location of the log file for the utility. +LOGFILE = os.path.join(LOG_DIR, "circup.log") + + +# Ensure DATA_DIR / LOG_DIR related directories and files exist. +if not os.path.exists(DATA_DIR): # pragma: no cover + os.makedirs(DATA_DIR) +if not os.path.exists(LOG_DIR): # pragma: no cover + os.makedirs(LOG_DIR) + + +# Setup logging. logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -logfile = os.path.join(tempfile.gettempdir(), "circup.log") -logfile_handler = logging.FileHandler(logfile) +logfile_handler = logging.FileHandler(LOGFILE) log_formatter = logging.Formatter("%(levelname)s: %(message)s") logfile_handler.setFormatter(log_formatter) logger.addHandler(logfile_handler) -click.echo("Logging to {}\n".format(logfile)) # IMPORTANT @@ -58,33 +88,29 @@ __author__ = "Adafruit Industries" __email__ = "ntoll@ntoll.org" -#: The unique USB vendor ID for Adafruit boards. -VENDOR_ID = 9114 -#: The regex used to extract ``__version__`` and ``__repo__`` assignments. -DUNDER_ASSIGN_RE = re.compile(r"""^__\w+__\s*=\s*['"].+['"]$""") -#: Flag to indicate if the command is being run in verbose mode. -VERBOSE = False - - class Module: """ - Represents a CircuitPython module + Represents a CircuitPython module. """ - def __init__(self, path, repo, local_version, remote_version): + def __init__( + self, path, repo, device_version, bundle_version, bundle_path + ): """ :param str path: The path to the module on the connected CIRCUITPYTHON device. :param str repo: The URL of the Git repository for this module. - :param str local_version: The semver value for the local copy. - :param str remote_version: The semver value for the remote copy. + :param str device_version: The semver value for the version on device. + :param str bundle_version: The semver value for the version in bundle. + :param str bundle_path: The path to the bundle version of the module. """ self.path = path self.file = os.path.basename(path) self.name = self.file[:-3] self.repo = repo - self.local_version = local_version - self.remote_version = remote_version + self.device_version = device_version + self.bundle_version = bundle_version + self.bundle_path = bundle_path logger.info(self) @property @@ -92,9 +118,9 @@ class Module: """ Returns a boolean to indicate if this module is out of date. """ - if self.local_version and self.remote_version: + if self.device_version and self.bundle_version: try: - return compare(self.local_version, self.remote_version) < 0 + return compare(self.device_version, self.bundle_version) < 0 except ValueError as ex: logger.warning( "Module '{}' has incorrect semver value.".format(self.name) @@ -108,10 +134,26 @@ class Module: Returns a tuple of items to display in a table row to show the module's name, local version and remote version. """ - loc = self.local_version if self.local_version else "unknown" - rem = self.remote_version if self.remote_version else "unknown" + loc = self.device_version if self.device_version else "unknown" + rem = self.bundle_version if self.bundle_version else "unknown" return (self.name, loc, rem) + def update(self): + """ + Delete the module on the device, then copy the module from the bundle + back onto the device. + + The caller is expected to handle any exceptions raised. + """ + if os.path.isdir(self.path): + # Delete and copy the directory. + shutil.rmtree(self.path) + shutil.copytree(self.bundle_path, self.path) + else: + # Delete and copy file. + os.remove(self.path) + shutil.copyfile(self.bundle_path, self.path) + def __repr__(self): """ Helps with log files. @@ -122,8 +164,9 @@ class Module: "file": self.file, "name": self.name, "repo": self.repo, - "local_version": self.local_version, - "remote_version": self.remote_version, + "device_version": self.device_version, + "bundle_version": self.bundle_version, + "bundle_path": self.bundle_path, } ) @@ -199,25 +242,22 @@ def find_device(): return device_dir -def get_repos_file(repository, filename): +def get_latest_tag(): """ - Given a GitHub repository and a file contained therein, either returns the - content of that file, or raises an exception. + Find the value of the latest tag for the project at the given repository. - :param str repository: The full path to the GitHub repository. - :param str filename: The name of the file within the GitHub repository. - :return: The content of the file. + :param: str repository: The full path to the GitHub repository. + :return: The most recent tag value for the project. """ - # Extract the repository's path for the GitHub API. - owner, repos_name = repository.split("/")[-2:] - repos_path = "{}/{}".format(owner, repos_name.replace(".git", "")) - url = "https://raw.githubusercontent.com/{}/master/{}".format( - repos_path, filename + url = ( + "https://github.com/adafruit/Adafruit_CircuitPython_Bundle" + "/releases/latest" ) - logger.info("Requesting remote file: {}".format(url)) + logger.info("Requesting tag information: {}".format(url)) response = requests.get(url) - logger.info(response) - return response.text + tag = response.url.rsplit("/", 1)[-1] + logger.info("Tag: ".format(tag)) + return tag def extract_metadata(code): @@ -246,50 +286,149 @@ def extract_metadata(code): def find_modules(): """ - Returns a list of paths to ``.py`` modules in the ``lib`` directory on a - connected Adafruit device. + Extracts metadata from the connected device and available bundle and return + this as a list of Module instances representing the modules on the device. - :return: A list of filpaths to modules in the lib directory on the device. + :return: A list of Module instances describing the current state of the + modules on the connected device. + """ + try: + device_modules = get_device_versions() + bundle_modules = get_bundle_versions() + result = [] + for name, device_metadata in device_modules.items(): + if name in bundle_modules: + bundle_metadata = bundle_modules[name] + path = device_metadata["path"] + repo = device_metadata.get("__repo__") + device_version = device_metadata.get("__version__") + bundle_version = bundle_metadata.get("__version__") + bundle_path = bundle_metadata["path"] + result.append( + Module( + path, repo, device_version, bundle_version, bundle_path + ) + ) + return result + except Exception as ex: + # If it's not possible to get the device and bundle metadata, bail out + # with a friendly message and indication of what's gone wrong. + logger.error(ex) + click.echo("There was a problem. {}".format(ex)) + sys.exit(1) + + +def get_bundle_versions(): + """ + Returns a dictionary of metadata from modules in the latest known release + of the library bundle. + + :return: A dictionary of metadata about the modules available in the + library bundle. + """ + ensure_latest_bundle() + for path, subdirs, files in os.walk(BUNDLE_DIR): + if path.endswith("lib"): + break + return get_modules(path) + + +def get_device_versions(): + """ + Returns a dictionary of metadata from modules on the connected device. + + :return: A dictionary of metadata about the modules available on the + connected device. """ device_path = find_device() if device_path is None: raise IOError("Could not find a connected Adafruit device.") - return glob.glob(os.path.join(device_path, "lib", "*.py")) + return get_modules(os.path.join(device_path, "lib")) -def check_file_versions(filepath): +def get_modules(path): """ - Given a path to an Adafruit module file, extract the metadata and check - the latest version via GitHub. Return an instance of the Module class. + Get a dictionary containing metadata about all the Python modules found in + the referenced path. - :param str filepath: A path to an Adafruit module file. - :return: An instance of the Module class containing metadata. + :param str path: The directory in which to find modules. + :return: A dictionary containing metadata about the found modules. """ - with open(filepath) as source_file: - source_code = source_file.read() - metadata = extract_metadata(source_code) - module_file = os.path.basename(filepath) - module_name = module_file[:-3] - logger.info("Checking versions for module '{}'.".format(module_name)) - local_version = metadata.get("__version__", "") - try: - parse(local_version) - except ValueError: - local_version = None - repo = metadata.get("__repo__", "unknown") - remote_version = None - if local_version and repo: - remote_source = get_repos_file(repo, module_file) - remote_metadata = extract_metadata(remote_source) - remote_version = remote_metadata.get("__version__", "") - return Module(filepath, repo, local_version, remote_version) + single_file_mods = glob.glob(os.path.join(path, "*.py")) + directory_mods = glob.glob(os.path.join(path, "*", "")) + result = {} + for sfm in single_file_mods: + with open(sfm) as source_file: + source_code = source_file.read() + metadata = extract_metadata(source_code) + metadata["path"] = sfm + result[os.path.basename(sfm)] = metadata + for dm in directory_mods: + name = os.path.basename(dm) + result[name] = {} + for source in glob.glob(os.path.join(dm, "*.py")): + with open(source) as source_file: + source_code = source_file.read() + metadata = extract_metadata(source_code) + if "__version__": + metadata["path"] = dm + result[name] = metadata + break + return result -def check_module(module): +def ensure_latest_bundle(): """ - Shim. TODO: finish this. + Ensure that there's a copy of the latest library bundle available so circup + can check the metadata contained therein. """ - return check_file_versions(module) + logger.info("Checking for library updates.") + tag = get_latest_tag() + old_tag = "0" + if os.path.isfile(BUNDLE_DATA): + with open(BUNDLE_DATA) as data: + old_tag = json.load(data)["tag"] + if tag > old_tag: + logger.info("New version available ({}).".format(tag)) + get_bundle(tag) + with open(BUNDLE_DATA, "w") as data: + json.dump({"tag": tag}, data) + else: + logger.info("Current library bundle up to date ({}).".format(tag)) + + +def get_bundle(tag): # pragma: no cover + """ + Downloads and extracts the version of the bundle with the referenced tag. + + :param str tag: The GIT tag to use to download the bundle. + :return: The location of the resulting zip file in a temporary location on + the local filesystem. + """ + url = ( + "https://github.com/adafruit/Adafruit_CircuitPython_Bundle" + "/releases/download" + "/{tag}/adafruit-circuitpython-bundle-py-{tag}.zip".format(tag=tag) + ) + logger.info("Downloading bundle: {}".format(url)) + r = requests.get(url, stream=True) + if r.status_code != requests.codes.ok: + logger.warning("Unable to connect to {}".format(url)) + r.raise_for_status() + total_size = int(r.headers.get("Content-Length")) + click.echo("Downloading latest version information.\n") + with click.progressbar( + r.iter_content(1024), length=total_size + ) as bar, open(BUNDLE_ZIP, "wb") as f: + for chunk in bar: + f.write(chunk) + bar.update(len(chunk)) + logger.info("Saved to {}".format(BUNDLE_ZIP)) + if os.path.isdir(BUNDLE_DIR): + shutil.rmtree(BUNDLE_DIR) + with zipfile.ZipFile(BUNDLE_ZIP, "r") as zfile: + zfile.extractall(BUNDLE_DIR) + click.echo("\nOK\n") # ----------- CLI command definitions ----------- # @@ -322,6 +461,8 @@ def main(verbose): # pragma: no cover verbose_handler.setLevel(logging.INFO) verbose_handler.setFormatter(log_formatter) logger.addHandler(verbose_handler) + else: + click.echo("Logging to {}\n".format(LOGFILE)) logger.info("\n\n\nStarted {}".format(datetime.now())) @@ -332,18 +473,10 @@ def freeze(): # pragma: no cover device. """ logger.info("Freeze") - local_modules = find_modules() - for module in local_modules: - with open(module) as source_file: - source_code = source_file.read() - metadata = extract_metadata(source_code) - module_file = os.path.basename(module) - module_name = module_file[:-3] - output = "{}=={}".format( - module_name, metadata.get("__version__", "unknown") - ) - click.echo(output) - logger.info(output) + for module in find_modules(): + output = "{}=={}".format(module.name, module.device_version) + click.echo(output) + logger.info(output) @main.command() @@ -352,26 +485,10 @@ def list(): # pragma: no cover Lists all out of date modules found on the connected CIRCUITPYTHON device. """ logger.info("List") - local_modules = find_modules() - results = [] - click.echo("Found {} modules to check...\n".format(len(local_modules))) - if VERBOSE: - # No CLI effects, just allow logs to be emitted. - for item in local_modules: - module = check_module(item) - if module.outofdate: - results.append(module) - else: - # Use a progress bar instead. - with click.progressbar(local_modules) as bar: - for item in bar: - module = check_module(item) - if module.outofdate: - results.append(module) - # Nice tabular display. + # Grab out of date modules. data = [("Package", "Version", "Latest")] - for item in results: - data.append(item.row) + data += [m.row for m in find_modules() if m.outofdate] + # Nice tabular display. col_width = [0, 0, 0] for row in data: for i, word in enumerate(row): @@ -389,7 +506,6 @@ def list(): # pragma: no cover if not VERBOSE: click.echo(output) logger.info(output) - click.echo("\n✨ 🍰 ✨") @main.command() @@ -399,3 +515,20 @@ def update(): # pragma: no cover prompts the user to confirm updating such modules. """ logger.info("Update") + # Grab out of date modules. + modules = [m for m in find_modules() if m.outofdate] + click.echo("\nFound {} module[s] needing update.".format(len(modules))) + if modules: + click.echo("Please indicate which modules you wish to update:\n") + for module in modules: + if click.confirm("Update '{}'?".format(module.name)): + try: + module.update() + click.echo("OK") + except Exception as ex: + logger.error(ex) + click.echo( + "Something went wrong {} (check the logs)".format( + str(ex) + ) + ) diff --git a/setup.py b/setup.py index 17e5686..dea93db 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ with open(os.path.join(base_dir, "CHANGES.rst"), encoding="utf8") as f: changes = f.read() -install_requires = ["PyGithub>=1.43.8", "semver>=2.8.1", "Click==7.0"] +install_requires = ["semver>=2.8.1", "Click==7.0", "appdirs>=1.4.3"] extras_require = { "tests": [ diff --git a/tests/bundle.json b/tests/bundle.json new file mode 100644 index 0000000..05b1bfc --- /dev/null +++ b/tests/bundle.json @@ -0,0 +1,12 @@ +{ + "adafruit_74hc595.py": { + "__version__": "1.0.2", + "__repo__": "https://github.com/adafruit/Adafruit_CircuitPython_74HC595.git", + "path": "/home/ntoll/.local/share/circup/adafruit_circuitpython_bundle/adafruit-circuitpython-bundle-py-20190903/lib/adafruit_74hc595.py" + }, + "adafruit_lsm303.py": { + "__version__": "1.2.5", + "__repo__": "https://github.com/adafruit/Adafruit_CircuitPython_LSM303.git", + "path": "/home/ntoll/.local/share/circup/adafruit_circuitpython_bundle/adafruit-circuitpython-bundle-py-20190903/lib/adafruit_lsm303.py" + } +} diff --git a/tests/device.json b/tests/device.json new file mode 100644 index 0000000..d500419 --- /dev/null +++ b/tests/device.json @@ -0,0 +1,7 @@ +{ + "adafruit_74hc595.py": { + "__version__": "1.0.2", + "__repo__": "https://github.com/adafruit/Adafruit_CircuitPython_74HC595.git", + "path": "/media/ntoll/CIRCUITPY/lib/adafruit_74hc595.py" + } +} diff --git a/tests/test_circup.py b/tests/test_circup.py index 49fe893..5061ce0 100644 --- a/tests/test_circup.py +++ b/tests/test_circup.py @@ -25,6 +25,7 @@ import os import circup import ctypes import pytest +import json from unittest import mock @@ -34,17 +35,21 @@ def test_Module_init(): """ path = os.path.join("foo", "bar", "baz", "module.py") repo = "https://github.com/adafruit/SomeLibrary.git" - local_version = "1.2.3" - remote_version = "3.2.1" + device_version = "1.2.3" + bundle_version = "3.2.1" + bundle_path = os.path.join("baz", "bar", "foo", "module.py") with mock.patch("circup.logger.info") as mock_logger: - m = circup.Module(path, repo, local_version, remote_version) + m = circup.Module( + path, repo, device_version, bundle_version, bundle_path + ) mock_logger.assert_called_once_with(m) assert m.path == path assert m.file == "module.py" assert m.name == "module" assert m.repo == repo - assert m.local_version == local_version - assert m.remote_version == remote_version + assert m.device_version == device_version + assert m.bundle_version == bundle_version + assert m.bundle_path == bundle_path def test_Module_outofdate(): @@ -55,9 +60,11 @@ def test_Module_outofdate(): """ path = os.path.join("foo", "bar", "baz", "module.py") repo = "https://github.com/adafruit/SomeLibrary.git" - m1 = circup.Module(path, repo, "1.2.3", "3.2.1") - m2 = circup.Module(path, repo, "1.2.3", "1.2.3") - m3 = circup.Module(path, repo, "3.2.1", "1.2.3") # shouldn't happen! + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m1 = circup.Module(path, repo, "1.2.3", "3.2.1", bundle_path) + m2 = circup.Module(path, repo, "1.2.3", "1.2.3", bundle_path) + # shouldn't happen! + m3 = circup.Module(path, repo, "3.2.1", "1.2.3", bundle_path) assert m1.outofdate is True assert m2.outofdate is False assert m3.outofdate is False @@ -71,7 +78,10 @@ def test_Module_outofdate_bad_versions(): """ path = os.path.join("foo", "bar", "baz", "module.py") repo = "https://github.com/adafruit/SomeLibrary.git" - m = circup.Module(path, repo, "1.2.3", "hello") + device_version = "hello" + bundle_version = "3.2.1" + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m = circup.Module(path, repo, device_version, bundle_version, bundle_path) with mock.patch("circup.logger.warning") as mock_logger: assert m.outofdate is True assert mock_logger.call_count == 2 @@ -84,26 +94,70 @@ def test_Module_row(): """ path = os.path.join("foo", "bar", "baz", "module.py") repo = "https://github.com/adafruit/SomeLibrary.git" - m = circup.Module(path, repo, "1.2.3", "") + device_version = "1.2.3" + bundle_version = None + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m = circup.Module(path, repo, device_version, bundle_version, bundle_path) assert m.row == ("module", "1.2.3", "unknown") +def test_Module_update_dir(): + """ + Ensure if the module is a directory, the expected actions take place to + update the module on the connected device. + """ + path = os.path.join("foo", "bar", "baz", "module.py") + repo = "https://github.com/adafruit/SomeLibrary.git" + device_version = "1.2.3" + bundle_version = None + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m = circup.Module(path, repo, device_version, bundle_version, bundle_path) + with mock.patch("circup.shutil") as mock_shutil, mock.patch( + "circup.os.path.isdir", return_value=True + ): + m.update() + mock_shutil.rmtree.assert_called_once_with(m.path) + mock_shutil.copytree.assert_called_once_with(m.bundle_path, m.path) + + +def test_Module_update_file(): + """ + Ensure if the module is a file, the expected actions take place to + update the module on the connected device. + """ + path = os.path.join("foo", "bar", "baz", "module.py") + repo = "https://github.com/adafruit/SomeLibrary.git" + device_version = "1.2.3" + bundle_version = None + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m = circup.Module(path, repo, device_version, bundle_version, bundle_path) + with mock.patch("circup.shutil") as mock_shutil, mock.patch( + "circup.os.remove" + ) as mock_remove, mock.patch("circup.os.path.isdir", return_value=False): + m.update() + mock_remove.assert_called_once_with(m.path) + mock_shutil.copyfile.assert_called_once_with(m.bundle_path, m.path) + + def test_Module_repr(): """ + Ensure the repr(dict) is returned (helps when logging). """ path = os.path.join("foo", "bar", "baz", "module.py") repo = "https://github.com/adafruit/SomeLibrary.git" - local_version = "1.2.3" - remote_version = "3.2.1" - m = circup.Module(path, repo, local_version, remote_version) + device_version = "1.2.3" + bundle_version = "3.2.1" + bundle_path = os.path.join("baz", "bar", "foo", "module.py") + m = circup.Module(path, repo, device_version, bundle_version, bundle_path) assert repr(m) == repr( { "path": path, "file": "module.py", "name": "module", "repo": repo, - "local_version": local_version, - "remote_version": remote_version, + "device_version": device_version, + "bundle_version": bundle_version, + "bundle_path": bundle_path, } ) @@ -195,25 +249,24 @@ def test_find_device_unknown_os(): assert ex.value.args[0] == 'OS "foo" not supported.' -def test_get_repos_file(): +def test_get_latest_tag(): """ - Ensure the repository path and filename are handled in such a way to create - the expected and correct calls to the GitHub API. + Ensure the expected tag value is extracted from the returned URL (resulting + from a call to the expected endpoint). """ - repository = "https://github.com/adafruit/SomeLibrary.git" - filename = "somelibrary.py" - mock_response = mock.MagicMock() - mock_response.text = "# Python content of the file\n" - url = ( - "https://raw.githubusercontent.com/" - "adafruit/SomeLibrary/master/somelibrary.py" + response = mock.MagicMock() + response.url = ( + "https://github.com/adafruit" + "/Adafruit_CircuitPython_Bundle/releases/tag/20190903" ) - with mock.patch( - "circup.requests.get", return_value=mock_response - ) as mock_get: - result = circup.get_repos_file(repository, filename) - assert result == mock_response.text - mock_get.assert_called_once_with(url) + expected_url = ( + "https://github.com/adafruit/Adafruit_CircuitPython_Bundle" + "/releases/latest" + ) + with mock.patch("circup.requests.get", return_value=response) as mock_get: + result = circup.get_latest_tag() + assert result == "20190903" + mock_get.assert_called_once_with(expected_url) def test_extract_metadata(): @@ -235,77 +288,128 @@ def test_extract_metadata(): def test_find_modules(): """ - Ensure the result of the glob.glob call is returned, and the call is made - with the expected path. + Ensure that the expected list of Module instances is returned given the + metadata dictionary fixtures for device and bundle modules. """ - glob_result = ["module1.py", "module2.py"] - with mock.patch("circup.find_device", return_value="foo"), mock.patch( - "circup.glob.glob", return_value=glob_result - ) as mock_glob: - circup.find_modules() - mock_glob.assert_called_once_with(os.path.join("foo", "lib", "*.py")) - - -def test_find_modules_no_device_connected(): - """ - Ensure an IOError is raised if there's no connected device which can be - checked. - """ - with mock.patch("circup.find_device", return_value=None), pytest.raises( - IOError - ) as ex: - circup.find_modules() - assert ex.value.args[0] == "Could find a connected Adafruit device." - - -def test_check_file_versions(): - """ - Ensure the expected calls are made for extracting both the local and - remote version information for the referenced single file module. This - should be returned as an instance of circup.Module. - - The local_module.py and remote_module.py "fixture" files contain versions: - ``"1.2.3"`` and ``"2.3.4"`` respectively. The referenced GitHub repository - is: ``"https://github.com/adafruit/SomeLibrary.git"`` - """ - filepath = "tests/local_module.py" - with open("tests/remote_module.py") as remote_module: - remote_source = remote_module.read() + with open("tests/device.json") as f: + device_modules = json.load(f) + with open("tests/bundle.json") as f: + bundle_modules = json.load(f) with mock.patch( - "circup.get_repos_file", return_value=remote_source - ) as mock_grf: - result = circup.check_file_versions(filepath) - assert isinstance(result, circup.Module) - assert repr(result) == repr( - circup.Module( - filepath, - "https://github.com/adafruit/SomeLibrary.git", - "1.2.3", - "2.3.4", - ) - ) - mock_grf.assert_called_once_with( - "https://github.com/adafruit/SomeLibrary.git", "local_module.py" - ) + "circup.get_device_versions", return_value=device_modules + ), mock.patch("circup.get_bundle_versions", return_value=bundle_modules): + result = circup.find_modules() + assert len(result) == 1 + assert result[0].name == "adafruit_74hc595" -def test_check_file_versions_unknown_version(): +def test_find_modules_goes_bang(): """ - If no version information is available from the local file, the resulting - circup.Module class has None set against the two potentail versions (local - and remote). + Ensure if there's a problem getting metadata an error message is displayed + and the utility exists with an error code of 1. """ - filepath = "tests/local_module.py" - with mock.patch("circup.extract_metadata", return_value={}): - result = circup.check_file_versions(filepath) - assert result.local_version is None - assert result.remote_version is None + with mock.patch( + "circup.get_device_versions", side_effect=Exception("BANG!") + ), mock.patch("circup.click") as mock_click, mock.patch( + "circup.sys.exit" + ) as mock_exit: + circup.find_modules() + assert mock_click.echo.call_count == 1 + mock_exit.assert_called_once_with(1) -def test_check_module(): +def test_get_bundle_versions(): """ - TODO: Finish this. + Ensure get_modules is called with the path for the library bundle. """ - with mock.patch("circup.check_file_versions") as mock_cfv: - circup.check_module("foo") - mock_cfv.assert_called_once_with("foo") + dirs = (("foo/bar/lib", "", ""),) + with mock.patch("circup.ensure_latest_bundle"), mock.patch( + "circup.os.walk", return_value=dirs + ) as mock_walk, mock.patch( + "circup.get_modules", return_value="ok" + ) as mock_gm: + assert circup.get_bundle_versions() == "ok" + mock_walk.assert_called_once_with(circup.BUNDLE_DIR) + mock_gm.assert_called_once_with("foo/bar/lib") + + +def test_get_device_versions(): + """ + Ensure get_modules is called with the path for the attached device. + """ + with mock.patch( + "circup.find_device", return_value="CIRCUITPYTHON" + ), mock.patch("circup.get_modules", return_value="ok") as mock_gm: + assert circup.get_device_versions() == "ok" + mock_gm.assert_called_once_with(os.path.join("CIRCUITPYTHON", "lib")) + + +def test_get_device_versions_go_bang(): + """ + If it's not possible to find a connected device, ensure an IOError is + raised. + """ + with mock.patch("circup.find_device", return_value=None): + with pytest.raises(IOError): + circup.get_device_versions() + + +def test_get_modules(): + """ + Check the expected dictionary containing metadata is returned given the + (mocked) results of glob and open. + """ + path = "foo" + mods = ["tests/local_module.py"] + with mock.patch("circup.glob.glob", return_value=mods): + result = circup.get_modules(path) + assert len(result) == 1 # dict key is reused. + + +def test_ensure_latest_bundle_no_bundle_data(): + """ + If there's no BUNDLE_DATA file (containing previous current version of the + bundle) then default to update. + """ + with mock.patch("circup.get_latest_tag", return_value="12345"), mock.patch( + "circup.os.path.isfile", return_value=False + ), mock.patch("circup.get_bundle") as mock_gb, mock.patch( + "circup.json" + ) as mock_json: + circup.ensure_latest_bundle() + mock_gb.assert_called_once_with("12345") + assert mock_json.dump.call_count == 1 # Current version saved to file. + + +def test_ensure_latest_bundle_to_update(): + """ + If the version found in the BUNDLE_DATA is out of date, the cause an update + to the bundle. + """ + with mock.patch("circup.get_latest_tag", return_value="54321"), mock.patch( + "circup.os.path.isfile", return_value=True + ), mock.patch("circup.get_bundle") as mock_gb, mock.patch( + "circup.json" + ) as mock_json: + mock_json.load.return_value = {"tag": "12345"} + circup.ensure_latest_bundle() + mock_gb.assert_called_once_with("54321") + assert mock_json.dump.call_count == 1 # Current version saved to file. + + +def test_ensure_latest_bundle_no_update(): + """ + If the version found in the BUNDLE_DATA is NOT out of date, just log the + fact and don't update. + """ + with mock.patch("circup.get_latest_tag", return_value="12345"), mock.patch( + "circup.os.path.isfile", return_value=True + ), mock.patch("circup.get_bundle") as mock_gb, mock.patch( + "circup.json" + ) as mock_json, mock.patch( + "circup.logger" + ) as mock_logger: + mock_json.load.return_value = {"tag": "12345"} + circup.ensure_latest_bundle() + assert mock_gb.call_count == 0 + assert mock_logger.info.call_count == 2