From ae47c6f71967a36b24124c746896dd826bad6cfc Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 4 Apr 2025 09:23:59 -0400 Subject: [PATCH 1/2] feat: Add the ability to match the version of a release As we move to containerized deploys we don't want to have to build a new image to incorporate he updated hash from the release branch. This adds the ability to expose a JSON blog with a "version" key that Doof can monitor to determine if a release was deployed. --- bot.py | 16 +++++----- wait_for_deploy.py | 75 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/bot.py b/bot.py index eea6dac..b97e944 100755 --- a/bot.py +++ b/bot.py @@ -456,7 +456,7 @@ async def _web_application_release( ) async def _wait_for_deploy_with_alerts( - self, *, repo_info, release_pr, hash_url, watch_branch + self, *, repo_info, release_pr, hash_url, watch_branch, expected_version ): """ Wait for a deployment, but alert with a a timeout @@ -471,6 +471,7 @@ async def _wait_for_deploy_with_alerts( repo_url=repo_url, hash_url=hash_url, watch_branch=watch_branch, + expected_version=expected_version, timeout_seconds=timeout_seconds, ): break @@ -490,12 +491,15 @@ async def _wait_for_deploy_rc(self, *, repo_info, manager, release_pr): """ repo_url = repo_info.repo_url channel_id = repo_info.channel_id + # Get the expected version from the release PR title + expected_version = release_pr.version await self._wait_for_deploy_with_alerts( repo_info=repo_info, release_pr=release_pr, hash_url=repo_info.rc_hash_url, watch_branch="release-candidate", + expected_version=expected_version, ) rc_server = remove_path_from_url(repo_info.rc_hash_url) @@ -525,17 +529,15 @@ async def _wait_for_deploy_prod(self, *, repo_info, manager, release_pr): """ repo_url = repo_info.repo_url channel_id = repo_info.channel_id - version = await get_version_tag( - github_access_token=self.github_access_token, - repo_url=repo_url, - commit_hash="origin/release", - ) + # Get the expected version from the release PR title (which should match the tag) + expected_version = release_pr.version await self._wait_for_deploy_with_alerts( repo_info=repo_info, release_pr=release_pr, hash_url=repo_info.prod_hash_url, watch_branch="release", + expected_version=expected_version, ) await set_release_label( @@ -550,7 +552,7 @@ async def _wait_for_deploy_prod(self, *, repo_info, manager, release_pr): await self.say( channel_id=channel_id, text=( - f"My evil scheme {version} for {repo_info.name} has been released to production at {prod_server}. " + f"My evil scheme {expected_version} for {repo_info.name} has been released to production at {prod_server}. " "And by 'released', I mean completely...um...leased." ), ) diff --git a/wait_for_deploy.py b/wait_for_deploy.py index e87d29f..5e97c41 100644 --- a/wait_for_deploy.py +++ b/wait_for_deploy.py @@ -1,28 +1,68 @@ """Wait for hash on server to match with deployed code""" import asyncio +import json +import logging import time from async_subprocess import check_output from client_wrapper import ClientWrapper -from release import init_working_dir +from lib import init_working_dir # Import from lib.py +from version import get_version_tag +log = logging.getLogger(__name__) -async def fetch_release_hash(hash_url): - """Fetch the hash from the release""" + +async def fetch_release_hash(hash_url, *, expected_version=None): + """ + Fetch the hash from the release URL. + + Handles both plain text hash responses and JSON responses containing a 'hash' key. + """ client = ClientWrapper() response = await client.get(hash_url) response.raise_for_status() - release_hash = response.content.decode().strip() + content = response.content.decode().strip() + release_hash = None + + try: + data = json.loads(content) + if isinstance(data, dict): + # If we expect a specific version, check it first + if expected_version and "version" in data: + deployed_version = str(data["version"]).strip() + if deployed_version != expected_version: + raise Exception( + f"Version mismatch at {hash_url}: Expected '{expected_version}', but found '{deployed_version}'" + ) + # If version matches (or wasn't checked), get the hash + if "hash" in data: + release_hash = str(data["hash"]).strip() + except json.JSONDecodeError: + # Content is not JSON, treat it as a plain hash + pass + + if release_hash is None: + # Fallback: Treat the entire content as the hash if JSON parsing failed + # or the 'hash' key wasn't found. + release_hash = content + if len(release_hash) != 40: + # Validate the final hash string raise Exception( - f"Expected release hash from {hash_url} but got: {release_hash}" + f"Expected a 40-character release hash from {hash_url} but got: '{release_hash}'" ) return release_hash async def wait_for_deploy( - *, github_access_token, repo_url, hash_url, watch_branch, timeout_seconds=60 * 60 + *, + github_access_token, + repo_url, + hash_url, + watch_branch, + expected_version, + timeout_seconds=60 * 60, ): """ Wait until server is finished with the deploy @@ -32,6 +72,7 @@ async def wait_for_deploy( repo_url (str): The repository URL which has the latest commit hash to check hash_url (str): The deployment URL which has the commit of the deployed app watch_branch (str): The branch in the repository which has the latest commit + expected_version (str or None): The version string expected to be found at the hash_url, or None to skip version check. timeout_seconds (int): The number of seconds to wait before timing out the deploy Returns: @@ -45,9 +86,27 @@ async def wait_for_deploy( ["git", "rev-parse", f"origin/{watch_branch}"], cwd=working_dir ) latest_hash = output.decode().strip() - while await fetch_release_hash(hash_url) != latest_hash: + + if expected_version: + log.info(f"Expecting version '{expected_version}' for hash {latest_hash[:7]}") + else: + log.info(f"No specific version expected for hash {latest_hash[:7]}. Proceeding without version check.") + + while True: + try: + current_hash = await fetch_release_hash(hash_url, expected_version=expected_version) + if current_hash == latest_hash: + log.info(f"Hash {latest_hash[:7]} confirmed at {hash_url}") + break # Hashes match, deploy successful + else: + log.info(f"Waiting for hash {latest_hash[:7]} at {hash_url}, currently {current_hash[:7]}") + except Exception as e: # pylint: disable=broad-except + log.error(f"Error checking deploy status at {hash_url}: {e}") + # Optionally, decide if specific errors should stop the wait + if (time.time() - start_time) > timeout_seconds: - return False + log.error(f"Timeout waiting for hash {latest_hash[:7]} at {hash_url}") + return False # Timeout reached await asyncio.sleep(30) return True From f878d328b0facb04ed0a634e0c371a7c79a46c72 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Fri, 4 Apr 2025 11:59:49 -0400 Subject: [PATCH 2/2] Refactor deployment checks and update dependencies - Refactor `wait_for_deploy` to accept `expected_version` argument - Update callers in `bot.py` to pass the version. - Update tests in `wait_for_deploy_test.py` and `bot_test.py`. - Replace deprecated `pkg_resources` with `packaging` for version parsing. - Improve Python package publishing in `publish.py`: - Use `python -m build .` for building sdist and wheel. - Use `check_call` to ensure build errors are raised. - Fix virtual environment handling to ensure `build` module is found. - Update GitHub Actions CI workflow to use `uv` instead of `pip`. - Address various Pylint errors in `publish.py` and `wait_for_deploy.py`. --- .github/workflows/ci.yml | 26 +- bot_test.py | 17 +- publish.py | 24 +- publish_test.py | 2 +- pyproject.toml | 28 ++ release_test.py | 15 -- requirements.txt | 7 - runtime.txt | 1 - test_requirements.txt | 8 - uv.lock | 534 +++++++++++++++++++++++++++++++++++++++ wait_for_deploy.py | 155 ++++++++---- wait_for_deploy_test.py | 241 ++++++++++++++++-- 12 files changed, 905 insertions(+), 153 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 runtime.txt delete mode 100644 test_requirements.txt create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba04f8e..06d6226 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: [push] jobs: python-tests: - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -10,30 +10,20 @@ jobs: - name: Set up JS requirements run: npm install - - name: Set up Python - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 - with: - python-version: "3.13" + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh - - id: cache - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/test_requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: pip install -r requirements.txt -r test_requirements.txt + - name: Install Python dependencies with uv + run: uv sync --locked - name: Lint - run: pylint *.py + run: uv run pylint *.py - name: Black - run: black --version && black . --check + run: uv run black --version && uv run black . --check - name: Tests - run: pytest . && coverage xml + run: uv run pytest . && uv run coverage xml # - name: Upload coverage to CodeCov # uses: codecov/codecov-action@v1 diff --git a/bot_test.py b/bot_test.py index ad4215a..6078ff5 100644 --- a/bot_test.py +++ b/bot_test.py @@ -444,6 +444,7 @@ async def test_release( repo_url=test_repo.repo_url, hash_url=test_repo.rc_hash_url, watch_branch="release-candidate", + expected_version=pr.version, timeout_seconds=3600, ) assert doof.said("Now deploying to RC...") @@ -519,6 +520,7 @@ async def test_hotfix_release( repo_url=test_repo.repo_url, hash_url=test_repo.rc_hash_url, watch_branch="release-candidate", + expected_version=pr.version, timeout_seconds=3600, ) assert doof.said("Now deploying to RC...") @@ -1219,6 +1221,7 @@ async def test_wait_for_deploy_rc( repo_url=test_repo.repo_url, hash_url=test_repo.rc_hash_url, watch_branch="release-candidate", + expected_version=release_pr.version, timeout_seconds=3600, ) get_unchecked.assert_called_once_with( @@ -1239,26 +1242,17 @@ async def test_wait_for_deploy_prod( ): # pylint: disable=unused-argument """Bot._wait_for_deploy_prod should wait until repo has been deployed to production""" wait_for_deploy_mock = mocker.async_patch("bot.wait_for_deploy") - version = "1.2.345" - get_version_tag_mock = mocker.async_patch( - "bot.get_version_tag", return_value=f"v{version}" - ) channel_id = test_repo.channel_id release_pr = ReleasePR( - "version", "https://github.com/org/repo/pulls/123456", "body", 123456, False + "1.2.345", "https://github.com/org/repo/pulls/123456", "body", 123456, False ) await doof._wait_for_deploy_prod( # pylint: disable=protected-access repo_info=test_repo, manager="me", release_pr=release_pr ) - get_version_tag_mock.assert_called_once_with( - github_access_token=GITHUB_ACCESS, - repo_url=test_repo.repo_url, - commit_hash="origin/release", - ) assert doof.said( - f"My evil scheme v{version} for {test_repo.name} has been released " + f"My evil scheme {release_pr.version} for {test_repo.name} has been released " # Use release_pr.version f"to production at {remove_path_from_url(test_repo.prod_hash_url)}. " "And by 'released', I mean completely...um...leased.", channel_id=channel_id, @@ -1268,6 +1262,7 @@ async def test_wait_for_deploy_prod( repo_url=test_repo.repo_url, hash_url=test_repo.prod_hash_url, watch_branch="release", + expected_version=release_pr.version, # Pass expected_version from PR timeout_seconds=3600, ) diff --git a/publish.py b/publish.py index 9c61011..59046e0 100644 --- a/publish.py +++ b/publish.py @@ -1,10 +1,8 @@ """Functions for publishing""" - import os from pathlib import Path from async_subprocess import ( - call, check_call, ) from constants import ( @@ -28,13 +26,6 @@ async def upload_to_pypi(project_dir): # Heroku has both Python 2 and 3 installed but the system libraries aren't configured for our use, # so make a virtualenv. async with virtualenv("python3", outer_environ) as (virtualenv_dir, environ): - # Use the virtualenv binaries to act within that environment - pip_path = os.path.join(virtualenv_dir, "bin", "pip") - - # Install dependencies. wheel is needed for Python 2. twine uploads the package. - await check_call( - [pip_path, "install", "twine"], env=environ, cwd=project_dir - ) await upload_with_twine( project_dir=project_dir, virtualenv_dir=virtualenv_dir, environ=environ ) @@ -56,15 +47,18 @@ async def upload_with_twine( "TWINE_USERNAME": os.environ["PYPI_USERNAME"], "TWINE_PASSWORD": os.environ["PYPI_PASSWORD"], } - - python_path = os.path.join(virtualenv_dir, "bin", "python") + # Use the virtualenv binaries to act within that environment pip_path = os.path.join(virtualenv_dir, "bin", "pip") + python_path = os.path.join(virtualenv_dir, "bin", "python") + # Install dependencies. wheel is needed for Python 2. twine uploads the package. + await check_call([pip_path, "install", "setuptools"], env=environ, cwd=project_dir) + await check_call([pip_path, "install", "build"], env=environ, cwd=project_dir) + await check_call([pip_path, "install", "twine"], env=environ, cwd=project_dir) twine_path = os.path.join(virtualenv_dir, "bin", "twine") - # Create source distribution and wheel. - await call([pip_path, "install", "setuptools"], env=environ, cwd=project_dir) - await call([python_path, "setup.py", "sdist"], env=environ, cwd=project_dir) - await call([python_path, "setup.py", "bdist_wheel"], env=environ, cwd=project_dir) + # Create source distribution and wheel using the virtualenv's python executable + # and explicitly passing the virtualenv's environment. + await check_call([python_path, "-m", "build", "."], env=environ, cwd=project_dir) dist_files = os.listdir(os.path.join(project_dir, "dist")) if len(dist_files) != 2: raise Exception("Expected to find one tarball and one wheel in directory") diff --git a/publish_test.py b/publish_test.py index ed45ea9..b40605f 100644 --- a/publish_test.py +++ b/publish_test.py @@ -55,7 +55,7 @@ def _call(command, *args, **kwargs): virtualenv_dir=virtualenv_dir, environ=environ, ) - assert call_mock.call_count == 1 + assert call_mock.call_count == 5 async def test_upload_to_npm(mocker, test_repo_directory, library_test_repo): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..432eda4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "doof" +requires-python = "~=3.13" +version = "0.0.1" +dependencies = [ + "python-dateutil", + "pytz", + "requests", + "sentry-sdk", + "setuptools>=78.1.0", + "tornado", + "virtualenv", +] + +[dependency-groups] +dev = [ + "black==22.3.0", + "codecov", + "pdbpp", + "pylint", + "pytest", + "pytest-asyncio", + "pytest-cov", + "pytest-mock", +] + +[tool.uv] +package = false diff --git a/release_test.py b/release_test.py index f77b8f0..2c00712 100644 --- a/release_test.py +++ b/release_test.py @@ -21,7 +21,6 @@ verify_new_commits, ) from test_util import async_context_manager_yielder, sync_check_call as check_call -from wait_for_deploy import fetch_release_hash pytestmark = pytest.mark.asyncio @@ -368,20 +367,6 @@ async def test_generate_release_pr(mocker): ) -async def test_fetch_release_hash(mocker): - """ - fetch_release_hash should download the release hash at the URL - """ - sha1_hash = b"X" * 40 - url = "a_url" - get_mock = mocker.async_patch( - "client_wrapper.ClientWrapper.get", return_value=mocker.Mock(content=sha1_hash) - ) - assert await fetch_release_hash(url) == sha1_hash.decode() - get_mock.assert_called_once_with(mocker.ANY, url) - get_mock.return_value.raise_for_status.assert_called_once_with() - - @pytest.mark.parametrize("hotfix_hash", ["", "abcdef"]) async def test_release(mocker, hotfix_hash, test_repo_directory, test_repo): """release should perform a release""" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 414cc24..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -python-dateutil -pytz -requests -sentry-sdk -tornado -virtualenv -packaging diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index f72c511..0000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.9.0 diff --git a/test_requirements.txt b/test_requirements.txt deleted file mode 100644 index 1807841..0000000 --- a/test_requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -black==25.1.0 -codecov -pdbpp -pylint -pytest -pytest-asyncio -pytest-cov -pytest-mock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a22cb91 --- /dev/null +++ b/uv.lock @@ -0,0 +1,534 @@ +version = 1 +revision = 1 +requires-python = ">=3.13, <4" + +[[package]] +name = "astroid" +version = "3.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/33/536530122a22a7504b159bccaf30a1f76aa19d23028bd8b5009eb9b2efea/astroid-3.3.9.tar.gz", hash = "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550", size = 398731 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/80/c749efbd8eef5ea77c7d6f1956e8fbfb51963b7f93ef79647afd4d9886e3/astroid-3.3.9-py3-none-any.whl", hash = "sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248", size = 275339 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "black" +version = "22.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/1f/b29c7371958ab41a800f8718f5d285bf4333b8d0b5a5a8650234463ee644/black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79", size = 554277 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/ef/a38a2189959246543e60859fb65bd3143129f6d18dfc7bcdd79217f81ca2/black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72", size = 153859 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "codecov" +version = "2.1.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/bb/594b26d2c85616be6195a64289c578662678afa4910cef2d3ce8417cf73e/codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c", size = 21416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 }, + { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 }, + { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 }, + { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 }, + { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 }, + { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 }, + { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 }, + { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 }, + { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 }, + { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 }, + { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 }, + { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 }, + { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 }, + { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 }, + { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 }, + { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 }, + { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 }, + { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 }, + { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 }, +] + +[[package]] +name = "dill" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/43/86fe3f9e130c4137b0f1b50784dd70a5087b911fe07fa81e53e0c4c47fea/dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c", size = 187000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d1/e73b6ad76f0b1fb7f23c35c6d95dbc506a9c8804f43dda8cb5b0fa6331fd/dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", size = 119418 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "doof" +version = "0.0.1" +source = { virtual = "." } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "setuptools" }, + { name = "tornado" }, + { name = "virtualenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "codecov" }, + { name = "pdbpp" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "setuptools", specifier = ">=78.1.0" }, + { name = "tornado" }, + { name = "virtualenv" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = "==22.3.0" }, + { name = "codecov" }, + { name = "pdbpp" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[[package]] +name = "fancycompleter" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline", marker = "sys_platform == 'win32'" }, + { name = "pyrepl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/649d135442d8ecf8af5c7e235550c628056423c96c4bc6787348bdae9248/fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272", size = 10866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ef/c08926112034d017633f693d3afc8343393a035134a29dfc12dcd71b0375/fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080", size = 9681 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186 }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pdbpp" +version = "0.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fancycompleter" }, + { name = "pygments" }, + { name = "wmctrl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/a3/c4bd048256fd4b7d28767ca669c505e156f24d16355505c62e6fce3314df/pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5", size = 68116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pylint" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/a7/113d02340afb9dcbb0c8b25454e9538cd08f0ebf3e510df4ed916caa1a89/pylint-3.3.6.tar.gz", hash = "sha256:b634a041aac33706d56a0d217e6587228c66427e20ec21a019bc4cdee48c040a", size = 1519586 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/21/9537fc94aee9ec7316a230a49895266cf02d78aa29b0a2efbc39566e0935/pylint-3.3.6-py3-none-any.whl", hash = "sha256:8b7c2d3e86ae3f94fb27703d521dd0b9b6b378775991f504d7c3a6275aa0a6a6", size = 522462 }, +] + +[[package]] +name = "pyreadline" +version = "2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/7c/d724ef1ec3ab2125f38a1d53285745445ec4a8f19b9bb0761b4064316679/pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", size = 109189 } + +[[package]] +name = "pyrepl" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 } + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, +] + +[[package]] +name = "pytest-cov" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/8c/039a7793f23f5cb666c834da9e944123f498ccc0753bed5fbfb2e2c11f87/pytest_cov-6.1.0.tar.gz", hash = "sha256:ec55e828c66755e5b74a21bd7cc03c303a9f928389c0563e50ba454a6dbe71db", size = 66651 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c5/8d6ffe9fc8f7f57b3662156ae8a34f2b8e7a754c73b48e689ce43145e98c/pytest_cov-6.1.0-py3-none-any.whl", hash = "sha256:cd7e1d54981d5185ef2b8d64b50172ce97e6f357e6df5cb103e828c7f993e201", size = 23743 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "sentry-sdk" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2f/a0f732270cc7c1834f5ec45539aec87c360d5483a8bd788217a9102ccfbd/sentry_sdk-2.25.1.tar.gz", hash = "sha256:f9041b7054a7cf12d41eadabe6458ce7c6d6eea7a97cfe1b760b6692e9562cf0", size = 322190 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/b6/84049ab0967affbc7cc7590d86ae0170c1b494edb69df8786707100420e5/sentry_sdk-2.25.1-py2.py3-none-any.whl", hash = "sha256:60b016d0772789454dc55a284a6a44212044d4a16d9f8448725effee97aaf7f6", size = 339851 }, +] + +[[package]] +name = "setuptools" +version = "78.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/0db4da3bc908df06e5efae42b44e75c81dd52716e10192ff36d0c1c8e379/setuptools-78.1.0.tar.gz", hash = "sha256:18fd474d4a82a5f83dac888df697af65afa82dec7323d09c3e37d1f14288da54", size = 1367827 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/21/f43f0a1fa8b06b32812e0975981f4677d28e0f3271601dc88ac5a5b83220/setuptools-78.1.0-py3-none-any.whl", hash = "sha256:3e386e96793c8702ae83d17b853fb93d3e09ef82ec62722e61da5cd22376dcd8", size = 1256108 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "virtualenv" +version = "20.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 }, +] + +[[package]] +name = "wmctrl" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 }, +] diff --git a/wait_for_deploy.py b/wait_for_deploy.py index 5e97c41..d0abc2c 100644 --- a/wait_for_deploy.py +++ b/wait_for_deploy.py @@ -7,52 +7,107 @@ from async_subprocess import check_output from client_wrapper import ClientWrapper -from lib import init_working_dir # Import from lib.py -from version import get_version_tag +from lib import init_working_dir # Import from lib.py log = logging.getLogger(__name__) async def fetch_release_hash(hash_url, *, expected_version=None): """ - Fetch the hash from the release URL. + Fetch the deployment signal (version or hash) from the release URL, requiring an expected version. - Handles both plain text hash responses and JSON responses containing a 'hash' key. + - If JSON response contains a 'version' key: + - Checks if it matches `expected_version`. Raises Exception on mismatch. + - Returns the matching version string as the signal. + - If JSON response does NOT contain 'version' key OR content is not JSON: + - Looks for 'hash' key in JSON (if applicable). Returns valid 40-char hash if found. + - If no 'hash' key or not JSON, treats raw content as hash. Returns if valid 40-char hash. + - Raises exceptions for version mismatches, invalid hash formats, or if no valid signal is found. + + Args: + hash_url (str): The URL to fetch the signal from. + expected_version (str): The version string that is expected. This is mandatory. + + Returns: + str: The validated deployment signal (either the matching version or a 40-char hash). + + Raises: + Exception: If version mismatch occurs, hash format is invalid, or no valid signal is found. """ + if not expected_version: + # Ensure expected_version is always provided, as per new requirement. + raise ValueError("expected_version must be provided to fetch_release_hash") + client = ClientWrapper() response = await client.get(hash_url) response.raise_for_status() content = response.content.decode().strip() - release_hash = None + data = None + is_json = False try: data = json.loads(content) if isinstance(data, dict): - # If we expect a specific version, check it first - if expected_version and "version" in data: + is_json = True + # --- Version Check --- + if "version" in data and expected_version is not None: deployed_version = str(data["version"]).strip() - if deployed_version != expected_version: + # Since expected_version is mandatory, we always compare if 'version' key exists. + if deployed_version == expected_version: + log.debug( + "Found matching version '%s' at %s", deployed_version, hash_url + ) + return deployed_version # Success: Found expected version + else: + # Version mismatch is always a failure condition if 'version' key is present. raise Exception( f"Version mismatch at {hash_url}: Expected '{expected_version}', but found '{deployed_version}'" ) - # If version matches (or wasn't checked), get the hash - if "hash" in data: + # If version key was present, we either returned or raised. We don't proceed to hash check. + + # --- Hash Check (only if 'version' key was NOT found in JSON) --- + elif "hash" in data: release_hash = str(data["hash"]).strip() + if len(release_hash) == 40: + log.debug( + "Found hash '%s' in JSON at %s", release_hash[:7], hash_url + ) + return release_hash # Success: Found hash signal in JSON + else: + # Invalid hash format in JSON is an error + raise Exception( + f"Invalid hash length ({len(release_hash)}) found in JSON 'hash' key at {hash_url}: '{release_hash}'" + ) + except json.JSONDecodeError: - # Content is not JSON, treat it as a plain hash - pass - - if release_hash is None: - # Fallback: Treat the entire content as the hash if JSON parsing failed - # or the 'hash' key wasn't found. - release_hash = content - - if len(release_hash) != 40: - # Validate the final hash string - raise Exception( - f"Expected a 40-character release hash from {hash_url} but got: '{release_hash}'" + # Content is not JSON, proceed to treat raw content as hash/signal + log.debug("Content at %s is not JSON, treating as raw signal.", hash_url) + + # --- Raw Content Check (if not JSON or relevant keys missing/invalid in JSON) --- + # At this point, we haven't returned a version or a valid JSON hash. + # Treat the raw content as the signal. It should be a hash. + release_signal = content + if len(release_signal) == 40: + log.debug( + "Using raw content as hash signal '%s' from %s", + release_signal[:7], + hash_url, ) - return release_hash + return release_signal # Success: Found hash signal in raw content + else: + # If we got here, the content is not valid JSON with a 'version' key, + # not valid JSON with a 'hash' key, and not a 40-char raw hash. + # This is an error condition. Since expected_version is mandatory, the error + # message reflects that we expected that version but didn't find a valid signal. + error_message = f"Expected version '{expected_version}' but found invalid signal at {hash_url}. Content: '{release_signal}'" + + # Add context if it was JSON but lacked valid keys + if is_json: + error_message += ( + f" (Content was JSON but lacked valid 'version' or 'hash' key: {data})" + ) + + raise Exception(error_message) async def wait_for_deploy( @@ -61,19 +116,19 @@ async def wait_for_deploy( repo_url, hash_url, watch_branch, - expected_version, + expected_version, # Made mandatory timeout_seconds=60 * 60, -): +): # pylint: disable=too-many-arguments """ - Wait until server is finished with the deploy + Wait until server is finished with the deploy by checking for the expected version. Args: github_access_token (str): A github access token - repo_url (str): The repository URL which has the latest commit hash to check - hash_url (str): The deployment URL which has the commit of the deployed app - watch_branch (str): The branch in the repository which has the latest commit - expected_version (str or None): The version string expected to be found at the hash_url, or None to skip version check. - timeout_seconds (int): The number of seconds to wait before timing out the deploy + repo_url (str): The repository URL (used only to get the working dir context). + hash_url (str): The deployment URL which should contain the deployment signal (version or hash). + watch_branch (str): The branch in the repository (used only to get the latest hash for logging). + expected_version (str): The version string expected to be found at the hash_url. This is mandatory. + timeout_seconds (int): The number of seconds to wait before timing out the deploy. Returns: bool: @@ -85,28 +140,24 @@ async def wait_for_deploy( output = await check_output( ["git", "rev-parse", f"origin/{watch_branch}"], cwd=working_dir ) - latest_hash = output.decode().strip() - - if expected_version: - log.info(f"Expecting version '{expected_version}' for hash {latest_hash[:7]}") - else: - log.info(f"No specific version expected for hash {latest_hash[:7]}. Proceeding without version check.") - - while True: - try: - current_hash = await fetch_release_hash(hash_url, expected_version=expected_version) - if current_hash == latest_hash: - log.info(f"Hash {latest_hash[:7]} confirmed at {hash_url}") - break # Hashes match, deploy successful - else: - log.info(f"Waiting for hash {latest_hash[:7]} at {hash_url}, currently {current_hash[:7]}") - except Exception as e: # pylint: disable=broad-except - log.error(f"Error checking deploy status at {hash_url}: {e}") - # Optionally, decide if specific errors should stop the wait - + latest_hash = output.decode().strip() # Keep for logging context + + # expected_version is now mandatory + release_signal = expected_version + log.info( + "Expecting version '%s' (commit: %s) at %s", + expected_version, + latest_hash[:7], + hash_url, + ) + + while ( + await fetch_release_hash(hash_url, expected_version=expected_version) + != release_signal # We always compare against expected_version now + ): if (time.time() - start_time) > timeout_seconds: - log.error(f"Timeout waiting for hash {latest_hash[:7]} at {hash_url}") - return False # Timeout reached + log.info("Timeout waiting for version %s at %s", release_signal, hash_url) + return False # Timeout reached await asyncio.sleep(30) return True diff --git a/wait_for_deploy_test.py b/wait_for_deploy_test.py index b90c608..6e61b4a 100644 --- a/wait_for_deploy_test.py +++ b/wait_for_deploy_test.py @@ -1,29 +1,43 @@ """Tests for wait_for_deploy""" +import json +from unittest.mock import Mock, AsyncMock + import pytest +from requests import Response +from requests.exceptions import RequestException from test_util import async_context_manager_yielder -from wait_for_deploy import wait_for_deploy +from wait_for_deploy import wait_for_deploy, fetch_release_hash pytestmark = pytest.mark.asyncio +# Removed parametrization as expected_version is now mandatory async def test_wait_for_deploy(mocker, test_repo_directory): - """wait_for_deploy should poll deployed web applications""" - matched_hash = "match" - mismatch_hash = "mismatch" - fetch_release_patch = mocker.async_patch("wait_for_deploy.fetch_release_hash") + """wait_for_deploy should poll deployed web applications until the expected version is found.""" + mock_latest_hash = "mock_commit_hash_1234567" + expected_version = "1.2.3" # Version is now mandatory + + # The signal wait_for_deploy will look for is always the expected_version + release_signal = expected_version + + # Mock fetch_release_hash to return dummy values twice, then the correct signal + fetch_release_patch = mocker.patch( + "wait_for_deploy.fetch_release_hash" + ) # Use patch, not async_patch fetch_release_patch.side_effect = [ - mismatch_hash, - mismatch_hash, - matched_hash, + "intermediate_signal_1", + "intermediate_signal_2", + release_signal, ] - check_output_patch = mocker.async_patch( - "wait_for_deploy.check_output", - ) - check_output_patch.return_value = f" {matched_hash} ".encode() + # Mock check_output to return the latest commit hash + check_output_patch = mocker.async_patch("wait_for_deploy.check_output") + check_output_patch.return_value = f" {mock_latest_hash} ".encode() + + # Mock init_working_dir init_working_dir_mock = mocker.patch( "wait_for_deploy.init_working_dir", side_effect=async_context_manager_yielder(test_repo_directory), @@ -32,21 +46,198 @@ async def test_wait_for_deploy(mocker, test_repo_directory): repo_url = "repo_url" token = "token" - hash_url = "hash" - watch_branch = "watch" - assert ( - await wait_for_deploy( - github_access_token=token, - repo_url=repo_url, - hash_url=hash_url, - watch_branch=watch_branch, - ) - is True + hash_url = "http://example.com/hash" + watch_branch = "main" + + # Call the function under test + result = await wait_for_deploy( + github_access_token=token, + repo_url=repo_url, + hash_url=hash_url, + watch_branch=watch_branch, + expected_version=expected_version, # Pass the mandatory version ) + assert result is True + # Assertions + init_working_dir_mock.assert_called_once_with(token, repo_url) check_output_patch.assert_called_once_with( ["git", "rev-parse", f"origin/{watch_branch}"], cwd=test_repo_directory ) - fetch_release_patch.assert_any_call(hash_url) - assert fetch_release_patch.call_count == 3 - init_working_dir_mock.assert_called_once_with(token, repo_url) + # Check that fetch_release_hash was called correctly with the expected version + fetch_release_patch.assert_any_call(hash_url, expected_version=expected_version) + assert ( + fetch_release_patch.call_count == 3 + ), "fetch_release_hash should be called 3 times" + + +@pytest.mark.asyncio +async def test_fetch_release_hash_plain_text(mocker): + """fetch_release_hash should return the hash when the response is plain text""" + mock_response = Mock(spec=Response) + mock_response.content = b"a" * 40 + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/hash" + expected_version = "0.0.1" # Must provide expected version + # Since content is plain text hash, it should return the hash + result = await fetch_release_hash(hash_url, expected_version=expected_version) + assert result == "a" * 40 + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_json(mocker): + """fetch_release_hash should return the hash from a JSON response""" + expected_hash = "b" * 40 + mock_response = Mock(spec=Response) + mock_response.content = json.dumps({"hash": expected_hash}).encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/hash.json" + expected_version = "0.0.2" # Must provide expected version + # Since JSON lacks 'version' key, it should return the hash + result = await fetch_release_hash(hash_url, expected_version=expected_version) + assert result == expected_hash + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_json_with_version_match(mocker): + """fetch_release_hash should return the hash when version matches""" + expected_hash = "c" * 40 + expected_version = "1.2.3" + mock_response = Mock(spec=Response) + mock_response.content = json.dumps( + {"hash": expected_hash, "version": expected_version} + ).encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/hash_version.json" + result = await fetch_release_hash(hash_url, expected_version=expected_version) + # With the new logic, if version matches, it returns the version, not the hash + assert result == expected_version + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_json_with_version_mismatch(mocker): + """fetch_release_hash should raise an exception when version mismatches""" + expected_hash = "d" * 40 + expected_version = "1.2.3" + deployed_version = "1.2.4" + mock_response = Mock(spec=Response) + mock_response.content = json.dumps( + {"hash": expected_hash, "version": deployed_version} + ).encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/hash_version_mismatch.json" + with pytest.raises(Exception) as excinfo: + await fetch_release_hash(hash_url, expected_version=expected_version) + assert ( + f"Version mismatch at {hash_url}: Expected '{expected_version}', but found '{deployed_version}'" + in str(excinfo.value) + ) + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_json_no_hash_key(mocker): + """fetch_release_hash should treat content as hash if 'hash' key is missing in JSON""" + # This tests the fallback behavior where the entire content is treated as hash + # if JSON parsing succeeds but 'hash' key is missing. + # If the content itself is not a valid hash, it will fail the length check later. + mock_response = Mock(spec=Response) + # Simulate JSON with only a version key + mock_response.content = json.dumps({"version": "1.0.0"}).encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/no_hash_key.json" + expected_version = "1.0.0" # Must provide expected version + # We expect it to return the version since it's present and matches expected + result = await fetch_release_hash(hash_url, expected_version=expected_version) + assert result == expected_version + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_json_no_version_key_expected(mocker): + """fetch_release_hash should proceed if version is expected but not in JSON""" + expected_hash = "f" * 40 + expected_version = "2.0.0" + mock_response = Mock(spec=Response) + # JSON has hash but no version key + mock_response.content = json.dumps({"hash": expected_hash}).encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/no_version_key.json" + # Since 'version' key is missing, it should fall back and return the hash + result = await fetch_release_hash(hash_url, expected_version=expected_version) + assert result == expected_hash + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_invalid_length(mocker): + """fetch_release_hash should raise an exception for invalid hash length""" + invalid_hash = "g" * 39 # Not 40 characters + mock_response = Mock(spec=Response) + mock_response.content = invalid_hash.encode() + mock_response.raise_for_status = Mock() + + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + mock_get = AsyncMock(return_value=mock_response) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/invalid_hash" + expected_version = "0.0.3" # Must provide expected version + with pytest.raises(Exception) as excinfo: + await fetch_release_hash(hash_url, expected_version=expected_version) + # Update assertion to match the error message when expected_version is provided + assert ( + f"Expected version '{expected_version}' but found invalid signal at {hash_url}. Content: '{invalid_hash}'" + in str(excinfo.value) + ) + mock_get.assert_called_once_with(hash_url) + + +@pytest.mark.asyncio +async def test_fetch_release_hash_http_error(mocker): + """fetch_release_hash should raise exception on HTTP error""" + mock_client_wrapper_patch = mocker.patch("wait_for_deploy.ClientWrapper") + # Simulate an HTTP error during the get call + mock_get = AsyncMock(side_effect=RequestException("Connection failed")) + mock_client_wrapper_patch.return_value.get = mock_get + + hash_url = "http://example.com/http_error" + expected_version = ( + "0.0.4" # Must provide expected version, even if error occurs before use + ) + with pytest.raises(RequestException): + await fetch_release_hash(hash_url, expected_version=expected_version) + mock_get.assert_called_once_with(hash_url)