devservices 1.5.3__tar.gz → 1.5.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {devservices-1.5.3 → devservices-1.5.4}/PKG-INFO +1 -1
  2. {devservices-1.5.3 → devservices-1.5.4}/README.md +1 -1
  3. {devservices-1.5.3 → devservices-1.5.4}/devservices/constants.py +7 -6
  4. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/dependencies.py +2 -1
  5. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/docker_compose.py +15 -32
  6. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/install_binary.py +16 -5
  7. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/PKG-INFO +1 -1
  8. {devservices-1.5.3 → devservices-1.5.4}/pyproject.toml +1 -1
  9. {devservices-1.5.3 → devservices-1.5.4}/testing/utils.py +2 -2
  10. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_docker_compose.py +51 -20
  11. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_install_binary.py +56 -10
  12. {devservices-1.5.3 → devservices-1.5.4}/LICENSE.md +0 -0
  13. {devservices-1.5.3 → devservices-1.5.4}/devservices/__init__.py +0 -0
  14. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/__init__.py +0 -0
  15. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/down.py +0 -0
  16. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/foreground.py +0 -0
  17. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/list_dependencies.py +0 -0
  18. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/list_services.py +0 -0
  19. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/logs.py +0 -0
  20. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/purge.py +0 -0
  21. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/reset.py +0 -0
  22. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/serve.py +0 -0
  23. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/status.py +0 -0
  24. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/toggle.py +0 -0
  25. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/up.py +0 -0
  26. {devservices-1.5.3 → devservices-1.5.4}/devservices/commands/update.py +0 -0
  27. {devservices-1.5.3 → devservices-1.5.4}/devservices/configs/service_config.py +0 -0
  28. {devservices-1.5.3 → devservices-1.5.4}/devservices/exceptions.py +0 -0
  29. {devservices-1.5.3 → devservices-1.5.4}/devservices/main.py +0 -0
  30. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/__init__.py +0 -0
  31. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/check_for_update.py +0 -0
  32. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/console.py +0 -0
  33. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/devenv.py +0 -0
  34. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/docker.py +0 -0
  35. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/file_lock.py +0 -0
  36. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/git.py +0 -0
  37. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/github.py +0 -0
  38. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/retry.py +0 -0
  39. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/services.py +0 -0
  40. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/state.py +0 -0
  41. {devservices-1.5.3 → devservices-1.5.4}/devservices/utils/supervisor.py +0 -0
  42. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/SOURCES.txt +0 -0
  43. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/dependency_links.txt +0 -0
  44. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/entry_points.txt +0 -0
  45. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/requires.txt +0 -0
  46. {devservices-1.5.3 → devservices-1.5.4}/devservices.egg-info/top_level.txt +0 -0
  47. {devservices-1.5.3 → devservices-1.5.4}/setup.cfg +0 -0
  48. {devservices-1.5.3 → devservices-1.5.4}/testing/__init__.py +0 -0
  49. {devservices-1.5.3 → devservices-1.5.4}/tests/__init__.py +0 -0
  50. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_down.py +0 -0
  51. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_foreground.py +0 -0
  52. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_list_dependencies.py +0 -0
  53. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_list_services.py +0 -0
  54. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_logs.py +0 -0
  55. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_purge.py +0 -0
  56. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_reset.py +0 -0
  57. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_serve.py +0 -0
  58. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_status.py +0 -0
  59. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_toggle.py +0 -0
  60. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_up.py +0 -0
  61. {devservices-1.5.3 → devservices-1.5.4}/tests/commands/test_update.py +0 -0
  62. {devservices-1.5.3 → devservices-1.5.4}/tests/configs/test_service_config.py +0 -0
  63. {devservices-1.5.3 → devservices-1.5.4}/tests/conftest.py +0 -0
  64. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_check_for_update.py +0 -0
  65. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_dependencies.py +0 -0
  66. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_docker.py +0 -0
  67. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_git.py +0 -0
  68. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_github.py +0 -0
  69. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_services.py +0 -0
  70. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_state.py +0 -0
  71. {devservices-1.5.3 → devservices-1.5.4}/tests/utils/test_supervisor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.5.3
3
+ Version: 1.5.4
4
4
  Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -33,7 +33,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
33
33
  The recommended way to install devservices is through a virtualenv in the requirements.txt. Once that is installed and a devservices config file is added, you should be able to run `devservices up` to begin local development.
34
34
 
35
35
  ```
36
- devservices==1.5.3
36
+ devservices==1.5.4
37
37
  ```
38
38
 
39
39
  ### 2. Add devservices config files
@@ -22,7 +22,10 @@ class DependencyType(StrEnum):
22
22
  SUPERVISOR = "supervisor"
23
23
 
24
24
 
25
+ # Any installed Docker Compose at or above the minimum is used as is. The install
26
+ # version is only downloaded when Compose is missing or older than the minimum.
25
27
  MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7"
28
+ DOCKER_COMPOSE_INSTALL_VERSION = "5.6.0"
26
29
  DEVSERVICES_DIR_NAME = "devservices"
27
30
  CONFIG_FILE_NAME = "config.yml"
28
31
  DOCKER_CONFIG_DIR = os.environ.get("DOCKER_CONFIG", os.path.expanduser("~/.docker"))
@@ -42,15 +45,13 @@ DEVSERVICES_RELEASES_URL = (
42
45
  "https://api.github.com/repos/getsentry/devservices/releases/latest"
43
46
  )
44
47
 
45
- # We mirror this in our GCP bucket since GitHub downloads can be flaky at times.
46
- # gsutil cp docker-compose-darwin-aarch64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64
47
- # gsutil cp docker-compose-linux-x86_64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64
48
- DOCKER_COMPOSE_DOWNLOAD_URL = (
49
- "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose"
50
- )
48
+ DOCKER_COMPOSE_DOWNLOAD_URL = "https://github.com/docker/compose/releases/download"
51
49
 
52
50
  DEVSERVICES_DOWNLOAD_URL = "https://github.com/getsentry/devservices/releases/download"
53
51
  BINARY_PERMISSIONS = 0o755
52
+ # Seconds a download may stall (connecting or between reads) before the attempt
53
+ # is abandoned and retried.
54
+ DOWNLOAD_TIMEOUT = 15
54
55
  MAX_LOG_LINES = "100"
55
56
  LOGGER_NAME = "devservices"
56
57
  DOCKER_NETWORK_NAME = "devservices"
@@ -23,6 +23,7 @@ from devservices.constants import CONFIG_FILE_NAME
23
23
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
24
24
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
25
25
  from devservices.constants import DEVSERVICES_DIR_NAME
26
+ from devservices.constants import DOWNLOAD_TIMEOUT
26
27
  from devservices.constants import DependencyType
27
28
  from devservices.exceptions import ConfigNotFoundError
28
29
  from devservices.exceptions import ConfigParseError
@@ -441,7 +442,7 @@ def _fetch_dependency(
441
442
 
442
443
  def _download() -> bytes:
443
444
  req = github.build_api_request(zip_url)
444
- with urllib.request.urlopen(req) as response:
445
+ with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT) as response:
445
446
  return bytes(response.read())
446
447
 
447
448
  try:
@@ -10,7 +10,6 @@ import shlex
10
10
  import subprocess
11
11
  import time
12
12
  from typing import NamedTuple
13
- from typing import cast
14
13
 
15
14
  from packaging import version
16
15
  from sentry_sdk import logger as sentry_logger
@@ -19,6 +18,7 @@ from devservices.configs.service_config import load_service_config_from_file
19
18
  from devservices.constants import CONFIG_FILE_NAME
20
19
  from devservices.constants import DEVSERVICES_DIR_NAME
21
20
  from devservices.constants import DOCKER_COMPOSE_DOWNLOAD_URL
21
+ from devservices.constants import DOCKER_COMPOSE_INSTALL_VERSION
22
22
  from devservices.constants import DOCKER_USER_PLUGIN_DIR
23
23
  from devservices.constants import LOGGER_NAME
24
24
  from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
@@ -69,7 +69,7 @@ def install_docker_compose() -> None:
69
69
  else:
70
70
  raise DockerComposeInstallationError(f"Unsupported operating system: {system}")
71
71
 
72
- url = f"{DOCKER_COMPOSE_DOWNLOAD_URL}/v{MINIMUM_DOCKER_COMPOSE_VERSION}/{binary_name_with_extension}"
72
+ url = f"{DOCKER_COMPOSE_DOWNLOAD_URL}/v{DOCKER_COMPOSE_INSTALL_VERSION}/{binary_name_with_extension}"
73
73
  destination = os.path.join(DOCKER_USER_PLUGIN_DIR, binary_name)
74
74
  os.makedirs(DOCKER_USER_PLUGIN_DIR, exist_ok=True)
75
75
 
@@ -77,14 +77,14 @@ def install_docker_compose() -> None:
77
77
  install_binary(
78
78
  binary_name,
79
79
  destination,
80
- MINIMUM_DOCKER_COMPOSE_VERSION,
80
+ DOCKER_COMPOSE_INSTALL_VERSION,
81
81
  url,
82
82
  )
83
83
  except BinaryInstallError as e:
84
84
  raise DockerComposeInstallationError(f"Failed to install Docker Compose: {e}")
85
85
 
86
86
  console.success(
87
- f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}"
87
+ f"Docker Compose {DOCKER_COMPOSE_INSTALL_VERSION} installed successfully to {destination}"
88
88
  )
89
89
 
90
90
  # Verify the installation
@@ -138,42 +138,25 @@ def check_docker_compose_version() -> None:
138
138
  # Throw an error if docker daemon isn't running
139
139
  check_docker_daemon_running()
140
140
  try:
141
- # Run the docker compose version command
142
- docker_compose_version = get_docker_compose_version()
141
+ version_output = get_docker_compose_version()
143
142
  except DockerComposeError:
144
- docker_compose_version = None
145
143
  console.warning(
146
- f"Docker Compose is not installed, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}"
144
+ f"Docker Compose is not installed, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
147
145
  )
146
+ install_docker_compose()
147
+ return
148
148
 
149
- # Extract the version number from the output
150
- version_output = (
151
- docker_compose_version if docker_compose_version is not None else ""
152
- )
153
-
154
- # Use regex to find the version number
155
- pattern = r"^(\d+\.\d+\.\d+)"
156
-
157
- match = re.search(pattern, version_output)
158
- if match:
159
- # There is a chance that Any type is returned, so cast this
160
- docker_compose_version = cast(str, match.group(1))
161
- else:
162
- docker_compose_version = None
163
-
164
- if docker_compose_version is None:
149
+ # Docker Desktop reports versions like "2.29.7-desktop.1", so only match the prefix
150
+ match = re.search(r"^(\d+\.\d+\.\d+)", version_output)
151
+ if match is None:
165
152
  console.warning(
166
- f"Unable to detect Docker Compose version, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}"
153
+ f"Unable to detect Docker Compose version, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
167
154
  )
168
- elif version.parse(docker_compose_version) != version.parse(
169
- MINIMUM_DOCKER_COMPOSE_VERSION
170
- ):
155
+ elif version.parse(match.group(1)) < version.parse(MINIMUM_DOCKER_COMPOSE_VERSION):
171
156
  console.warning(
172
- f"Docker Compose version v{docker_compose_version} unsupported, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}"
157
+ f"Docker Compose v{match.group(1)} is older than the minimum supported v{MINIMUM_DOCKER_COMPOSE_VERSION}, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
173
158
  )
174
- elif version.parse(docker_compose_version) == version.parse(
175
- MINIMUM_DOCKER_COMPOSE_VERSION
176
- ):
159
+ else:
177
160
  return
178
161
  install_docker_compose()
179
162
 
@@ -3,13 +3,23 @@ from __future__ import annotations
3
3
  import os
4
4
  import shutil
5
5
  import tempfile
6
- from urllib.request import urlretrieve
6
+ from urllib.request import urlopen
7
7
 
8
8
  from devservices.constants import BINARY_PERMISSIONS
9
+ from devservices.constants import DOWNLOAD_TIMEOUT
9
10
  from devservices.exceptions import BinaryInstallError
10
11
  from devservices.utils.console import Console
11
12
  from devservices.utils.retry import retry
12
13
 
14
+ DOWNLOAD_ATTEMPTS = 3
15
+
16
+
17
+ def download_file(url: str, path: str) -> None:
18
+ # urlretrieve has no timeout, so a stalled connection would hang forever.
19
+ # The timeout applies to connecting and to each read, not the whole download.
20
+ with urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response, open(path, "wb") as f:
21
+ shutil.copyfileobj(response, f)
22
+
13
23
 
14
24
  def install_binary(
15
25
  binary_name: str,
@@ -24,16 +34,17 @@ def install_binary(
24
34
  console.info(f"Downloading {binary_name} {version} from {url}...")
25
35
  try:
26
36
  retry(
27
- lambda: urlretrieve(url, temp_file),
28
- retries=3,
37
+ lambda: download_file(url, temp_file),
38
+ retries=DOWNLOAD_ATTEMPTS,
29
39
  delay=1.0,
40
+ backoff=2.0,
30
41
  on_retry=lambda e, remaining: console.warning(
31
- f"Download failed. Retrying in 1 seconds... ({remaining} retries left)"
42
+ f"Download failed ({e}). Retrying... ({remaining} retries left)"
32
43
  ),
33
44
  )
34
45
  except Exception as e:
35
46
  raise BinaryInstallError(
36
- f"Failed to download {binary_name} after 3 attempts: {e}"
47
+ f"Failed to download {binary_name} after {DOWNLOAD_ATTEMPTS} attempts: {e}"
37
48
  ) from e
38
49
 
39
50
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.5.3
3
+ Version: 1.5.4
4
4
  Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devservices"
7
- version = "1.5.3"
7
+ version = "1.5.4"
8
8
  # 3.11 is just for internal pypi compat
9
9
  # but we test/support on 3.13
10
10
  requires-python = ">=3.11"
@@ -69,10 +69,10 @@ def make_urlopen_response(zip_bytes: bytes) -> mock.MagicMock:
69
69
 
70
70
  def url_dispatch(
71
71
  zip_bytes_by_repo_name: dict[str, bytes],
72
- ) -> Callable[[mock.MagicMock], mock.MagicMock]:
72
+ ) -> Callable[..., mock.MagicMock]:
73
73
  """Return a urlopen side_effect that routes requests by repo name in the URL."""
74
74
 
75
- def _side_effect(request: mock.MagicMock) -> mock.MagicMock:
75
+ def _side_effect(request: mock.MagicMock, timeout: float) -> mock.MagicMock:
76
76
  url = request.full_url
77
77
  for repo_name, zip_bytes in zip_bytes_by_repo_name.items():
78
78
  if f"/{repo_name}/" in url:
@@ -11,6 +11,7 @@ import pytest
11
11
  from devservices.configs.service_config import load_service_config_from_file
12
12
  from devservices.constants import CONFIG_FILE_NAME
13
13
  from devservices.constants import DEVSERVICES_DIR_NAME
14
+ from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
14
15
  from devservices.exceptions import BinaryInstallError
15
16
  from devservices.exceptions import DockerComposeError
16
17
  from devservices.exceptions import DockerComposeInstallationError
@@ -44,9 +45,13 @@ def mock_docker_compose_config_services(
44
45
 
45
46
 
46
47
  @mock.patch("subprocess.run")
47
- def test_check_docker_compose_version_success(mock_run: mock.Mock) -> None:
48
- mock_run.return_value.stdout = "2.29.7\n"
48
+ @mock.patch("devservices.utils.docker_compose.install_docker_compose")
49
+ def test_check_docker_compose_version_success(
50
+ mock_install_docker_compose: mock.Mock, mock_run: mock.Mock
51
+ ) -> None:
52
+ mock_run.return_value.stdout = f"{MINIMUM_DOCKER_COMPOSE_VERSION}\n"
49
53
  check_docker_compose_version() # Should not raise any exception
54
+ mock_install_docker_compose.assert_not_called()
50
55
 
51
56
 
52
57
  @mock.patch("subprocess.run")
@@ -78,7 +83,33 @@ def test_check_docker_compose_version_unsupported(
78
83
  ) -> None:
79
84
  mock_run.return_value.stdout = "2.20.0-desktop.1\n"
80
85
  check_docker_compose_version()
81
- assert mock_install_docker_compose.is_called()
86
+ mock_install_docker_compose.assert_called_once()
87
+
88
+
89
+ @pytest.mark.parametrize(
90
+ "installed_version",
91
+ ["2.29.7", "2.29.7-desktop.1", "2.38.2", "2.40.3-desktop.1", "5.6.0", "6.0.0"],
92
+ )
93
+ @mock.patch("subprocess.run")
94
+ @mock.patch("devservices.utils.docker_compose.install_docker_compose")
95
+ def test_check_docker_compose_version_at_or_above_minimum(
96
+ mock_install_docker_compose: mock.Mock,
97
+ mock_run: mock.Mock,
98
+ installed_version: str,
99
+ ) -> None:
100
+ mock_run.return_value.stdout = f"{installed_version}\n"
101
+ check_docker_compose_version()
102
+ mock_install_docker_compose.assert_not_called()
103
+
104
+
105
+ @mock.patch("subprocess.run")
106
+ @mock.patch("devservices.utils.docker_compose.install_docker_compose")
107
+ def test_check_docker_compose_version_below_minimum(
108
+ mock_install_docker_compose: mock.Mock, mock_run: mock.Mock
109
+ ) -> None:
110
+ mock_run.return_value.stdout = "2.29.6\n"
111
+ check_docker_compose_version()
112
+ mock_install_docker_compose.assert_called_once()
82
113
 
83
114
 
84
115
  @mock.patch("subprocess.run")
@@ -186,7 +217,7 @@ def test_install_docker_compose_binary_install_error(
186
217
 
187
218
  @mock.patch("platform.system", return_value="Darwin")
188
219
  @mock.patch("platform.machine", return_value="arm64")
189
- @mock.patch("devservices.utils.install_binary.urlretrieve")
220
+ @mock.patch("devservices.utils.install_binary.download_file")
190
221
  @mock.patch("devservices.utils.install_binary.os.chmod")
191
222
  @mock.patch("devservices.utils.install_binary.shutil.move")
192
223
  @mock.patch(
@@ -202,7 +233,7 @@ def test_install_docker_compose_compose_verification_error(
202
233
  _mock_get_docker_compose_version: mock.Mock,
203
234
  _mock_shutil_move: mock.Mock,
204
235
  _mock_chmod: mock.Mock,
205
- _mock_urlretrieve: mock.Mock,
236
+ _mock_download_file: mock.Mock,
206
237
  _mock_machine: mock.Mock,
207
238
  _mock_system: mock.Mock,
208
239
  ) -> None:
@@ -216,26 +247,26 @@ def test_install_docker_compose_compose_verification_error(
216
247
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
217
248
  @mock.patch("platform.system", return_value="Darwin")
218
249
  @mock.patch("platform.machine", return_value="arm64")
219
- @mock.patch("devservices.utils.install_binary.urlretrieve")
250
+ @mock.patch("devservices.utils.install_binary.download_file")
220
251
  @mock.patch("devservices.utils.install_binary.os.chmod")
221
252
  @mock.patch("devservices.utils.install_binary.shutil.move")
222
253
  @mock.patch(
223
254
  "devservices.utils.docker_compose.get_docker_compose_version",
224
- return_value="2.29.7\n",
255
+ return_value="5.6.0\n",
225
256
  )
226
257
  def test_install_docker_compose_macos_arm64(
227
258
  mock_get_docker_compose_version: mock.Mock,
228
259
  mock_shutil_move: mock.Mock,
229
260
  mock_chmod: mock.Mock,
230
- mock_urlretrieve: mock.Mock,
261
+ mock_download_file: mock.Mock,
231
262
  _mock_machine: mock.Mock,
232
263
  _mock_system: mock.Mock,
233
264
  mock_tempdir: mock.Mock,
234
265
  ) -> None:
235
266
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
236
267
  install_docker_compose()
237
- mock_urlretrieve.assert_called_once_with(
238
- "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64",
268
+ mock_download_file.assert_called_once_with(
269
+ "https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-darwin-aarch64",
239
270
  "tempdir/docker-compose",
240
271
  )
241
272
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -249,26 +280,26 @@ def test_install_docker_compose_macos_arm64(
249
280
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
250
281
  @mock.patch("platform.system", return_value="Linux")
251
282
  @mock.patch("platform.machine", return_value="x86_64")
252
- @mock.patch("devservices.utils.install_binary.urlretrieve")
283
+ @mock.patch("devservices.utils.install_binary.download_file")
253
284
  @mock.patch("devservices.utils.install_binary.os.chmod")
254
285
  @mock.patch("devservices.utils.install_binary.shutil.move")
255
286
  @mock.patch(
256
287
  "devservices.utils.docker_compose.get_docker_compose_version",
257
- return_value="2.29.7\n",
288
+ return_value="5.6.0\n",
258
289
  )
259
290
  def test_install_docker_compose_linux_x86(
260
291
  mock_get_docker_compose_version: mock.Mock,
261
292
  mock_shutil_move: mock.Mock,
262
293
  mock_chmod: mock.Mock,
263
- mock_urlretrieve: mock.Mock,
294
+ mock_download_file: mock.Mock,
264
295
  _mock_machine: mock.Mock,
265
296
  _mock_system: mock.Mock,
266
297
  mock_tempdir: mock.Mock,
267
298
  ) -> None:
268
299
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
269
300
  install_docker_compose()
270
- mock_urlretrieve.assert_called_once_with(
271
- "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64",
301
+ mock_download_file.assert_called_once_with(
302
+ "https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-linux-x86_64",
272
303
  "tempdir/docker-compose",
273
304
  )
274
305
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -282,18 +313,18 @@ def test_install_docker_compose_linux_x86(
282
313
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
283
314
  @mock.patch("platform.system", return_value="Darwin")
284
315
  @mock.patch("platform.machine", return_value="arm64")
285
- @mock.patch("devservices.utils.install_binary.urlretrieve")
316
+ @mock.patch("devservices.utils.install_binary.download_file")
286
317
  @mock.patch("devservices.utils.install_binary.os.chmod")
287
318
  @mock.patch("devservices.utils.install_binary.shutil.move")
288
319
  @mock.patch(
289
320
  "devservices.utils.docker_compose.get_docker_compose_version",
290
- return_value="2.29.7\n",
321
+ return_value="5.6.0\n",
291
322
  )
292
323
  def test_install_docker_compose_custom_docker_config_dir(
293
324
  mock_get_docker_compose_version: mock.Mock,
294
325
  mock_shutil_move: mock.Mock,
295
326
  mock_chmod: mock.Mock,
296
- mock_urlretrieve: mock.Mock,
327
+ mock_download_file: mock.Mock,
297
328
  _mock_machine: mock.Mock,
298
329
  _mock_system: mock.Mock,
299
330
  mock_tempdir: mock.Mock,
@@ -304,8 +335,8 @@ def test_install_docker_compose_custom_docker_config_dir(
304
335
  "tempdir/docker/config/cli-plugins",
305
336
  ):
306
337
  install_docker_compose()
307
- mock_urlretrieve.assert_called_once_with(
308
- "https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64",
338
+ mock_download_file.assert_called_once_with(
339
+ "https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-darwin-aarch64",
309
340
  "tempdir/docker-compose",
310
341
  )
311
342
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -1,19 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import io
4
+ import pathlib
3
5
  from unittest import mock
4
6
 
5
7
  import pytest
6
8
 
9
+ from devservices.constants import DOWNLOAD_TIMEOUT
7
10
  from devservices.exceptions import BinaryInstallError
11
+ from devservices.utils.install_binary import download_file
8
12
  from devservices.utils.install_binary import install_binary
9
13
 
10
14
 
11
15
  @mock.patch(
12
- "devservices.utils.install_binary.urlretrieve",
16
+ "devservices.utils.install_binary.download_file",
13
17
  side_effect=Exception("Connection error"),
14
18
  )
15
19
  def test_install_docker_compose_connection_error(
16
- mock_urlretrieve: mock.Mock, capsys: pytest.CaptureFixture[str]
20
+ mock_download_file: mock.Mock, capsys: pytest.CaptureFixture[str]
17
21
  ) -> None:
18
22
  with pytest.raises(
19
23
  BinaryInstallError,
@@ -33,9 +37,9 @@ def test_install_docker_compose_connection_error(
33
37
  assert "Download failed. Retrying in 1 seconds... (Attempt 2/2)" in captured.out
34
38
 
35
39
 
36
- @mock.patch("devservices.utils.install_binary.urlretrieve")
40
+ @mock.patch("devservices.utils.install_binary.download_file")
37
41
  def test_install_docker_compose_chmod_file_not_found_error(
38
- mock_urlretrieve: mock.Mock,
42
+ mock_download_file: mock.Mock,
39
43
  ) -> None:
40
44
  with pytest.raises(
41
45
  BinaryInstallError,
@@ -49,11 +53,11 @@ def test_install_docker_compose_chmod_file_not_found_error(
49
53
  )
50
54
 
51
55
 
52
- @mock.patch("devservices.utils.install_binary.urlretrieve")
56
+ @mock.patch("devservices.utils.install_binary.download_file")
53
57
  @mock.patch("devservices.utils.install_binary.os.chmod")
54
58
  def test_install_docker_compose_shutil_file_not_found_error(
55
59
  mock_chmod: mock.Mock,
56
- mock_urlretrieve: mock.Mock,
60
+ mock_download_file: mock.Mock,
57
61
  ) -> None:
58
62
  with pytest.raises(
59
63
  BinaryInstallError,
@@ -67,14 +71,14 @@ def test_install_docker_compose_shutil_file_not_found_error(
67
71
  )
68
72
 
69
73
 
70
- @mock.patch("devservices.utils.install_binary.urlretrieve")
74
+ @mock.patch("devservices.utils.install_binary.download_file")
71
75
  @mock.patch(
72
76
  "devservices.utils.install_binary.os.chmod",
73
77
  side_effect=PermissionError("Insufficient Permissions"),
74
78
  )
75
79
  def test_install_docker_compose_chmod_permission_error(
76
80
  mock_chmod: mock.Mock,
77
- mock_urlretrieve: mock.Mock,
81
+ mock_download_file: mock.Mock,
78
82
  ) -> None:
79
83
  with pytest.raises(
80
84
  BinaryInstallError,
@@ -88,7 +92,7 @@ def test_install_docker_compose_chmod_permission_error(
88
92
  )
89
93
 
90
94
 
91
- @mock.patch("devservices.utils.install_binary.urlretrieve")
95
+ @mock.patch("devservices.utils.install_binary.download_file")
92
96
  @mock.patch("devservices.utils.install_binary.os.chmod")
93
97
  @mock.patch(
94
98
  "devservices.utils.install_binary.shutil.move",
@@ -96,7 +100,7 @@ def test_install_docker_compose_chmod_permission_error(
96
100
  )
97
101
  def test_install_docker_compose_shutil_move_permission_error(
98
102
  mock_chmod: mock.Mock,
99
- mock_urlretrieve: mock.Mock,
103
+ mock_download_file: mock.Mock,
100
104
  mock_move: mock.Mock,
101
105
  ) -> None:
102
106
  with pytest.raises(
@@ -109,3 +113,45 @@ def test_install_docker_compose_shutil_move_permission_error(
109
113
  "1.0.0",
110
114
  "http:://example.com",
111
115
  )
116
+
117
+
118
+ @mock.patch("devservices.utils.install_binary.urlopen")
119
+ def test_download_file_uses_timeout(
120
+ mock_urlopen: mock.Mock, tmp_path: pathlib.Path
121
+ ) -> None:
122
+ mock_urlopen.return_value.__enter__.return_value = io.BytesIO(b"binary")
123
+ path = tmp_path / "binary-name"
124
+
125
+ download_file("https://example.com/binary", str(path))
126
+
127
+ mock_urlopen.assert_called_once_with(
128
+ "https://example.com/binary", timeout=DOWNLOAD_TIMEOUT
129
+ )
130
+ assert path.read_bytes() == b"binary"
131
+
132
+
133
+ @mock.patch("devservices.utils.retry.time.sleep")
134
+ @mock.patch(
135
+ "devservices.utils.install_binary.download_file",
136
+ side_effect=[TimeoutError("timed out"), None],
137
+ )
138
+ @mock.patch("devservices.utils.install_binary.os.chmod")
139
+ @mock.patch("devservices.utils.install_binary.shutil.move")
140
+ def test_install_binary_retries_stalled_download(
141
+ mock_move: mock.Mock,
142
+ mock_chmod: mock.Mock,
143
+ mock_download_file: mock.Mock,
144
+ mock_sleep: mock.Mock,
145
+ capsys: pytest.CaptureFixture[str],
146
+ ) -> None:
147
+ install_binary(
148
+ "binary-name",
149
+ "exec_path",
150
+ "1.0.0",
151
+ "https://example.com/binary",
152
+ )
153
+
154
+ assert mock_download_file.call_count == 2
155
+ mock_sleep.assert_called_once_with(1.0)
156
+ mock_move.assert_called_once()
157
+ assert "Download failed (timed out)" in capsys.readouterr().out
File without changes
File without changes