devservices 1.3.1__tar.gz → 1.4.0__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 (70) hide show
  1. {devservices-1.3.1 → devservices-1.4.0}/PKG-INFO +1 -1
  2. {devservices-1.3.1 → devservices-1.4.0}/README.md +1 -1
  3. {devservices-1.3.1 → devservices-1.4.0}/devservices/constants.py +0 -5
  4. {devservices-1.3.1 → devservices-1.4.0}/devservices/exceptions.py +0 -19
  5. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/dependencies.py +66 -313
  6. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/install_binary.py +15 -20
  7. devservices-1.4.0/devservices/utils/retry.py +45 -0
  8. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/PKG-INFO +1 -1
  9. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/SOURCES.txt +1 -0
  10. {devservices-1.3.1 → devservices-1.4.0}/pyproject.toml +2 -2
  11. devservices-1.4.0/testing/utils.py +82 -0
  12. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_down.py +153 -166
  13. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_serve.py +5 -1
  14. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_status.py +9 -7
  15. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_toggle.py +87 -93
  16. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_up.py +70 -75
  17. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_dependencies.py +646 -1773
  18. devservices-1.3.1/testing/utils.py +0 -41
  19. {devservices-1.3.1 → devservices-1.4.0}/LICENSE.md +0 -0
  20. {devservices-1.3.1 → devservices-1.4.0}/devservices/__init__.py +0 -0
  21. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/__init__.py +0 -0
  22. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/down.py +0 -0
  23. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/foreground.py +0 -0
  24. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/list_dependencies.py +0 -0
  25. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/list_services.py +0 -0
  26. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/logs.py +0 -0
  27. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/purge.py +0 -0
  28. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/reset.py +0 -0
  29. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/serve.py +0 -0
  30. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/status.py +0 -0
  31. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/toggle.py +0 -0
  32. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/up.py +0 -0
  33. {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/update.py +0 -0
  34. {devservices-1.3.1 → devservices-1.4.0}/devservices/configs/service_config.py +0 -0
  35. {devservices-1.3.1 → devservices-1.4.0}/devservices/main.py +0 -0
  36. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/__init__.py +0 -0
  37. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/check_for_update.py +0 -0
  38. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/console.py +0 -0
  39. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/devenv.py +0 -0
  40. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/docker.py +0 -0
  41. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/docker_compose.py +0 -0
  42. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/file_lock.py +0 -0
  43. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/git.py +0 -0
  44. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/services.py +0 -0
  45. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/state.py +0 -0
  46. {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/supervisor.py +0 -0
  47. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/dependency_links.txt +0 -0
  48. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/entry_points.txt +0 -0
  49. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/requires.txt +0 -0
  50. {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/top_level.txt +0 -0
  51. {devservices-1.3.1 → devservices-1.4.0}/setup.cfg +0 -0
  52. {devservices-1.3.1 → devservices-1.4.0}/testing/__init__.py +0 -0
  53. {devservices-1.3.1 → devservices-1.4.0}/tests/__init__.py +0 -0
  54. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_foreground.py +0 -0
  55. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_list_dependencies.py +0 -0
  56. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_list_services.py +0 -0
  57. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_logs.py +0 -0
  58. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_purge.py +0 -0
  59. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_reset.py +0 -0
  60. {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_update.py +0 -0
  61. {devservices-1.3.1 → devservices-1.4.0}/tests/configs/test_service_config.py +0 -0
  62. {devservices-1.3.1 → devservices-1.4.0}/tests/conftest.py +0 -0
  63. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_check_for_update.py +0 -0
  64. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_docker.py +0 -0
  65. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_docker_compose.py +0 -0
  66. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_git.py +0 -0
  67. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_install_binary.py +0 -0
  68. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_services.py +0 -0
  69. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_state.py +0 -0
  70. {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_supervisor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.3.1
3
+ Version: 1.4.0
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.3.1
36
+ devservices==1.4.0
37
37
  ```
38
38
 
39
39
  ### 2. Add devservices config files
@@ -37,11 +37,6 @@ STATE_DB_FILE = os.path.join(DEVSERVICES_LOCAL_DIR, "state")
37
37
  DEVSERVICES_ORCHESTRATOR_LABEL = "orchestrator=devservices"
38
38
 
39
39
  DEPENDENCY_CONFIG_VERSION = "v1"
40
- DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS = {
41
- "protocol.version": "2",
42
- "extensions.partialClone": "true",
43
- "core.sparseCheckout": "true",
44
- }
45
40
 
46
41
  DEVSERVICES_RELEASES_URL = (
47
42
  "https://api.github.com/repos/getsentry/devservices/releases/latest"
@@ -102,13 +102,6 @@ class DependencyError(Exception):
102
102
  return f"DependencyError: {self.repo_name} ({self.repo_link}) on {self.branch}"
103
103
 
104
104
 
105
- class UnableToCloneDependencyError(DependencyError):
106
- """Raised when a dependency is unable to be cloned."""
107
-
108
- def __str__(self) -> str:
109
- return f"Unable to clone dependency: {self.repo_name} ({self.repo_link}) on {self.branch}"
110
-
111
-
112
105
  class InvalidDependencyConfigError(DependencyError):
113
106
  """Raised when a dependency's config is invalid."""
114
107
 
@@ -142,18 +135,6 @@ class GitError(Exception):
142
135
  self.stderr = stderr
143
136
 
144
137
 
145
- class GitConfigError(Exception):
146
- """Base class for git config related errors."""
147
-
148
- pass
149
-
150
-
151
- class FailedToSetGitConfigError(GitConfigError):
152
- """Raised when a git config cannot be set."""
153
-
154
- pass
155
-
156
-
157
138
  class ContainerHealthcheckFailedError(Exception):
158
139
  """Raised when a container is not healthy."""
159
140
 
@@ -1,21 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
3
+ import io
4
4
  import os
5
5
  import shutil
6
- import subprocess
7
- import tempfile
8
- import time
6
+ import urllib.error
7
+ import urllib.request
8
+ import zipfile
9
9
  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 typing import TextIO
14
13
  from typing import TypeGuard
15
14
 
16
- from sentry_sdk import capture_message
17
15
  from sentry_sdk import logger as sentry_logger
18
- from sentry_sdk import set_context
19
16
 
20
17
  from devservices.configs.service_config import Dependency
21
18
  from devservices.configs.service_config import RemoteConfig
@@ -23,21 +20,18 @@ from devservices.configs.service_config import ServiceConfig
23
20
  from devservices.configs.service_config import load_service_config_from_file
24
21
  from devservices.constants import CONFIG_FILE_NAME
25
22
  from devservices.constants import DEPENDENCY_CONFIG_VERSION
26
- from devservices.constants import DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS
27
23
  from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
28
24
  from devservices.constants import DEVSERVICES_DIR_NAME
29
- from devservices.constants import LOGGER_NAME
30
25
  from devservices.constants import DependencyType
31
26
  from devservices.exceptions import ConfigNotFoundError
32
27
  from devservices.exceptions import ConfigParseError
33
28
  from devservices.exceptions import ConfigValidationError
34
29
  from devservices.exceptions import DependencyError
35
30
  from devservices.exceptions import DependencyNotInstalledError
36
- from devservices.exceptions import FailedToSetGitConfigError
37
31
  from devservices.exceptions import InvalidDependencyConfigError
38
32
  from devservices.exceptions import ModeDoesNotExistError
39
- from devservices.exceptions import UnableToCloneDependencyError
40
33
  from devservices.utils.file_lock import lock
34
+ from devservices.utils.retry import retry
41
35
  from devservices.utils.services import Service
42
36
  from devservices.utils.services import find_matching_service
43
37
  from devservices.utils.services import get_active_service_names
@@ -45,17 +39,6 @@ from devservices.utils.state import ServiceRuntime
45
39
  from devservices.utils.state import State
46
40
  from devservices.utils.state import StateTables
47
41
 
48
- RELEVANT_GIT_CONFIG_KEYS = [
49
- "init.defaultbranch",
50
- "core.sparsecheckout",
51
- "remote.origin.url",
52
- "remote.origin.fetch",
53
- "remote.origin.promisor",
54
- "remote.origin.partialclonefilter",
55
- "protocol.version",
56
- "extensions.partialclone",
57
- ]
58
-
59
42
 
60
43
  @dataclass(frozen=True, eq=True)
61
44
  class DependencyNode:
@@ -129,87 +112,6 @@ class InstalledRemoteDependency:
129
112
  mode: str = "default"
130
113
 
131
114
 
132
- class SparseCheckoutManager:
133
- """
134
- Manages sparse checkout for a repo
135
- """
136
-
137
- def __init__(self, repo_dir: str):
138
- self.repo_dir = repo_dir
139
-
140
- def init_sparse_checkout(self) -> None:
141
- """
142
- Initialize sparse checkout for the repo
143
- """
144
- _run_command(["git", "sparse-checkout", "init"], cwd=self.repo_dir)
145
-
146
- def set_sparse_checkout(self, pattern: str) -> None:
147
- """
148
- Set sparse checkout patterns for the repo
149
- """
150
- self.init_sparse_checkout()
151
- _run_command(["git", "sparse-checkout", "set", pattern], cwd=self.repo_dir)
152
-
153
-
154
- class GitConfigManager:
155
- """
156
- Manages git config for a repo
157
- """
158
-
159
- def __init__(
160
- self,
161
- repo_dir: str,
162
- config_options: dict[str, str],
163
- sparse_pattern: str | None = None,
164
- ) -> None:
165
- self.repo_dir = repo_dir
166
- self.config_options = config_options
167
- self.sparse_pattern = sparse_pattern
168
- self.sparse_checkout_manager = SparseCheckoutManager(repo_dir)
169
-
170
- def ensure_config(self) -> None:
171
- """
172
- Ensure that the git config is set correctly for the repo
173
- """
174
- # Otherwise, set the config options
175
- for key, value in self.config_options.items():
176
- self._set_config(key, value)
177
-
178
- if self.sparse_pattern:
179
- self.sparse_checkout_manager.set_sparse_checkout(self.sparse_pattern)
180
-
181
- def get_relevant_config(self) -> dict[str, str]:
182
- """
183
- Get the relevant git config entries (to avoid logging sensitive information)
184
- """
185
- git_config = (
186
- subprocess.check_output(
187
- ["git", "config", "--list"],
188
- cwd=self.repo_dir,
189
- stderr=subprocess.PIPE,
190
- )
191
- .decode()
192
- .strip()
193
- )
194
- git_config_dict = dict()
195
- for line in git_config.split("\n"):
196
- if not line:
197
- continue
198
- key, value = line.split("=")
199
- if key in RELEVANT_GIT_CONFIG_KEYS:
200
- git_config_dict[key] = value
201
- return git_config_dict
202
-
203
- def _set_config(self, key: str, value: str) -> None:
204
- """
205
- Set a git config option for the repo
206
- """
207
- try:
208
- _run_command(["git", "config", key, value], cwd=self.repo_dir)
209
- except subprocess.CalledProcessError as e:
210
- raise FailedToSetGitConfigError from e
211
-
212
-
213
115
  def install_and_verify_dependencies(
214
116
  service: Service,
215
117
  force_update_dependencies: bool = False,
@@ -435,14 +337,7 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
435
337
  DEVSERVICES_DEPENDENCIES_CACHE_DIR, f"{dependency.repo_name}.lock"
436
338
  )
437
339
  with lock(lock_path):
438
- if (
439
- os.path.exists(dependency_repo_dir)
440
- and _is_valid_repo(dependency_repo_dir)
441
- and _has_valid_config_file(dependency_repo_dir)
442
- ):
443
- _update_dependency(dependency, dependency_repo_dir)
444
- else:
445
- _checkout_dependency(dependency, dependency_repo_dir)
340
+ _fetch_dependency(dependency, dependency_repo_dir)
446
341
 
447
342
  if not verify_local_dependency(dependency):
448
343
  # TODO: what should we do if the local dependency isn't installed correctly?
@@ -503,185 +398,101 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
503
398
  return installed_dependencies
504
399
 
505
400
 
506
- def _update_dependency(
401
+ def _parse_github_repo_path(repo_link: str) -> str:
402
+ url = repo_link.rstrip("/").removesuffix(".git")
403
+ if "github.com/" not in url:
404
+ raise ValueError(f"Not a GitHub URL: {repo_link}")
405
+ return url.split("github.com/", 1)[1]
406
+
407
+
408
+ def _fetch_dependency(
507
409
  dependency: RemoteConfig,
508
410
  dependency_repo_dir: str,
509
411
  ) -> None:
510
412
  sentry_logger.info(
511
- "Updating dependency",
413
+ "Fetching dependency",
512
414
  extra={
513
415
  "repo_name": dependency.repo_name,
514
416
  "repo_link": dependency.repo_link,
515
417
  "branch": dependency.branch,
516
418
  },
517
419
  )
518
- git_config_manager = GitConfigManager(
519
- dependency_repo_dir,
520
- DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
521
- f"{DEVSERVICES_DIR_NAME}/",
522
- )
523
420
  try:
524
- git_config_manager.ensure_config()
525
- except FailedToSetGitConfigError as e:
421
+ repo_path = _parse_github_repo_path(dependency.repo_link)
422
+ except ValueError as e:
526
423
  raise DependencyError(
527
424
  repo_name=dependency.repo_name,
528
425
  repo_link=dependency.repo_link,
529
426
  branch=dependency.branch,
530
427
  ) from e
531
428
 
532
- try:
533
- _run_command_with_retries(
534
- [
535
- "git",
536
- "fetch",
537
- "origin",
538
- dependency.branch,
539
- "--filter=blob:none",
540
- "--no-recurse-submodules", # Avoid fetching submodules
541
- ],
542
- cwd=dependency_repo_dir,
429
+ zip_url = f"https://api.github.com/repos/{repo_path}/zipball/{dependency.branch}"
430
+
431
+ def _download() -> bytes:
432
+ req = urllib.request.Request(
433
+ zip_url,
434
+ headers={"Accept": "application/vnd.github+json"},
543
435
  )
544
- except subprocess.CalledProcessError as e:
545
- # Try to set the git config context to help with debugging
546
- _try_set_git_config_context(git_config_manager)
547
- raise DependencyError(
548
- repo_name=dependency.repo_name,
549
- repo_link=dependency.repo_link,
550
- branch=dependency.branch,
551
- stderr=e.stderr,
552
- ) from e
436
+ with urllib.request.urlopen(req) as response:
437
+ return bytes(response.read())
553
438
 
554
- # Check if the local repo is up-to-date
555
439
  try:
556
- local_commit = _rev_parse(dependency_repo_dir, "HEAD")
557
- except subprocess.CalledProcessError as e:
440
+ zip_data = io.BytesIO(
441
+ retry(
442
+ _download,
443
+ exceptions=(OSError,),
444
+ should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
445
+ )
446
+ )
447
+ except (OSError, ValueError) as e:
558
448
  raise DependencyError(
559
449
  repo_name=dependency.repo_name,
560
450
  repo_link=dependency.repo_link,
561
451
  branch=dependency.branch,
562
- stderr=e.stderr,
563
452
  ) from e
564
453
 
565
454
  try:
566
- remote_commit = _rev_parse(dependency_repo_dir, "FETCH_HEAD")
567
- except subprocess.CalledProcessError as e:
568
- raise DependencyError(
569
- repo_name=dependency.repo_name,
570
- repo_link=dependency.repo_link,
571
- branch=dependency.branch,
572
- stderr=e.stderr,
573
- ) from e
574
-
575
- if local_commit == remote_commit:
576
- # Already up-to-date, don't pull anything
577
- logger = logging.getLogger(LOGGER_NAME)
578
- logger.debug(
579
- "Dependency %s is already up-to-date, not pulling anything",
580
- dependency.repo_name,
581
- )
582
- return
455
+ with zipfile.ZipFile(zip_data) as zf:
456
+ names = zf.namelist()
457
+ if not names:
458
+ raise DependencyError(
459
+ repo_name=dependency.repo_name,
460
+ repo_link=dependency.repo_link,
461
+ branch=dependency.branch,
462
+ )
463
+ # GitHub zips always have a single top-level directory: "owner-repo-sha/"
464
+ prefix = names[0].split("/")[0] + "/"
465
+ devservices_prefix = f"{prefix}{DEVSERVICES_DIR_NAME}/"
466
+ to_extract = [
467
+ name
468
+ for name in names
469
+ if name.startswith(devservices_prefix) and not name.endswith("/")
470
+ ]
471
+ if not to_extract:
472
+ raise DependencyError(
473
+ repo_name=dependency.repo_name,
474
+ repo_link=dependency.repo_link,
475
+ branch=dependency.branch,
476
+ )
583
477
 
584
- # If it's not up-to-date, checkout the latest changes (forcibly)
585
- try:
586
- _run_command(["git", "checkout", "-f", "FETCH_HEAD"], cwd=dependency_repo_dir)
587
- except subprocess.CalledProcessError as e:
478
+ if os.path.exists(dependency_repo_dir):
479
+ shutil.rmtree(dependency_repo_dir)
480
+ os.makedirs(dependency_repo_dir)
481
+
482
+ for name in to_extract:
483
+ relative_path = name[len(prefix) :]
484
+ target = os.path.join(dependency_repo_dir, relative_path)
485
+ os.makedirs(os.path.dirname(target), exist_ok=True)
486
+ with zf.open(name) as src, open(target, "wb") as dst:
487
+ shutil.copyfileobj(src, dst)
488
+ except (zipfile.BadZipFile, OSError) as e:
588
489
  raise DependencyError(
589
490
  repo_name=dependency.repo_name,
590
491
  repo_link=dependency.repo_link,
591
492
  branch=dependency.branch,
592
- stderr=e.stderr,
593
493
  ) from e
594
494
 
595
495
 
596
- def _checkout_dependency(
597
- dependency: RemoteConfig,
598
- dependency_repo_dir: str,
599
- ) -> None:
600
- sentry_logger.info(
601
- "Checking out dependency",
602
- extra={
603
- "repo_name": dependency.repo_name,
604
- "repo_link": dependency.repo_link,
605
- "branch": dependency.branch,
606
- },
607
- )
608
- with tempfile.TemporaryDirectory() as temp_dir:
609
- try:
610
- _run_command(
611
- [
612
- "git",
613
- "clone",
614
- "--filter=blob:none",
615
- "--no-checkout",
616
- dependency.repo_link,
617
- temp_dir,
618
- ],
619
- cwd=temp_dir,
620
- )
621
- except subprocess.CalledProcessError as e:
622
- raise UnableToCloneDependencyError(
623
- repo_name=dependency.repo_name,
624
- repo_link=dependency.repo_link,
625
- branch=dependency.branch,
626
- stderr=e.stderr,
627
- ) from e
628
-
629
- # Setup config for partial clone and sparse checkout
630
- git_config_manager = GitConfigManager(
631
- temp_dir,
632
- DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
633
- f"{DEVSERVICES_DIR_NAME}/",
634
- )
635
- try:
636
- git_config_manager.ensure_config()
637
- except FailedToSetGitConfigError as e:
638
- raise DependencyError(
639
- repo_name=dependency.repo_name,
640
- repo_link=dependency.repo_link,
641
- branch=dependency.branch,
642
- ) from e
643
-
644
- try:
645
- _run_command(
646
- ["git", "checkout", dependency.branch],
647
- cwd=temp_dir,
648
- )
649
- except subprocess.CalledProcessError as e:
650
- raise DependencyError(
651
- repo_name=dependency.repo_name,
652
- repo_link=dependency.repo_link,
653
- branch=dependency.branch,
654
- stderr=e.stderr,
655
- ) from e
656
-
657
- # Clean up the existing directory if it exists
658
- if os.path.exists(dependency_repo_dir):
659
- shutil.rmtree(dependency_repo_dir)
660
- # Copy the cloned repo to the dependency cache directory
661
- try:
662
- shutil.copytree(temp_dir, dst=dependency_repo_dir)
663
- except FileExistsError as e:
664
- raise DependencyError(
665
- repo_name=dependency.repo_name,
666
- repo_link=dependency.repo_link,
667
- branch=dependency.branch,
668
- ) from e
669
-
670
-
671
- def _is_valid_repo(path: str) -> bool:
672
- if not os.path.exists(os.path.join(path, ".git")):
673
- return False
674
- try:
675
- _run_command(["git", "rev-parse", "--is-inside-work-tree"], cwd=path)
676
- return True
677
- except subprocess.CalledProcessError:
678
- return False
679
-
680
-
681
- def _has_valid_config_file(path: str) -> bool:
682
- return os.path.exists(os.path.join(path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME))
683
-
684
-
685
496
  def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
686
497
  return [
687
498
  dependency.remote
@@ -694,64 +505,6 @@ def _has_remote_config(remote_config: RemoteConfig | None) -> TypeGuard[RemoteCo
694
505
  return remote_config is not None
695
506
 
696
507
 
697
- def _rev_parse(repo_dir: str, ref: str) -> str:
698
- logger = logging.getLogger(LOGGER_NAME)
699
- logger.debug("Parsing revision for %s (%s)", ref, repo_dir)
700
- rev = (
701
- subprocess.check_output(
702
- ["git", "rev-parse", ref], cwd=repo_dir, stderr=subprocess.PIPE
703
- )
704
- .strip()
705
- .decode()
706
- )
707
- logger.debug("Parsed revision %s for %s (%s)", rev, ref, repo_dir)
708
- return rev
709
-
710
-
711
- def _run_command(
712
- cmd: list[str], cwd: str, stdout: int | TextIO | None = subprocess.DEVNULL
713
- ) -> None:
714
- logger = logging.getLogger(LOGGER_NAME)
715
- logger.debug("Running command: %s in %s", " ".join(cmd), cwd)
716
- subprocess.run(cmd, cwd=cwd, check=True, stdout=stdout, stderr=subprocess.PIPE)
717
-
718
-
719
- def _run_command_with_retries(
720
- cmd: list[str],
721
- cwd: str,
722
- stdout: int | TextIO | None = subprocess.DEVNULL,
723
- retries: int = 3,
724
- backoff: int = 2,
725
- ) -> None:
726
- for i in range(retries):
727
- try:
728
- _run_command(cmd, cwd=cwd, stdout=stdout)
729
- break
730
- except subprocess.CalledProcessError as e:
731
- logger = logging.getLogger(LOGGER_NAME)
732
- logger.debug(
733
- "Attempt %s of %s for %s failed: %s", i + 1, retries, cmd, e.stderr
734
- )
735
- capture_message(
736
- f"Attempt {i + 1} of {retries} for {cmd} failed: {e.stderr}",
737
- level="warning",
738
- )
739
- if i == retries - 1:
740
- raise e
741
- time.sleep(backoff**i)
742
-
743
-
744
- def _try_set_git_config_context(
745
- git_config_manager: GitConfigManager,
746
- ) -> None:
747
- try:
748
- git_config = git_config_manager.get_relevant_config()
749
- set_context("git_config", git_config)
750
- except subprocess.CalledProcessError as e:
751
- logger = logging.getLogger(LOGGER_NAME)
752
- logger.exception(e)
753
-
754
-
755
508
  def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
756
509
  dependency_repo_dir = os.path.join(
757
510
  DEVSERVICES_DEPENDENCIES_CACHE_DIR,
@@ -3,12 +3,12 @@ from __future__ import annotations
3
3
  import os
4
4
  import shutil
5
5
  import tempfile
6
- import time
7
6
  from urllib.request import urlretrieve
8
7
 
9
8
  from devservices.constants import BINARY_PERMISSIONS
10
9
  from devservices.exceptions import BinaryInstallError
11
10
  from devservices.utils.console import Console
11
+ from devservices.utils.retry import retry
12
12
 
13
13
 
14
14
  def install_binary(
@@ -18,29 +18,24 @@ def install_binary(
18
18
  url: str,
19
19
  ) -> None:
20
20
  console = Console()
21
- with tempfile.TemporaryDirectory() as temp_dir:
21
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
22
22
  temp_file = os.path.join(temp_dir, binary_name)
23
23
 
24
- # Download the binary with retries
25
- max_retries = 3
26
- retry_delay_seconds = 1
27
24
  console.info(f"Downloading {binary_name} {version} from {url}...")
28
- for attempt in range(max_retries):
29
- try:
30
- urlretrieve(url, temp_file)
31
- break
32
- except Exception as e:
33
- if attempt < max_retries - 1:
34
- console.warning(
35
- f"Download failed. Retrying in {retry_delay_seconds} seconds... (Attempt {attempt + 1}/{max_retries - 1})"
36
- )
37
- time.sleep(retry_delay_seconds)
38
- else:
39
- raise BinaryInstallError(
40
- f"Failed to download {binary_name} after {max_retries} attempts: {e}"
41
- ) from e
25
+ try:
26
+ retry(
27
+ lambda: urlretrieve(url, temp_file),
28
+ retries=3,
29
+ delay=1.0,
30
+ on_retry=lambda e, remaining: console.warning(
31
+ f"Download failed. Retrying in 1 seconds... ({remaining} retries left)"
32
+ ),
33
+ )
34
+ except Exception as e:
35
+ raise BinaryInstallError(
36
+ f"Failed to download {binary_name} after 3 attempts: {e}"
37
+ ) from e
42
38
 
43
- # Make the binary executable
44
39
  try:
45
40
  os.chmod(temp_file, BINARY_PERMISSIONS)
46
41
  except (PermissionError, FileNotFoundError) as e:
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ def retry(
11
+ fn: Callable[[], T],
12
+ *,
13
+ retries: int = 3,
14
+ delay: float = 1.0,
15
+ exceptions: tuple[type[BaseException], ...] = (Exception,),
16
+ on_retry: Callable[[BaseException, int], None] | None = None,
17
+ should_retry: Callable[[BaseException], bool] | None = None,
18
+ ) -> T:
19
+ """
20
+ Call fn() up to `retries` times, sleeping `delay` seconds between attempts.
21
+ Raises the last exception if all attempts fail.
22
+
23
+ Args:
24
+ fn: Callable to invoke.
25
+ retries: Total number of attempts (must be >= 1).
26
+ delay: Seconds to sleep between attempts.
27
+ exceptions: Exception types that trigger a retry.
28
+ on_retry: Optional callback(exc, attempts_remaining) invoked before each sleep.
29
+ should_retry: Optional predicate; if it returns False the exception is re-raised
30
+ immediately without further attempts.
31
+ """
32
+ if retries < 1:
33
+ raise ValueError("retries must be >= 1")
34
+ for attempt in range(retries):
35
+ try:
36
+ return fn()
37
+ except exceptions as e:
38
+ if attempt == retries - 1 or (
39
+ should_retry is not None and not should_retry(e)
40
+ ):
41
+ raise
42
+ if on_retry is not None:
43
+ on_retry(e, retries - attempt - 1)
44
+ time.sleep(delay)
45
+ raise AssertionError("unreachable")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -35,6 +35,7 @@ devservices/utils/docker_compose.py
35
35
  devservices/utils/file_lock.py
36
36
  devservices/utils/git.py
37
37
  devservices/utils/install_binary.py
38
+ devservices/utils/retry.py
38
39
  devservices/utils/services.py
39
40
  devservices/utils/state.py
40
41
  devservices/utils/supervisor.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devservices"
7
- version = "1.3.1"
7
+ version = "1.4.0"
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"
@@ -22,7 +22,7 @@ dev = [
22
22
  "freezegun",
23
23
  "mypy",
24
24
  "pre-commit",
25
- "pytest>9",
25
+ "pytest>=9.0.3",
26
26
  "pytest-cov",
27
27
  "ruff",
28
28
  "setuptools>=70",