devservices 1.5.1__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.1 → devservices-1.5.4}/PKG-INFO +1 -1
  2. {devservices-1.5.1 → devservices-1.5.4}/README.md +1 -1
  3. {devservices-1.5.1 → devservices-1.5.4}/devservices/constants.py +7 -6
  4. {devservices-1.5.1 → devservices-1.5.4}/devservices/exceptions.py +6 -1
  5. {devservices-1.5.1 → devservices-1.5.4}/devservices/main.py +7 -0
  6. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/dependencies.py +14 -2
  7. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/docker_compose.py +32 -41
  8. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/install_binary.py +16 -5
  9. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/retry.py +4 -2
  10. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/PKG-INFO +1 -1
  11. {devservices-1.5.1 → devservices-1.5.4}/pyproject.toml +1 -1
  12. {devservices-1.5.1 → devservices-1.5.4}/testing/utils.py +2 -2
  13. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_dependencies.py +67 -2
  14. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_docker_compose.py +87 -66
  15. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_install_binary.py +56 -10
  16. {devservices-1.5.1 → devservices-1.5.4}/LICENSE.md +0 -0
  17. {devservices-1.5.1 → devservices-1.5.4}/devservices/__init__.py +0 -0
  18. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/__init__.py +0 -0
  19. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/down.py +0 -0
  20. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/foreground.py +0 -0
  21. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/list_dependencies.py +0 -0
  22. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/list_services.py +0 -0
  23. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/logs.py +0 -0
  24. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/purge.py +0 -0
  25. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/reset.py +0 -0
  26. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/serve.py +0 -0
  27. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/status.py +0 -0
  28. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/toggle.py +0 -0
  29. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/up.py +0 -0
  30. {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/update.py +0 -0
  31. {devservices-1.5.1 → devservices-1.5.4}/devservices/configs/service_config.py +0 -0
  32. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/__init__.py +0 -0
  33. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/check_for_update.py +0 -0
  34. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/console.py +0 -0
  35. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/devenv.py +0 -0
  36. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/docker.py +0 -0
  37. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/file_lock.py +0 -0
  38. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/git.py +0 -0
  39. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/github.py +0 -0
  40. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/services.py +0 -0
  41. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/state.py +0 -0
  42. {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/supervisor.py +0 -0
  43. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/SOURCES.txt +0 -0
  44. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/dependency_links.txt +0 -0
  45. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/entry_points.txt +0 -0
  46. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/requires.txt +0 -0
  47. {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/top_level.txt +0 -0
  48. {devservices-1.5.1 → devservices-1.5.4}/setup.cfg +0 -0
  49. {devservices-1.5.1 → devservices-1.5.4}/testing/__init__.py +0 -0
  50. {devservices-1.5.1 → devservices-1.5.4}/tests/__init__.py +0 -0
  51. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_down.py +0 -0
  52. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_foreground.py +0 -0
  53. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_list_dependencies.py +0 -0
  54. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_list_services.py +0 -0
  55. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_logs.py +0 -0
  56. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_purge.py +0 -0
  57. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_reset.py +0 -0
  58. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_serve.py +0 -0
  59. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_status.py +0 -0
  60. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_toggle.py +0 -0
  61. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_up.py +0 -0
  62. {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_update.py +0 -0
  63. {devservices-1.5.1 → devservices-1.5.4}/tests/configs/test_service_config.py +0 -0
  64. {devservices-1.5.1 → devservices-1.5.4}/tests/conftest.py +0 -0
  65. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_check_for_update.py +0 -0
  66. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_docker.py +0 -0
  67. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_git.py +0 -0
  68. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_github.py +0 -0
  69. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_services.py +0 -0
  70. {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_state.py +0 -0
  71. {devservices-1.5.1 → 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.1
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.1
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"
@@ -99,7 +99,12 @@ class DependencyError(Exception):
99
99
  self.stderr = stderr
100
100
 
101
101
  def __str__(self) -> str:
102
- return f"DependencyError: {self.repo_name} ({self.repo_link}) on {self.branch}"
102
+ message = (
103
+ f"DependencyError: {self.repo_name} ({self.repo_link}) on {self.branch}"
104
+ )
105
+ if self.__cause__ is not None:
106
+ message += f" ({type(self.__cause__).__name__}: {self.__cause__})"
107
+ return message
103
108
 
104
109
 
105
110
  class InvalidDependencyConfigError(DependencyError):
@@ -84,6 +84,13 @@ if not disable_sentry:
84
84
  ArgvIntegration(),
85
85
  LoggingIntegration(sentry_logs_level=logging.DEBUG),
86
86
  ],
87
+ # By default the Sentry SDK loads integrations for all relevant libraries in
88
+ # the venv. When running devservices in the seer directory we end up loading
89
+ # Hugging Face, OpenAI, Anthropic and Celery. Likewise in sentry we end up
90
+ # loading Django, Redis and boto3. This is in spite of devservices not
91
+ # actually using those libraries. By disabling this behaviour we can reduce
92
+ # startup time for devservices.
93
+ auto_enabling_integrations=False,
87
94
  enable_logs=True,
88
95
  environment=sentry_environment,
89
96
  before_send=before_send_error,
@@ -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
@@ -408,6 +409,14 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
408
409
  return installed_dependencies
409
410
 
410
411
 
412
+ def _is_retryable_download_error(e: BaseException) -> bool:
413
+ # GitHub's API intermittently returns 5xx (and 429 when throttled). Those
414
+ # are worth retrying; other HTTP errors (404, 401, 403) won't fix themselves.
415
+ if isinstance(e, urllib.error.HTTPError):
416
+ return e.code >= 500 or e.code == 429
417
+ return True
418
+
419
+
411
420
  def _fetch_dependency(
412
421
  dependency: RemoteConfig,
413
422
  dependency_repo_dir: str,
@@ -433,15 +442,18 @@ def _fetch_dependency(
433
442
 
434
443
  def _download() -> bytes:
435
444
  req = github.build_api_request(zip_url)
436
- with urllib.request.urlopen(req) as response:
445
+ with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT) as response:
437
446
  return bytes(response.read())
438
447
 
439
448
  try:
440
449
  zip_data = io.BytesIO(
441
450
  retry(
442
451
  _download,
452
+ retries=5,
453
+ delay=1.0,
454
+ backoff=2.0,
443
455
  exceptions=(OSError,),
444
- should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
456
+ should_retry=_is_retryable_download_error,
445
457
  )
446
458
  )
447
459
  except (OSError, ValueError) as e:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import concurrent.futures
3
4
  import json
4
5
  import logging
5
6
  import os
@@ -9,7 +10,6 @@ import shlex
9
10
  import subprocess
10
11
  import time
11
12
  from typing import NamedTuple
12
- from typing import cast
13
13
 
14
14
  from packaging import version
15
15
  from sentry_sdk import logger as sentry_logger
@@ -18,6 +18,7 @@ from devservices.configs.service_config import load_service_config_from_file
18
18
  from devservices.constants import CONFIG_FILE_NAME
19
19
  from devservices.constants import DEVSERVICES_DIR_NAME
20
20
  from devservices.constants import DOCKER_COMPOSE_DOWNLOAD_URL
21
+ from devservices.constants import DOCKER_COMPOSE_INSTALL_VERSION
21
22
  from devservices.constants import DOCKER_USER_PLUGIN_DIR
22
23
  from devservices.constants import LOGGER_NAME
23
24
  from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
@@ -68,7 +69,7 @@ def install_docker_compose() -> None:
68
69
  else:
69
70
  raise DockerComposeInstallationError(f"Unsupported operating system: {system}")
70
71
 
71
- 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}"
72
73
  destination = os.path.join(DOCKER_USER_PLUGIN_DIR, binary_name)
73
74
  os.makedirs(DOCKER_USER_PLUGIN_DIR, exist_ok=True)
74
75
 
@@ -76,14 +77,14 @@ def install_docker_compose() -> None:
76
77
  install_binary(
77
78
  binary_name,
78
79
  destination,
79
- MINIMUM_DOCKER_COMPOSE_VERSION,
80
+ DOCKER_COMPOSE_INSTALL_VERSION,
80
81
  url,
81
82
  )
82
83
  except BinaryInstallError as e:
83
84
  raise DockerComposeInstallationError(f"Failed to install Docker Compose: {e}")
84
85
 
85
86
  console.success(
86
- f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}"
87
+ f"Docker Compose {DOCKER_COMPOSE_INSTALL_VERSION} installed successfully to {destination}"
87
88
  )
88
89
 
89
90
  # Verify the installation
@@ -137,42 +138,25 @@ def check_docker_compose_version() -> None:
137
138
  # Throw an error if docker daemon isn't running
138
139
  check_docker_daemon_running()
139
140
  try:
140
- # Run the docker compose version command
141
- docker_compose_version = get_docker_compose_version()
141
+ version_output = get_docker_compose_version()
142
142
  except DockerComposeError:
143
- docker_compose_version = None
144
143
  console.warning(
145
- 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}"
146
145
  )
146
+ install_docker_compose()
147
+ return
147
148
 
148
- # Extract the version number from the output
149
- version_output = (
150
- docker_compose_version if docker_compose_version is not None else ""
151
- )
152
-
153
- # Use regex to find the version number
154
- pattern = r"^(\d+\.\d+\.\d+)"
155
-
156
- match = re.search(pattern, version_output)
157
- if match:
158
- # There is a chance that Any type is returned, so cast this
159
- docker_compose_version = cast(str, match.group(1))
160
- else:
161
- docker_compose_version = None
162
-
163
- 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:
164
152
  console.warning(
165
- 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}"
166
154
  )
167
- elif version.parse(docker_compose_version) != version.parse(
168
- MINIMUM_DOCKER_COMPOSE_VERSION
169
- ):
155
+ elif version.parse(match.group(1)) < version.parse(MINIMUM_DOCKER_COMPOSE_VERSION):
170
156
  console.warning(
171
- 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}"
172
158
  )
173
- elif version.parse(docker_compose_version) == version.parse(
174
- MINIMUM_DOCKER_COMPOSE_VERSION
175
- ):
159
+ else:
176
160
  return
177
161
  install_docker_compose()
178
162
 
@@ -254,17 +238,25 @@ def get_docker_compose_commands_to_run(
254
238
  service_config_file_path: str,
255
239
  mode_dependencies: list[str],
256
240
  ) -> list[DockerComposeCommand]:
241
+ dependency_config_paths = [
242
+ os.path.join(dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME)
243
+ for dependency in remote_dependencies
244
+ ]
245
+
246
+ # Each lookup shells out to `docker compose config`, so run them concurrently
247
+ with concurrent.futures.ThreadPoolExecutor() as executor:
248
+ *dependency_non_remote_services, non_remote_services = executor.map(
249
+ lambda config_path: get_non_remote_services(config_path, current_env),
250
+ [*dependency_config_paths, service_config_file_path],
251
+ )
252
+
257
253
  docker_compose_commands = []
258
- for dependency in remote_dependencies:
254
+ for dependency, dependency_config_path, dependency_services in zip(
255
+ remote_dependencies, dependency_config_paths, dependency_non_remote_services
256
+ ):
259
257
  # TODO: Consider passing in service config in InstalledRemoteDependency instead of loading it here
260
258
  dependency_service_config = load_service_config_from_file(dependency.repo_path)
261
- dependency_config_path = os.path.join(
262
- dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
263
- )
264
- non_remote_services = get_non_remote_services(
265
- dependency_config_path, current_env
266
- )
267
- services_to_use = non_remote_services.intersection(
259
+ services_to_use = dependency_services.intersection(
268
260
  set(dependency_service_config.modes[dependency.mode])
269
261
  )
270
262
  docker_compose_commands.append(
@@ -278,7 +270,6 @@ def get_docker_compose_commands_to_run(
278
270
  )
279
271
 
280
272
  # Add docker compose command for the top level service
281
- non_remote_services = get_non_remote_services(service_config_file_path, current_env)
282
273
  services_to_use = non_remote_services.intersection(set(mode_dependencies))
283
274
  if len(services_to_use) > 0:
284
275
  docker_compose_commands.append(
@@ -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:
@@ -12,6 +12,7 @@ def retry(
12
12
  *,
13
13
  retries: int = 3,
14
14
  delay: float = 1.0,
15
+ backoff: float = 1.0,
15
16
  exceptions: tuple[type[BaseException], ...] = (Exception,),
16
17
  on_retry: Callable[[BaseException, int], None] | None = None,
17
18
  should_retry: Callable[[BaseException], bool] | None = None,
@@ -23,7 +24,8 @@ def retry(
23
24
  Args:
24
25
  fn: Callable to invoke.
25
26
  retries: Total number of attempts (must be >= 1).
26
- delay: Seconds to sleep between attempts.
27
+ delay: Seconds to sleep before the first retry.
28
+ backoff: Multiplier applied to the delay after each retry.
27
29
  exceptions: Exception types that trigger a retry.
28
30
  on_retry: Optional callback(exc, attempts_remaining) invoked before each sleep.
29
31
  should_retry: Optional predicate; if it returns False the exception is re-raised
@@ -41,5 +43,5 @@ def retry(
41
43
  raise
42
44
  if on_retry is not None:
43
45
  on_retry(e, retries - attempt - 1)
44
- time.sleep(delay)
46
+ time.sleep(delay * backoff**attempt)
45
47
  raise AssertionError("unreachable")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.5.1
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.1"
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:
@@ -106,7 +106,7 @@ def test_fetch_dependency_network_error(tmp_path: Path) -> None:
106
106
  _fetch_dependency(dep, str(tmp_path / "dest"))
107
107
 
108
108
 
109
- def test_fetch_dependency_http_error_not_retried(tmp_path: Path) -> None:
109
+ def test_fetch_dependency_client_error_not_retried(tmp_path: Path) -> None:
110
110
  dep = RemoteConfig(
111
111
  repo_name="test-repo",
112
112
  branch="main",
@@ -125,10 +125,75 @@ def test_fetch_dependency_http_error_not_retried(tmp_path: Path) -> None:
125
125
  )
126
126
  with urlopen_mock as mock_urlopen, pytest.raises(DependencyError):
127
127
  _fetch_dependency(dep, str(tmp_path / "dest"))
128
- # HTTP errors must not be retried, so urlopen is only ever called once.
128
+ # Client errors won't fix themselves, so urlopen is only ever called once.
129
129
  mock_urlopen.assert_called_once()
130
130
 
131
131
 
132
+ def _http_error(code: int, msg: str) -> urllib.error.HTTPError:
133
+ return urllib.error.HTTPError(
134
+ url="https://api.github.com/repos/getsentry/test-repo/zipball/main",
135
+ code=code,
136
+ msg=msg,
137
+ hdrs=None, # type: ignore[arg-type]
138
+ fp=None,
139
+ )
140
+
141
+
142
+ @pytest.mark.parametrize(
143
+ ("code", "msg"),
144
+ [(500, "Internal Server Error"), (503, "Service Unavailable"), (429, "Too Many")],
145
+ )
146
+ def test_fetch_dependency_retries_transient_http_error(
147
+ tmp_path: Path, code: int, msg: str
148
+ ) -> None:
149
+ dep = RemoteConfig(
150
+ repo_name="test-repo",
151
+ branch="main",
152
+ repo_link="https://github.com/getsentry/test-repo",
153
+ )
154
+ zip_bytes = _make_zip_bytes(BASIC_SERVICE_CONFIG)
155
+ dest = str(tmp_path / "dest")
156
+ with (
157
+ mock.patch("devservices.utils.retry.time.sleep") as mock_sleep,
158
+ mock.patch(
159
+ "devservices.utils.dependencies.urllib.request.urlopen",
160
+ side_effect=[
161
+ _http_error(code, msg),
162
+ _http_error(code, msg),
163
+ _make_urlopen_response(zip_bytes),
164
+ ],
165
+ ) as mock_urlopen,
166
+ ):
167
+ _fetch_dependency(dep, dest)
168
+ assert mock_urlopen.call_count == 3
169
+ assert [c.args[0] for c in mock_sleep.call_args_list] == [1.0, 2.0]
170
+ assert (Path(dest) / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME).exists()
171
+
172
+
173
+ def test_fetch_dependency_server_error_exhausts_retries(tmp_path: Path) -> None:
174
+ dep = RemoteConfig(
175
+ repo_name="test-repo",
176
+ branch="main",
177
+ repo_link="https://github.com/getsentry/test-repo",
178
+ )
179
+ with (
180
+ mock.patch("devservices.utils.retry.time.sleep") as mock_sleep,
181
+ mock.patch(
182
+ "devservices.utils.dependencies.urllib.request.urlopen",
183
+ side_effect=_http_error(500, "Internal Server Error"),
184
+ ) as mock_urlopen,
185
+ pytest.raises(DependencyError) as exc_info,
186
+ ):
187
+ _fetch_dependency(dep, str(tmp_path / "dest"))
188
+ assert mock_urlopen.call_count == 5
189
+ assert [c.args[0] for c in mock_sleep.call_args_list] == [1.0, 2.0, 4.0, 8.0]
190
+ # The underlying cause is surfaced so CI logs show what actually failed.
191
+ assert str(exc_info.value) == (
192
+ "DependencyError: test-repo (https://github.com/getsentry/test-repo) on main "
193
+ "(HTTPError: HTTP Error 500: Internal Server Error)"
194
+ )
195
+
196
+
132
197
  def test_fetch_dependency_read_error(tmp_path: Path) -> None:
133
198
  dep = RemoteConfig(
134
199
  repo_name="test-repo",
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import subprocess
5
+ from collections.abc import Callable
5
6
  from pathlib import Path
6
7
  from unittest import mock
7
8
 
@@ -10,6 +11,7 @@ import pytest
10
11
  from devservices.configs.service_config import load_service_config_from_file
11
12
  from devservices.constants import CONFIG_FILE_NAME
12
13
  from devservices.constants import DEVSERVICES_DIR_NAME
14
+ from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
13
15
  from devservices.exceptions import BinaryInstallError
14
16
  from devservices.exceptions import DockerComposeError
15
17
  from devservices.exceptions import DockerComposeInstallationError
@@ -27,10 +29,29 @@ from devservices.utils.services import Service
27
29
  from testing.utils import create_mock_git_repo
28
30
 
29
31
 
32
+ def mock_docker_compose_config_services(
33
+ services_by_repo_path: dict[str, str],
34
+ ) -> Callable[..., subprocess.CompletedProcess[str]]:
35
+ def _run(
36
+ cmd: list[str], *args: object, **kwargs: object
37
+ ) -> subprocess.CompletedProcess[str]:
38
+ config_path = cmd[cmd.index("-f") + 1]
39
+ repo_path = os.path.dirname(os.path.dirname(config_path))
40
+ return subprocess.CompletedProcess(
41
+ args=cmd, returncode=0, stdout=services_by_repo_path[repo_path]
42
+ )
43
+
44
+ return _run
45
+
46
+
30
47
  @mock.patch("subprocess.run")
31
- def test_check_docker_compose_version_success(mock_run: mock.Mock) -> None:
32
- 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"
33
53
  check_docker_compose_version() # Should not raise any exception
54
+ mock_install_docker_compose.assert_not_called()
34
55
 
35
56
 
36
57
  @mock.patch("subprocess.run")
@@ -62,7 +83,33 @@ def test_check_docker_compose_version_unsupported(
62
83
  ) -> None:
63
84
  mock_run.return_value.stdout = "2.20.0-desktop.1\n"
64
85
  check_docker_compose_version()
65
- 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()
66
113
 
67
114
 
68
115
  @mock.patch("subprocess.run")
@@ -170,7 +217,7 @@ def test_install_docker_compose_binary_install_error(
170
217
 
171
218
  @mock.patch("platform.system", return_value="Darwin")
172
219
  @mock.patch("platform.machine", return_value="arm64")
173
- @mock.patch("devservices.utils.install_binary.urlretrieve")
220
+ @mock.patch("devservices.utils.install_binary.download_file")
174
221
  @mock.patch("devservices.utils.install_binary.os.chmod")
175
222
  @mock.patch("devservices.utils.install_binary.shutil.move")
176
223
  @mock.patch(
@@ -186,7 +233,7 @@ def test_install_docker_compose_compose_verification_error(
186
233
  _mock_get_docker_compose_version: mock.Mock,
187
234
  _mock_shutil_move: mock.Mock,
188
235
  _mock_chmod: mock.Mock,
189
- _mock_urlretrieve: mock.Mock,
236
+ _mock_download_file: mock.Mock,
190
237
  _mock_machine: mock.Mock,
191
238
  _mock_system: mock.Mock,
192
239
  ) -> None:
@@ -200,26 +247,26 @@ def test_install_docker_compose_compose_verification_error(
200
247
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
201
248
  @mock.patch("platform.system", return_value="Darwin")
202
249
  @mock.patch("platform.machine", return_value="arm64")
203
- @mock.patch("devservices.utils.install_binary.urlretrieve")
250
+ @mock.patch("devservices.utils.install_binary.download_file")
204
251
  @mock.patch("devservices.utils.install_binary.os.chmod")
205
252
  @mock.patch("devservices.utils.install_binary.shutil.move")
206
253
  @mock.patch(
207
254
  "devservices.utils.docker_compose.get_docker_compose_version",
208
- return_value="2.29.7\n",
255
+ return_value="5.6.0\n",
209
256
  )
210
257
  def test_install_docker_compose_macos_arm64(
211
258
  mock_get_docker_compose_version: mock.Mock,
212
259
  mock_shutil_move: mock.Mock,
213
260
  mock_chmod: mock.Mock,
214
- mock_urlretrieve: mock.Mock,
261
+ mock_download_file: mock.Mock,
215
262
  _mock_machine: mock.Mock,
216
263
  _mock_system: mock.Mock,
217
264
  mock_tempdir: mock.Mock,
218
265
  ) -> None:
219
266
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
220
267
  install_docker_compose()
221
- mock_urlretrieve.assert_called_once_with(
222
- "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",
223
270
  "tempdir/docker-compose",
224
271
  )
225
272
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -233,26 +280,26 @@ def test_install_docker_compose_macos_arm64(
233
280
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
234
281
  @mock.patch("platform.system", return_value="Linux")
235
282
  @mock.patch("platform.machine", return_value="x86_64")
236
- @mock.patch("devservices.utils.install_binary.urlretrieve")
283
+ @mock.patch("devservices.utils.install_binary.download_file")
237
284
  @mock.patch("devservices.utils.install_binary.os.chmod")
238
285
  @mock.patch("devservices.utils.install_binary.shutil.move")
239
286
  @mock.patch(
240
287
  "devservices.utils.docker_compose.get_docker_compose_version",
241
- return_value="2.29.7\n",
288
+ return_value="5.6.0\n",
242
289
  )
243
290
  def test_install_docker_compose_linux_x86(
244
291
  mock_get_docker_compose_version: mock.Mock,
245
292
  mock_shutil_move: mock.Mock,
246
293
  mock_chmod: mock.Mock,
247
- mock_urlretrieve: mock.Mock,
294
+ mock_download_file: mock.Mock,
248
295
  _mock_machine: mock.Mock,
249
296
  _mock_system: mock.Mock,
250
297
  mock_tempdir: mock.Mock,
251
298
  ) -> None:
252
299
  mock_tempdir.return_value.__enter__.return_value = "tempdir"
253
300
  install_docker_compose()
254
- mock_urlretrieve.assert_called_once_with(
255
- "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",
256
303
  "tempdir/docker-compose",
257
304
  )
258
305
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -266,18 +313,18 @@ def test_install_docker_compose_linux_x86(
266
313
  @mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
267
314
  @mock.patch("platform.system", return_value="Darwin")
268
315
  @mock.patch("platform.machine", return_value="arm64")
269
- @mock.patch("devservices.utils.install_binary.urlretrieve")
316
+ @mock.patch("devservices.utils.install_binary.download_file")
270
317
  @mock.patch("devservices.utils.install_binary.os.chmod")
271
318
  @mock.patch("devservices.utils.install_binary.shutil.move")
272
319
  @mock.patch(
273
320
  "devservices.utils.docker_compose.get_docker_compose_version",
274
- return_value="2.29.7\n",
321
+ return_value="5.6.0\n",
275
322
  )
276
323
  def test_install_docker_compose_custom_docker_config_dir(
277
324
  mock_get_docker_compose_version: mock.Mock,
278
325
  mock_shutil_move: mock.Mock,
279
326
  mock_chmod: mock.Mock,
280
- mock_urlretrieve: mock.Mock,
327
+ mock_download_file: mock.Mock,
281
328
  _mock_machine: mock.Mock,
282
329
  _mock_system: mock.Mock,
283
330
  mock_tempdir: mock.Mock,
@@ -288,8 +335,8 @@ def test_install_docker_compose_custom_docker_config_dir(
288
335
  "tempdir/docker/config/cli-plugins",
289
336
  ):
290
337
  install_docker_compose()
291
- mock_urlretrieve.assert_called_once_with(
292
- "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",
293
340
  "tempdir/docker-compose",
294
341
  )
295
342
  mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
@@ -452,18 +499,12 @@ def test_get_all_commands_to_run_simple_remote(
452
499
  parent_service_repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
453
500
  )
454
501
  mode_dependencies = service_config.modes["default"]
455
- mock_run.side_effect = [
456
- subprocess.CompletedProcess(
457
- args=["docker", "compose", "config", "--services"],
458
- returncode=0,
459
- stdout="child-service\n",
460
- ),
461
- subprocess.CompletedProcess(
462
- args=["docker", "compose", "config", "--services"],
463
- returncode=0,
464
- stdout="parent-service\n",
465
- ),
466
- ]
502
+ mock_run.side_effect = mock_docker_compose_config_services(
503
+ {
504
+ child_service_repo_path_str: "child-service\n",
505
+ parent_service_repo_path_str: "parent-service\n",
506
+ }
507
+ )
467
508
  commands = get_docker_compose_commands_to_run(
468
509
  service=service,
469
510
  remote_dependencies=remote_dependencies,
@@ -555,23 +596,13 @@ def test_get_all_commands_to_run_complex_remote(
555
596
  grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
556
597
  )
557
598
  mode_dependencies = service_config.modes["default"]
558
- mock_run.side_effect = [
559
- subprocess.CompletedProcess(
560
- args=["docker", "compose", "config", "--services"],
561
- returncode=0,
562
- stdout="child-service\n",
563
- ),
564
- subprocess.CompletedProcess(
565
- args=["docker", "compose", "config", "--services"],
566
- returncode=0,
567
- stdout="parent-service\n",
568
- ),
569
- subprocess.CompletedProcess(
570
- args=["docker", "compose", "config", "--services"],
571
- returncode=0,
572
- stdout="grandparent-service\n",
573
- ),
574
- ]
599
+ mock_run.side_effect = mock_docker_compose_config_services(
600
+ {
601
+ child_service_repo_path_str: "child-service\n",
602
+ parent_service_repo_path_str: "parent-service\n",
603
+ grandparent_service_repo_path_str: "grandparent-service\n",
604
+ }
605
+ )
575
606
  commands = get_docker_compose_commands_to_run(
576
607
  service=service,
577
608
  remote_dependencies=list(remote_dependencies),
@@ -685,23 +716,13 @@ def test_get_all_commands_to_run_complex_shared_dependency(
685
716
  grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
686
717
  )
687
718
  mode_dependencies = service_config.modes["default"]
688
- mock_run.side_effect = [
689
- subprocess.CompletedProcess(
690
- args=["docker", "compose", "config", "--services"],
691
- returncode=0,
692
- stdout="child-service\n",
693
- ),
694
- subprocess.CompletedProcess(
695
- args=["docker", "compose", "config", "--services"],
696
- returncode=0,
697
- stdout="parent-service\n",
698
- ),
699
- subprocess.CompletedProcess(
700
- args=["docker", "compose", "config", "--services"],
701
- returncode=0,
702
- stdout="grandparent-service\n",
703
- ),
704
- ]
719
+ mock_run.side_effect = mock_docker_compose_config_services(
720
+ {
721
+ child_service_repo_path_str: "child-service\n",
722
+ parent_service_repo_path_str: "parent-service\n",
723
+ grandparent_service_repo_path_str: "grandparent-service\n",
724
+ }
725
+ )
705
726
  commands = get_docker_compose_commands_to_run(
706
727
  service=service,
707
728
  remote_dependencies=remote_dependencies,
@@ -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