From e80ac288c11383dcb7aad5b7c8547189ab300beb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:57:49 +0000 Subject: [PATCH 01/11] Initial plan From 2d10272f199b84a40fe92842d9a0ae7e987d10ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:59:27 +0000 Subject: [PATCH 02/11] Remove requests dependency from cli50 startup Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 17 ++++++++++++----- setup.py | 2 +- tests/test_main.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/test_main.py diff --git a/cli50/__main__.py b/cli50/__main__.py index ee300c5..ced9bc1 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -5,12 +5,14 @@ import argparse import gettext +import json import os import re -import requests import shutil import subprocess import tzlocal +import urllib.error +import urllib.request from importlib.resources import files from packaging import version @@ -59,9 +61,9 @@ def main(): # Check PyPI for newer version if __version__ and not args["fast"]: try: - release = max(requests.get("https://pypi.org/pypi/cli50/json").json()["releases"], key=version.parse) + release = max(pypi_releases(), key=version.parse) assert release <= __version__ - except requests.RequestException: + except (OSError, urllib.error.URLError): pass except AssertionError: try: @@ -323,7 +325,6 @@ def ports(container): def pull(image, tag): """Pull image as needed.""" - import json try: # Get the latest manifest from registry @@ -338,11 +339,17 @@ def pull(image, tag): # Pull latest if local image id does not match any digest in the manifest assert localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest] == True - except (AssertionError, requests.exceptions.ConnectionError, subprocess.CalledProcessError): + except (AssertionError, OSError, urllib.error.URLError, subprocess.CalledProcessError): # Pull image subprocess.call(["docker", "pull", f"{image}:{tag}"], stderr=subprocess.DEVNULL) +def pypi_releases(): + """Return release versions published to PyPI.""" + with urllib.request.urlopen("https://pypi.org/pypi/cli50/json") as response: + return json.load(response)["releases"] + + if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index c8af0e6..52d5ac8 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ description="This is CS50 CLI, with which you can mount a directory inside of an Ubuntu container.", long_description=open("README.md").read(), license="GPLv3", - install_requires=["inflect", "packaging", "requests", "tzlocal"], + install_requires=["inflect", "packaging", "tzlocal"], keywords="cli50", name="cli50", python_requires=">=3.8", diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..015446b --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,15 @@ +import io +import unittest +from unittest import mock + +from cli50 import __main__ + + +class PypiReleasesTestCase(unittest.TestCase): + def test_pypi_releases_uses_standard_library_response(self): + payload = io.BytesIO(b'{"releases": {"8.0.1": [], "8.0.0": []}}') + + with mock.patch("urllib.request.urlopen", return_value=payload) as urlopen: + self.assertEqual(__main__.pypi_releases(), {"8.0.1": [], "8.0.0": []}) + + urlopen.assert_called_once_with("https://pypi.org/pypi/cli50/json") From ae5378c20eab5d17b57639ed904f9078660ed347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:00:26 +0000 Subject: [PATCH 03/11] Harden PyPI update check handling Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 5 ++--- tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index ced9bc1..fd87b84 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -63,7 +63,7 @@ def main(): try: release = max(pypi_releases(), key=version.parse) assert release <= __version__ - except (OSError, urllib.error.URLError): + except (OSError, urllib.error.URLError, json.JSONDecodeError, KeyError, TypeError, ValueError): pass except AssertionError: try: @@ -172,7 +172,6 @@ def main(): if not args["fast"]: # Remote manifest - import json try: RemoteManifest = json.loads(subprocess.check_output([ "docker", "manifest", "inspect", f"{IMAGE}:{args['tag']}", "--verbose" @@ -339,7 +338,7 @@ def pull(image, tag): # Pull latest if local image id does not match any digest in the manifest assert localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest] == True - except (AssertionError, OSError, urllib.error.URLError, subprocess.CalledProcessError): + except (AssertionError, subprocess.CalledProcessError): # Pull image subprocess.call(["docker", "pull", f"{image}:{tag}"], stderr=subprocess.DEVNULL) diff --git a/tests/test_main.py b/tests/test_main.py index 015446b..6845c2a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,7 @@ import io +import json +import os +import argparse import unittest from unittest import mock @@ -13,3 +16,32 @@ def test_pypi_releases_uses_standard_library_response(self): self.assertEqual(__main__.pypi_releases(), {"8.0.1": [], "8.0.0": []}) urlopen.assert_called_once_with("https://pypi.org/pypi/cli50/json") + + def test_main_ignores_invalid_pypi_json(self): + self._assert_update_check_failure_is_ignored( + json.JSONDecodeError("Expecting value", "", 0) + ) + + def test_main_ignores_missing_pypi_releases(self): + self._assert_update_check_failure_is_ignored(KeyError("releases")) + + def _assert_update_check_failure_is_ignored(self, error): + args = argparse.Namespace( + directory=os.getcwd(), + dotfile=[], + fast=False, + jekyll=False, + login=False, + port=[], + stop=False, + tag=__main__.TAG, + ) + + with mock.patch.object(__main__, "__version__", "8.0.1"), \ + mock.patch("argparse.ArgumentParser.parse_args", return_value=args), \ + mock.patch.object(__main__, "pypi_releases", side_effect=error), \ + mock.patch("shutil.which", return_value=None), \ + self.assertRaises(SystemExit) as raised: + __main__.main() + + self.assertEqual(raised.exception.code, 2) From d91700ea340fbd49e5d6341458f88af3a198919f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:01:01 +0000 Subject: [PATCH 04/11] Decode PyPI response before JSON parsing Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index fd87b84..8b3ce2e 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -347,7 +347,7 @@ def pull(image, tag): def pypi_releases(): """Return release versions published to PyPI.""" with urllib.request.urlopen("https://pypi.org/pypi/cli50/json") as response: - return json.load(response)["releases"] + return json.loads(response.read().decode("utf-8"))["releases"] if __name__ == "__main__": From 4236f38484fcfb6826f4246b79c648f32375d14c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:01:43 +0000 Subject: [PATCH 05/11] Add timeout to PyPI version check Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- tests/test_main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 8b3ce2e..8b249dc 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -346,7 +346,7 @@ def pull(image, tag): def pypi_releases(): """Return release versions published to PyPI.""" - with urllib.request.urlopen("https://pypi.org/pypi/cli50/json") as response: + with urllib.request.urlopen("https://pypi.org/pypi/cli50/json", timeout=10) as response: return json.loads(response.read().decode("utf-8"))["releases"] diff --git a/tests/test_main.py b/tests/test_main.py index 6845c2a..43767aa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,7 +15,7 @@ def test_pypi_releases_uses_standard_library_response(self): with mock.patch("urllib.request.urlopen", return_value=payload) as urlopen: self.assertEqual(__main__.pypi_releases(), {"8.0.1": [], "8.0.0": []}) - urlopen.assert_called_once_with("https://pypi.org/pypi/cli50/json") + urlopen.assert_called_once_with("https://pypi.org/pypi/cli50/json", timeout=10) def test_main_ignores_invalid_pypi_json(self): self._assert_update_check_failure_is_ignored( From 16c7f535d7253a1d398934d0238f1399fa2b5bbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:02:25 +0000 Subject: [PATCH 06/11] Compare PyPI versions semantically Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- tests/test_main.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 8b249dc..c21c428 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -62,7 +62,7 @@ def main(): if __version__ and not args["fast"]: try: release = max(pypi_releases(), key=version.parse) - assert release <= __version__ + assert version.parse(release) <= version.parse(__version__) except (OSError, urllib.error.URLError, json.JSONDecodeError, KeyError, TypeError, ValueError): pass except AssertionError: diff --git a/tests/test_main.py b/tests/test_main.py index 43767aa..fd3bbd1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -25,6 +25,29 @@ def test_main_ignores_invalid_pypi_json(self): def test_main_ignores_missing_pypi_releases(self): self._assert_update_check_failure_is_ignored(KeyError("releases")) + def test_main_compares_versions_semantically(self): + args = argparse.Namespace( + directory=os.getcwd(), + dotfile=[], + fast=False, + jekyll=False, + login=False, + port=[], + stop=False, + tag=__main__.TAG, + ) + + with mock.patch.object(__main__, "__version__", "10.0.0"), \ + mock.patch("argparse.ArgumentParser.parse_args", return_value=args), \ + mock.patch.object(__main__, "pypi_releases", return_value={"9.9.9": []}), \ + mock.patch("shutil.which", return_value=None), \ + mock.patch("builtins.input") as prompt, \ + self.assertRaises(SystemExit) as raised: + __main__.main() + + self.assertEqual(raised.exception.code, 2) + prompt.assert_not_called() + def _assert_update_check_failure_is_ignored(self, error): args = argparse.Namespace( directory=os.getcwd(), From 2611266e9a6ab5b72059eea9ca43b6ae568b929c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:03:26 +0000 Subject: [PATCH 07/11] Use explicit update version check Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index c21c428..82e595d 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -62,18 +62,20 @@ def main(): if __version__ and not args["fast"]: try: release = max(pypi_releases(), key=version.parse) - assert version.parse(release) <= version.parse(__version__) + if version.parse(release) <= version.parse(__version__): + release = None except (OSError, urllib.error.URLError, json.JSONDecodeError, KeyError, TypeError, ValueError): pass - except AssertionError: - try: - response = input("A newer version of cli50 is available. Upgrade now? [Y/n] ") - except EOFError: - pass - else: - if response.strip().lower() not in ["n", "no"]: - print("Run `pip3 install --upgrade cli50` to upgrade. Then re-run cli50.") - sys.exit(0) + else: + if release is not None: + try: + response = input("A newer version of cli50 is available. Upgrade now? [Y/n] ") + except EOFError: + pass + else: + if response.strip().lower() not in ["n", "no"]: + print("Run `pip3 install --upgrade cli50` to upgrade. Then re-run cli50.") + sys.exit(0) # Check if Docker installed if not shutil.which("docker"): From 4b3a64fa43f59b456c19ffe24bc7d0ce7b74a5a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:04:22 +0000 Subject: [PATCH 08/11] Preserve docker manifest pull fallback Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 11 ++++++----- tests/test_main.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 82e595d..0fa367a 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -337,13 +337,14 @@ def pull(image, tag): localImageId = json.loads(subprocess.check_output([ "docker", "inspect", f"{image}:{tag}"], stderr=subprocess.DEVNULL).decode("utf-8"))[0]['Id'] - # Pull latest if local image id does not match any digest in the manifest - assert localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest] == True + if localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest]: + return - except (AssertionError, subprocess.CalledProcessError): + except (IndexError, KeyError, TypeError, json.JSONDecodeError, subprocess.SubprocessError): + pass - # Pull image - subprocess.call(["docker", "pull", f"{image}:{tag}"], stderr=subprocess.DEVNULL) + # Pull image + subprocess.call(["docker", "pull", f"{image}:{tag}"], stderr=subprocess.DEVNULL) def pypi_releases(): diff --git a/tests/test_main.py b/tests/test_main.py index fd3bbd1..e38b54b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,7 @@ import json import os import argparse +import subprocess import unittest from unittest import mock @@ -48,6 +49,17 @@ def test_main_compares_versions_semantically(self): self.assertEqual(raised.exception.code, 2) prompt.assert_not_called() + def test_pull_falls_back_when_manifest_lookup_fails(self): + error = subprocess.CalledProcessError(1, ["docker", "manifest", "inspect"]) + + with mock.patch("subprocess.check_output", side_effect=error), \ + mock.patch("subprocess.call") as docker_pull: + __main__.pull("cs50/cli", "latest") + + docker_pull.assert_called_once_with( + ["docker", "pull", "cs50/cli:latest"], stderr=__main__.subprocess.DEVNULL + ) + def _assert_update_check_failure_is_ignored(self, error): args = argparse.Namespace( directory=os.getcwd(), From 1e77ae2f5f0500f14f0e5a7a6aef7ad5b0156f63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:05:10 +0000 Subject: [PATCH 09/11] Narrow docker manifest fallback handling Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- tests/test_main.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 0fa367a..7971fde 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -340,7 +340,7 @@ def pull(image, tag): if localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest]: return - except (IndexError, KeyError, TypeError, json.JSONDecodeError, subprocess.SubprocessError): + except subprocess.SubprocessError: pass # Pull image diff --git a/tests/test_main.py b/tests/test_main.py index e38b54b..9c2145b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -60,6 +60,17 @@ def test_pull_falls_back_when_manifest_lookup_fails(self): ["docker", "pull", "cs50/cli:latest"], stderr=__main__.subprocess.DEVNULL ) + def test_pull_does_not_hide_invalid_manifest_data(self): + manifest = b'{"unexpected": []}' + local = b'[{"Id": "sha256:test"}]' + + with mock.patch("subprocess.check_output", side_effect=[manifest, local]), \ + mock.patch("subprocess.call") as docker_pull, \ + self.assertRaises((KeyError, TypeError)): + __main__.pull("cs50/cli", "latest") + + docker_pull.assert_not_called() + def _assert_update_check_failure_is_ignored(self, error): args = argparse.Namespace( directory=os.getcwd(), From 1cf0da9556c1fe68cbbcbbb4e9d4f42fbed4ea3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:05:53 +0000 Subject: [PATCH 10/11] Handle docker manifest execution failures Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- tests/test_main.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 7971fde..9b3bba7 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -340,7 +340,7 @@ def pull(image, tag): if localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest]: return - except subprocess.SubprocessError: + except (OSError, subprocess.SubprocessError): pass # Pull image diff --git a/tests/test_main.py b/tests/test_main.py index 9c2145b..1b4007a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -60,6 +60,15 @@ def test_pull_falls_back_when_manifest_lookup_fails(self): ["docker", "pull", "cs50/cli:latest"], stderr=__main__.subprocess.DEVNULL ) + def test_pull_falls_back_when_manifest_command_cannot_run(self): + with mock.patch("subprocess.check_output", side_effect=OSError("docker unavailable")), \ + mock.patch("subprocess.call") as docker_pull: + __main__.pull("cs50/cli", "latest") + + docker_pull.assert_called_once_with( + ["docker", "pull", "cs50/cli:latest"], stderr=__main__.subprocess.DEVNULL + ) + def test_pull_does_not_hide_invalid_manifest_data(self): manifest = b'{"unexpected": []}' local = b'[{"Id": "sha256:test"}]' From 36d1be53f0284aeaf1b3068e95ddd329db8713ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:06:33 +0000 Subject: [PATCH 11/11] Narrow manifest subprocess exceptions Co-authored-by: dmalan <788678+dmalan@users.noreply.github.com> --- cli50/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli50/__main__.py b/cli50/__main__.py index 9b3bba7..4f6f2c0 100644 --- a/cli50/__main__.py +++ b/cli50/__main__.py @@ -340,7 +340,7 @@ def pull(image, tag): if localImageId in [manifest['SchemaV2Manifest']['config']['digest'] for manifest in RemoteManifest]: return - except (OSError, subprocess.SubprocessError): + except (OSError, subprocess.CalledProcessError): pass # Pull image