devservices 1.5.0__tar.gz → 1.5.3__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.0 → devservices-1.5.3}/PKG-INFO +1 -1
  2. {devservices-1.5.0 → devservices-1.5.3}/README.md +1 -1
  3. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/up.py +68 -31
  4. {devservices-1.5.0 → devservices-1.5.3}/devservices/exceptions.py +6 -1
  5. {devservices-1.5.0 → devservices-1.5.3}/devservices/main.py +7 -0
  6. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/dependencies.py +64 -28
  7. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/docker_compose.py +17 -9
  8. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/retry.py +4 -2
  9. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/PKG-INFO +1 -1
  10. {devservices-1.5.0 → devservices-1.5.3}/pyproject.toml +1 -1
  11. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_dependencies.py +148 -2
  12. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_docker_compose.py +36 -46
  13. {devservices-1.5.0 → devservices-1.5.3}/LICENSE.md +0 -0
  14. {devservices-1.5.0 → devservices-1.5.3}/devservices/__init__.py +0 -0
  15. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/__init__.py +0 -0
  16. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/down.py +0 -0
  17. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/foreground.py +0 -0
  18. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/list_dependencies.py +0 -0
  19. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/list_services.py +0 -0
  20. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/logs.py +0 -0
  21. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/purge.py +0 -0
  22. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/reset.py +0 -0
  23. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/serve.py +0 -0
  24. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/status.py +0 -0
  25. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/toggle.py +0 -0
  26. {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/update.py +0 -0
  27. {devservices-1.5.0 → devservices-1.5.3}/devservices/configs/service_config.py +0 -0
  28. {devservices-1.5.0 → devservices-1.5.3}/devservices/constants.py +0 -0
  29. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/__init__.py +0 -0
  30. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/check_for_update.py +0 -0
  31. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/console.py +0 -0
  32. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/devenv.py +0 -0
  33. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/docker.py +0 -0
  34. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/file_lock.py +0 -0
  35. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/git.py +0 -0
  36. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/github.py +0 -0
  37. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/install_binary.py +0 -0
  38. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/services.py +0 -0
  39. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/state.py +0 -0
  40. {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/supervisor.py +0 -0
  41. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/SOURCES.txt +0 -0
  42. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/dependency_links.txt +0 -0
  43. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/entry_points.txt +0 -0
  44. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/requires.txt +0 -0
  45. {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/top_level.txt +0 -0
  46. {devservices-1.5.0 → devservices-1.5.3}/setup.cfg +0 -0
  47. {devservices-1.5.0 → devservices-1.5.3}/testing/__init__.py +0 -0
  48. {devservices-1.5.0 → devservices-1.5.3}/testing/utils.py +0 -0
  49. {devservices-1.5.0 → devservices-1.5.3}/tests/__init__.py +0 -0
  50. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_down.py +0 -0
  51. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_foreground.py +0 -0
  52. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_list_dependencies.py +0 -0
  53. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_list_services.py +0 -0
  54. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_logs.py +0 -0
  55. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_purge.py +0 -0
  56. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_reset.py +0 -0
  57. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_serve.py +0 -0
  58. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_status.py +0 -0
  59. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_toggle.py +0 -0
  60. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_up.py +0 -0
  61. {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_update.py +0 -0
  62. {devservices-1.5.0 → devservices-1.5.3}/tests/configs/test_service_config.py +0 -0
  63. {devservices-1.5.0 → devservices-1.5.3}/tests/conftest.py +0 -0
  64. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_check_for_update.py +0 -0
  65. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_docker.py +0 -0
  66. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_git.py +0 -0
  67. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_github.py +0 -0
  68. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_install_binary.py +0 -0
  69. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_services.py +0 -0
  70. {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_state.py +0 -0
  71. {devservices-1.5.0 → devservices-1.5.3}/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.0
3
+ Version: 1.5.3
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.0
36
+ devservices==1.5.3
37
37
  ```
38
38
 
39
39
  ### 2. Add devservices config files
@@ -30,6 +30,7 @@ from devservices.exceptions import SupervisorError
30
30
  from devservices.utils.console import Console
31
31
  from devservices.utils.console import Status
32
32
  from devservices.utils.dependencies import DependencyNode
33
+ from devservices.utils.dependencies import DependencyUpdateMode
33
34
  from devservices.utils.dependencies import InstalledRemoteDependency
34
35
  from devservices.utils.dependencies import construct_dependency_graph
35
36
  from devservices.utils.dependencies import install_and_verify_dependencies
@@ -68,6 +69,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
68
69
  action="store_true",
69
70
  default=False,
70
71
  )
72
+ parser.add_argument(
73
+ "--offline",
74
+ help="Use cached dependencies and images without accessing the network",
75
+ action="store_true",
76
+ default=False,
77
+ )
71
78
  parser.set_defaults(func=up)
72
79
 
73
80
 
@@ -96,6 +103,7 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
96
103
  modes = service.config.modes
97
104
  mode = args.mode
98
105
  exclude_local = getattr(args, "exclude_local", False)
106
+ offline = getattr(args, "offline", False)
99
107
 
100
108
  sentry_logger.info(
101
109
  "Starting service",
@@ -162,7 +170,9 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
162
170
  },
163
171
  )
164
172
 
165
- remote_dependencies = _install_service_dependencies(service, mode, status)
173
+ remote_dependencies = _install_service_dependencies(
174
+ service, mode, status, offline
175
+ )
166
176
  _create_devservices_network()
167
177
  # Add the service to the starting services table
168
178
  state.update_service_entry(service.name, mode, StateTables.STARTING_SERVICES)
@@ -204,6 +214,7 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
204
214
  service_name=local_runtime_dependency_name,
205
215
  mode="default", # We intentionally don't use the mode from the parent command here
206
216
  exclude_local=True, # TODO: This should be False (or maybe whatever the parent command is set to)
217
+ offline=offline,
207
218
  ),
208
219
  status,
209
220
  )
@@ -216,7 +227,12 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
216
227
  span.set_data("exclude_local", exclude_local)
217
228
  try:
218
229
  bring_up_docker_compose_services(
219
- service, [mode], remote_dependencies, mode_dependencies, status
230
+ service,
231
+ [mode],
232
+ remote_dependencies,
233
+ mode_dependencies,
234
+ status,
235
+ offline,
220
236
  )
221
237
  except DockerComposeError as dce:
222
238
  capture_exception(dce, level="info")
@@ -239,25 +255,39 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
239
255
 
240
256
 
241
257
  def _install_service_dependencies(
242
- service: Service, mode: str, status: Status
258
+ service: Service, mode: str, status: Status, offline: bool = False
243
259
  ) -> set[InstalledRemoteDependency]:
244
260
  with start_span(
245
261
  op="service.dependencies.install", name="Install dependencies"
246
262
  ) as span:
247
- status.info("Retrieving dependencies")
263
+ status.info(
264
+ "Using cached dependencies" if offline else "Retrieving dependencies"
265
+ )
248
266
  span.set_data("service_name", service.name)
249
267
  span.set_data("mode", mode)
268
+ span.set_data("offline", offline)
269
+
270
+ update_mode = (
271
+ DependencyUpdateMode.OFFLINE if offline else DependencyUpdateMode.FORCE
272
+ )
250
273
  try:
251
274
  remote_dependencies = install_and_verify_dependencies(
252
- service, force_update_dependencies=True, modes=[mode]
275
+ service,
276
+ update_mode,
277
+ modes=[mode],
253
278
  )
254
279
  span.set_data("remote_dependency_count", len(remote_dependencies))
255
280
  return remote_dependencies
256
281
  except DependencyError as de:
257
282
  capture_exception(de)
258
- status.failure(
259
- f"{str(de)}. If this error persists, try running `devservices purge`"
260
- )
283
+ if offline:
284
+ status.failure(
285
+ f"{str(de)}. Run `devservices up` without `--offline` to fetch and cache dependencies"
286
+ )
287
+ else:
288
+ status.failure(
289
+ f"{str(de)}. If this error persists, try running `devservices purge`"
290
+ )
261
291
  exit(1)
262
292
  except ModeDoesNotExistError as mde:
263
293
  status.failure(str(mde))
@@ -310,6 +340,7 @@ def bring_up_docker_compose_services(
310
340
  remote_dependencies: set[InstalledRemoteDependency],
311
341
  mode_dependencies: list[str],
312
342
  status: Status,
343
+ offline: bool = False,
313
344
  ) -> None:
314
345
  relative_local_dependency_directory = os.path.relpath(
315
346
  os.path.join(DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION),
@@ -334,35 +365,41 @@ def bring_up_docker_compose_services(
334
365
  ),
335
366
  )
336
367
 
337
- # Pull all images in parallel
338
- status.info("Pulling images")
339
- pull_commands = get_docker_compose_commands_to_run(
340
- service=service,
341
- remote_dependencies=sorted_remote_dependencies,
342
- current_env=current_env,
343
- command="pull",
344
- options=[],
345
- service_config_file_path=service_config_file_path,
346
- mode_dependencies=mode_dependencies,
347
- )
348
-
349
- with concurrent.futures.ThreadPoolExecutor() as pull_dependency_executor:
350
- futures = [
351
- pull_dependency_executor.submit(
352
- _pull_dependency_images, cmd, current_env, status
353
- )
354
- for cmd in pull_commands
355
- ]
356
- for future in concurrent.futures.as_completed(futures):
357
- _ = future.result()
368
+ if offline:
369
+ status.info("Skipping image pull (offline mode)")
370
+ else:
371
+ # Pull all images in parallel
372
+ status.info("Pulling images")
373
+ pull_commands = get_docker_compose_commands_to_run(
374
+ service=service,
375
+ remote_dependencies=sorted_remote_dependencies,
376
+ current_env=current_env,
377
+ command="pull",
378
+ options=[],
379
+ service_config_file_path=service_config_file_path,
380
+ mode_dependencies=mode_dependencies,
381
+ )
358
382
 
359
- # Bring up all necessary containers
383
+ with concurrent.futures.ThreadPoolExecutor() as pull_dependency_executor:
384
+ futures = [
385
+ pull_dependency_executor.submit(
386
+ _pull_dependency_images, cmd, current_env, status
387
+ )
388
+ for cmd in pull_commands
389
+ ]
390
+ for future in concurrent.futures.as_completed(futures):
391
+ _ = future.result()
392
+
393
+ # Bring up all necessary containers. In offline mode, never pull missing images.
394
+ up_options = ["-d"]
395
+ if offline:
396
+ up_options += ["--pull", "never"]
360
397
  up_commands = get_docker_compose_commands_to_run(
361
398
  service=service,
362
399
  remote_dependencies=sorted_remote_dependencies,
363
400
  current_env=current_env,
364
401
  command="up",
365
- options=["-d"],
402
+ options=up_options,
366
403
  service_config_file_path=service_config_file_path,
367
404
  mode_dependencies=mode_dependencies,
368
405
  )
@@ -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,
@@ -10,6 +10,7 @@ from collections import deque
10
10
  from concurrent.futures import ThreadPoolExecutor
11
11
  from concurrent.futures import as_completed
12
12
  from dataclasses import dataclass
13
+ from enum import StrEnum
13
14
  from typing import TypeGuard
14
15
 
15
16
  from sentry_sdk import logger as sentry_logger
@@ -113,13 +114,29 @@ class InstalledRemoteDependency:
113
114
  mode: str = "default"
114
115
 
115
116
 
117
+ class DependencyUpdateMode(StrEnum):
118
+ """Controls how remote dependencies are resolved during installation.
119
+
120
+ FORCE always fetches from the network, IF_NEEDED uses the cache when valid and
121
+ otherwise fetches, and OFFLINE never fetches and raises if the cache is
122
+ incomplete.
123
+ """
124
+
125
+ FORCE = "force"
126
+ IF_NEEDED = "if_needed"
127
+ OFFLINE = "offline"
128
+
129
+
116
130
  def install_and_verify_dependencies(
117
131
  service: Service,
118
- force_update_dependencies: bool = False,
132
+ update_mode: DependencyUpdateMode = DependencyUpdateMode.IF_NEEDED,
119
133
  modes: list[str] | None = None,
120
134
  ) -> set[InstalledRemoteDependency]:
121
135
  """
122
- Install and verify dependencies for a service
136
+ Install and verify dependencies for a service.
137
+
138
+ update_mode controls whether dependencies are fetched from the network. See
139
+ DependencyUpdateMode.
123
140
  """
124
141
  if modes is None:
125
142
  modes = ["default"]
@@ -138,18 +155,18 @@ def install_and_verify_dependencies(
138
155
  if dependency_key in mode_dependencies
139
156
  ]
140
157
 
141
- if force_update_dependencies:
142
- remote_dependencies = install_dependencies(matching_dependencies)
158
+ skip_dependency_install = update_mode is DependencyUpdateMode.OFFLINE or (
159
+ update_mode is DependencyUpdateMode.IF_NEEDED
160
+ and verify_local_dependencies(matching_dependencies)
161
+ )
162
+
163
+ if skip_dependency_install:
164
+ remote_dependencies = get_installed_remote_dependencies(matching_dependencies)
143
165
  else:
144
- are_dependencies_valid = verify_local_dependencies(matching_dependencies)
145
- if not are_dependencies_valid:
146
- # TODO: Figure out how to handle this case as installing dependencies may not be the right thing to do
147
- # since the dependencies may have changed since the service was started.
148
- remote_dependencies = install_dependencies(matching_dependencies)
149
- else:
150
- remote_dependencies = get_installed_remote_dependencies(
151
- matching_dependencies
152
- )
166
+ # TODO: Figure out how to handle this case as installing dependencies may not be the right thing to do
167
+ # since the dependencies may have changed since the service was started.
168
+ remote_dependencies = install_dependencies(matching_dependencies)
169
+
153
170
  return remote_dependencies
154
171
 
155
172
 
@@ -275,9 +292,10 @@ def get_installed_remote_dependencies(
275
292
  mode=remote_config.mode,
276
293
  )
277
294
  )
278
- nested_remote_configs = _get_remote_configs(
279
- list(service_config.dependencies.values())
295
+ active_nested_dependencies = _get_active_nested_dependencies(
296
+ service_config, remote_config.mode
280
297
  )
298
+ nested_remote_configs = _get_remote_configs(active_nested_dependencies)
281
299
  remote_configs.extend(nested_remote_configs)
282
300
 
283
301
  return installed_dependencies
@@ -359,18 +377,9 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
359
377
  branch=dependency.branch,
360
378
  ) from e
361
379
 
362
- if dependency.mode not in installed_config.modes:
363
- raise ModeDoesNotExistError(
364
- service_name=installed_config.service_name,
365
- mode=dependency.mode,
366
- available_modes=list(installed_config.modes.keys()),
367
- )
368
-
369
- active_nested_dependencies = [
370
- nested_dependency
371
- for nested_dependency_name, nested_dependency in installed_config.dependencies.items()
372
- if nested_dependency_name in installed_config.modes[dependency.mode]
373
- ]
380
+ active_nested_dependencies = _get_active_nested_dependencies(
381
+ installed_config, dependency.mode
382
+ )
374
383
  nested_remote_configs = _get_remote_configs(active_nested_dependencies)
375
384
 
376
385
  installed_dependencies: set[InstalledRemoteDependency] = set(
@@ -399,6 +408,14 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
399
408
  return installed_dependencies
400
409
 
401
410
 
411
+ def _is_retryable_download_error(e: BaseException) -> bool:
412
+ # GitHub's API intermittently returns 5xx (and 429 when throttled). Those
413
+ # are worth retrying; other HTTP errors (404, 401, 403) won't fix themselves.
414
+ if isinstance(e, urllib.error.HTTPError):
415
+ return e.code >= 500 or e.code == 429
416
+ return True
417
+
418
+
402
419
  def _fetch_dependency(
403
420
  dependency: RemoteConfig,
404
421
  dependency_repo_dir: str,
@@ -431,8 +448,11 @@ def _fetch_dependency(
431
448
  zip_data = io.BytesIO(
432
449
  retry(
433
450
  _download,
451
+ retries=5,
452
+ delay=1.0,
453
+ backoff=2.0,
434
454
  exceptions=(OSError,),
435
- should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
455
+ should_retry=_is_retryable_download_error,
436
456
  )
437
457
  )
438
458
  except (OSError, ValueError) as e:
@@ -484,6 +504,22 @@ def _fetch_dependency(
484
504
  ) from e
485
505
 
486
506
 
507
+ def _get_active_nested_dependencies(
508
+ service_config: ServiceConfig, mode: str
509
+ ) -> list[Dependency]:
510
+ if mode not in service_config.modes:
511
+ raise ModeDoesNotExistError(
512
+ service_name=service_config.service_name,
513
+ mode=mode,
514
+ available_modes=list(service_config.modes.keys()),
515
+ )
516
+ return [
517
+ dependency
518
+ for dependency_name, dependency in service_config.dependencies.items()
519
+ if dependency_name in service_config.modes[mode]
520
+ ]
521
+
522
+
487
523
  def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
488
524
  return [
489
525
  dependency.remote
@@ -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
@@ -254,17 +255,25 @@ def get_docker_compose_commands_to_run(
254
255
  service_config_file_path: str,
255
256
  mode_dependencies: list[str],
256
257
  ) -> list[DockerComposeCommand]:
258
+ dependency_config_paths = [
259
+ os.path.join(dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME)
260
+ for dependency in remote_dependencies
261
+ ]
262
+
263
+ # Each lookup shells out to `docker compose config`, so run them concurrently
264
+ with concurrent.futures.ThreadPoolExecutor() as executor:
265
+ *dependency_non_remote_services, non_remote_services = executor.map(
266
+ lambda config_path: get_non_remote_services(config_path, current_env),
267
+ [*dependency_config_paths, service_config_file_path],
268
+ )
269
+
257
270
  docker_compose_commands = []
258
- for dependency in remote_dependencies:
271
+ for dependency, dependency_config_path, dependency_services in zip(
272
+ remote_dependencies, dependency_config_paths, dependency_non_remote_services
273
+ ):
259
274
  # TODO: Consider passing in service config in InstalledRemoteDependency instead of loading it here
260
275
  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(
276
+ services_to_use = dependency_services.intersection(
268
277
  set(dependency_service_config.modes[dependency.mode])
269
278
  )
270
279
  docker_compose_commands.append(
@@ -278,7 +287,6 @@ def get_docker_compose_commands_to_run(
278
287
  )
279
288
 
280
289
  # Add docker compose command for the top level service
281
- non_remote_services = get_non_remote_services(service_config_file_path, current_env)
282
290
  services_to_use = non_remote_services.intersection(set(mode_dependencies))
283
291
  if len(services_to_use) > 0:
284
292
  docker_compose_commands.append(
@@ -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.0
3
+ Version: 1.5.3
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.0"
7
+ version = "1.5.3"
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"
@@ -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",
@@ -316,6 +381,87 @@ def test_get_installed_remote_dependencies_single_dep_installed(tmp_path: Path)
316
381
  }
317
382
 
318
383
 
384
+ def test_get_installed_remote_dependencies_does_not_return_unnecessary_dependencies(
385
+ tmp_path: Path,
386
+ ) -> None:
387
+ """Cached traversal only returns nested deps in the active mode."""
388
+ with mock.patch(
389
+ "devservices.utils.dependencies.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
390
+ str(tmp_path / "dependency-dir"),
391
+ ):
392
+ repo_a_config = {
393
+ "x-sentry-service-config": {
394
+ "version": 0.1,
395
+ "service_name": "repo-a",
396
+ "dependencies": {
397
+ "repo-b": {
398
+ "description": "nested dependency",
399
+ "remote": {
400
+ "repo_name": "repo-b",
401
+ "repo_link": "https://github.com/getsentry/repo-b",
402
+ "branch": "main",
403
+ },
404
+ },
405
+ "unnecessary-repo": {
406
+ "description": "unnecessary nested dependency",
407
+ "remote": {
408
+ "repo_name": "unnecessary-repo",
409
+ "repo_link": "invalid-link",
410
+ "branch": "main",
411
+ },
412
+ },
413
+ },
414
+ "modes": {"default": ["repo-b"], "other": ["unnecessary-repo"]},
415
+ },
416
+ }
417
+
418
+ with mock.patch(
419
+ "devservices.utils.dependencies.urllib.request.urlopen",
420
+ side_effect=_url_dispatch(
421
+ {
422
+ "repo-a": _make_zip_bytes(repo_a_config),
423
+ "repo-b": _make_zip_bytes(BASIC_SERVICE_CONFIG),
424
+ }
425
+ ),
426
+ ):
427
+ install_dependency(
428
+ RemoteConfig(
429
+ repo_name="repo-a",
430
+ branch="main",
431
+ repo_link="https://github.com/getsentry/repo-a",
432
+ )
433
+ )
434
+
435
+ installed = get_installed_remote_dependencies(
436
+ dependencies=[
437
+ Dependency(
438
+ description="test repo",
439
+ remote=RemoteConfig(
440
+ repo_name="repo-a",
441
+ branch="main",
442
+ repo_link="https://github.com/getsentry/repo-a",
443
+ ),
444
+ dependency_type=DependencyType.SERVICE,
445
+ )
446
+ ]
447
+ )
448
+
449
+ assert installed == {
450
+ InstalledRemoteDependency(
451
+ service_name="repo-a",
452
+ repo_path=str(
453
+ tmp_path / "dependency-dir" / DEPENDENCY_CONFIG_VERSION / "repo-a"
454
+ ),
455
+ ),
456
+ InstalledRemoteDependency(
457
+ service_name="basic",
458
+ repo_path=str(
459
+ tmp_path / "dependency-dir" / DEPENDENCY_CONFIG_VERSION / "repo-b"
460
+ ),
461
+ ),
462
+ }
463
+
464
+
319
465
  def test_install_dependency_invalid_repo(tmp_path: Path) -> None:
320
466
  with mock.patch(
321
467
  "devservices.utils.dependencies.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
@@ -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
 
@@ -27,6 +28,21 @@ from devservices.utils.services import Service
27
28
  from testing.utils import create_mock_git_repo
28
29
 
29
30
 
31
+ def mock_docker_compose_config_services(
32
+ services_by_repo_path: dict[str, str],
33
+ ) -> Callable[..., subprocess.CompletedProcess[str]]:
34
+ def _run(
35
+ cmd: list[str], *args: object, **kwargs: object
36
+ ) -> subprocess.CompletedProcess[str]:
37
+ config_path = cmd[cmd.index("-f") + 1]
38
+ repo_path = os.path.dirname(os.path.dirname(config_path))
39
+ return subprocess.CompletedProcess(
40
+ args=cmd, returncode=0, stdout=services_by_repo_path[repo_path]
41
+ )
42
+
43
+ return _run
44
+
45
+
30
46
  @mock.patch("subprocess.run")
31
47
  def test_check_docker_compose_version_success(mock_run: mock.Mock) -> None:
32
48
  mock_run.return_value.stdout = "2.29.7\n"
@@ -452,18 +468,12 @@ def test_get_all_commands_to_run_simple_remote(
452
468
  parent_service_repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
453
469
  )
454
470
  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
- ]
471
+ mock_run.side_effect = mock_docker_compose_config_services(
472
+ {
473
+ child_service_repo_path_str: "child-service\n",
474
+ parent_service_repo_path_str: "parent-service\n",
475
+ }
476
+ )
467
477
  commands = get_docker_compose_commands_to_run(
468
478
  service=service,
469
479
  remote_dependencies=remote_dependencies,
@@ -555,23 +565,13 @@ def test_get_all_commands_to_run_complex_remote(
555
565
  grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
556
566
  )
557
567
  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
- ]
568
+ mock_run.side_effect = mock_docker_compose_config_services(
569
+ {
570
+ child_service_repo_path_str: "child-service\n",
571
+ parent_service_repo_path_str: "parent-service\n",
572
+ grandparent_service_repo_path_str: "grandparent-service\n",
573
+ }
574
+ )
575
575
  commands = get_docker_compose_commands_to_run(
576
576
  service=service,
577
577
  remote_dependencies=list(remote_dependencies),
@@ -685,23 +685,13 @@ def test_get_all_commands_to_run_complex_shared_dependency(
685
685
  grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
686
686
  )
687
687
  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
- ]
688
+ mock_run.side_effect = mock_docker_compose_config_services(
689
+ {
690
+ child_service_repo_path_str: "child-service\n",
691
+ parent_service_repo_path_str: "parent-service\n",
692
+ grandparent_service_repo_path_str: "grandparent-service\n",
693
+ }
694
+ )
705
695
  commands = get_docker_compose_commands_to_run(
706
696
  service=service,
707
697
  remote_dependencies=remote_dependencies,
File without changes
File without changes