From 53a6d01fa3c7857a55342bb8381b98c33505efd0 Mon Sep 17 00:00:00 2001 From: Matt Weiden <538456+mweiden@users.noreply.github.com> Date: Tue, 31 Dec 2019 17:43:11 -0800 Subject: [PATCH] Notify users of new versions of cellxgene (#1078) * Notify users of new versions of cellxgene Fixes https://github.com/chanzuckerberg/cellxgene/issues/683 * Do not use PyGithub client * Protect against AttributeError * Document that all version tags must follow SemVer * Release tags `should -> MUST` follow semantic versioning --- dev_docs/release_process.md | 4 +- server/cli/cli.py | 12 +++-- server/cli/upgrade.py | 84 +++++++++++++++++++++++++++++++++ server/test/test_api.py | 12 ++++- server/test/test_cli_upgrade.py | 28 +++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 server/cli/upgrade.py create mode 100644 server/test/test_cli_upgrade.py diff --git a/dev_docs/release_process.md b/dev_docs/release_process.md index 0e1ab907..2b67adfc 100644 --- a/dev_docs/release_process.md +++ b/dev_docs/release_process.md @@ -9,11 +9,13 @@ to PyPi, with a matching tagged release on github. The release process should result in the following side-effects: -- Version number bump, using semantic versioning +- Version number bump, using [semantic versioning](https://semver.org/) - JS assets built & packaged, committed to the repo - Tagged github release - Publication to PyPi +Note all release tags pushed to GitHub MUST follow semantic versioning. + ## Recipe Follow these steps to create a release. diff --git a/server/cli/cli.py b/server/cli/cli.py index 439587d0..9bb9105a 100644 --- a/server/cli/cli.py +++ b/server/cli/cli.py @@ -1,7 +1,9 @@ import click +from .. import __version__ from .launch import launch from .prepare import prepare +from .upgrade import log_upgrade_check @click.group( @@ -12,13 +14,17 @@ from .prepare import prepare ) @click.help_option("--help", "-h", help="Show this message and exit.") @click.version_option( - version="0.13.0", + version=__version__, prog_name="cellxgene", message="[%(prog)s] Version %(version)s", help="Show the software version and exit.", ) -def cli(): - pass +@click.option( + "--upgrade-check/--no-upgrade-check", default=True, show_default=True, help="Check for release upgrades on start.", +) +def cli(upgrade_check): + if upgrade_check: + log_upgrade_check() cli.add_command(launch) diff --git a/server/cli/upgrade.py b/server/cli/upgrade.py new file mode 100644 index 00000000..e20aacb0 --- /dev/null +++ b/server/cli/upgrade.py @@ -0,0 +1,84 @@ +import click +import re +import requests + +from requests.exceptions import ConnectionError +from .. import __version__ + +# Official SemVer regex: https://semver.org/ +SEMVER_FORMAT = re.compile( + r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)" + + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" +) + + +def log_upgrade_check(): + # Sanity-check that the CLI version is a properly-formatted SemVer string + assert validate_version_str(__version__, release_only=False) + + # Get the current latest release + try: + release_tag_generator = (r['tag_name'] for r in _request_cellxgene_releases()) + latest_release = next(release_tag_generator, lambda tag_name: validate_version_str(tag_name)) + if version_gt(latest_release, __version__): + click.echo(f"There's a new version of cellxgene available ({latest_release})!") + click.echo("To upgrade, run the following: pip install --upgrade cellxgene\n") + except (ConnectionError, RateLimitException): + click.echo("Upgrade check failed.\n") + + +class RateLimitException(Exception): + """ + Github API Rate Limit Exception + """ + + +def _request_cellxgene_releases(): + def raise_on_rate_limit(response): + if response.status_code == 403 and res.headers.get('X-RateLimit-Remaining') == '0': + raise RateLimitException + url = "https://api.github.com/repos/chanzuckerberg/cellxgene/releases" + res = requests.get(url) + raise_on_rate_limit(res) + for release in res.json(): + yield release + while 'next' in res.links.keys(): + res = requests.get(res.links['next']['url']) + raise_on_rate_limit(res) + for release in res.json(): + yield release + + +def validate_version_str(version_str, release_only=True): + """ + Test if a string conforms to SemVer format (https://semver.org/) + :param version_str: a string to be validated + :param release_only: only declare releases (not prereleases) valid + :return: True if the version string is of a valid SemVer format else False + """ + match = SEMVER_FORMAT.match(version_str) + has_match = match is not None + if has_match and release_only: + return not match.group("prerelease") + return has_match + + +def split_version(version_string): + """ + Split a SemVer-formatted string into its component integers + :param version_string: a SemVer string to be split + :return: an array of three integers + """ + match = SEMVER_FORMAT.match(version_string) + return [int(match.group(group)) for group in ["major", "minor", "patch"]] + + +def version_gt(left_version, right_version): + for left, right in zip(split_version(left_version), split_version(right_version)): + if left > right: + return True + elif right > left: + return False + return False diff --git a/server/test/test_api.py b/server/test/test_api.py index dea00132..d1482ba1 100644 --- a/server/test/test_api.py +++ b/server/test/test_api.py @@ -19,7 +19,17 @@ class EndPoints(unittest.TestCase): @classmethod def setUpClass(cls): - cls.ps = Popen(["cellxgene", "launch", "../example-dataset/pbmc3k.h5ad", "--verbose", "--port", "5005"]) + cls.ps = Popen( + [ + "cellxgene", + "--no-upgrade-check", + "launch", + "../example-dataset/pbmc3k.h5ad", + "--verbose", + "--port", + "5005", + ] + ) session = requests.Session() for i in range(90): try: diff --git a/server/test/test_cli_upgrade.py b/server/test/test_cli_upgrade.py new file mode 100644 index 00000000..c3c93e17 --- /dev/null +++ b/server/test/test_cli_upgrade.py @@ -0,0 +1,28 @@ +import unittest + +from server.cli.upgrade import validate_version_str, split_version, version_gt + + +class CLIUpgradeTests(unittest.TestCase): + """ Test cases for CLI logic """ + + def test_validate_version_str(self): + self.assertTrue(validate_version_str("0.1.2")) + self.assertTrue(validate_version_str("0.1.2-RC", release_only=False)) + self.assertFalse(validate_version_str("0.1")) + self.assertFalse(validate_version_str("0.1.2.3")) + self.assertFalse(validate_version_str("0.1.2-RC")) + + def test_split_version_str(self): + self.assertEqual(split_version("0.1.2"), [0, 1, 2]) + with self.assertRaises(AttributeError): + split_version("0.1") + + def test_assert_verstion_gt(self): + self.assertTrue(version_gt("1.0.0", "0.1.1")) + self.assertTrue(version_gt("0.1.0", "0.0.1")) + self.assertTrue(version_gt("0.0.1", "0.0.0")) + self.assertFalse(version_gt("0.0.0", "0.0.0")) + self.assertFalse(version_gt("0.0.0", "0.0.1")) + self.assertFalse(version_gt("0.0.1", "0.1.0")) + self.assertFalse(version_gt("0.1.1", "1.0.0"))