devservices 1.3.2__tar.gz → 1.4.1__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 (73) hide show
  1. {devservices-1.3.2 → devservices-1.4.1}/PKG-INFO +1 -1
  2. {devservices-1.3.2 → devservices-1.4.1}/README.md +1 -1
  3. {devservices-1.3.2 → devservices-1.4.1}/devservices/constants.py +0 -5
  4. {devservices-1.3.2 → devservices-1.4.1}/devservices/exceptions.py +0 -19
  5. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/dependencies.py +58 -314
  6. devservices-1.4.1/devservices/utils/github.py +89 -0
  7. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/install_binary.py +14 -19
  8. devservices-1.4.1/devservices/utils/retry.py +45 -0
  9. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/PKG-INFO +1 -1
  10. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/SOURCES.txt +3 -0
  11. {devservices-1.3.2 → devservices-1.4.1}/pyproject.toml +7 -2
  12. devservices-1.4.1/testing/utils.py +82 -0
  13. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_down.py +153 -166
  14. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_serve.py +5 -1
  15. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_status.py +9 -7
  16. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_toggle.py +87 -93
  17. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_up.py +70 -75
  18. devservices-1.4.1/tests/conftest.py +32 -0
  19. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_dependencies.py +633 -1765
  20. devservices-1.4.1/tests/utils/test_github.py +145 -0
  21. devservices-1.3.2/testing/utils.py +0 -41
  22. devservices-1.3.2/tests/conftest.py +0 -10
  23. {devservices-1.3.2 → devservices-1.4.1}/LICENSE.md +0 -0
  24. {devservices-1.3.2 → devservices-1.4.1}/devservices/__init__.py +0 -0
  25. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/__init__.py +0 -0
  26. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/down.py +0 -0
  27. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/foreground.py +0 -0
  28. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/list_dependencies.py +0 -0
  29. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/list_services.py +0 -0
  30. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/logs.py +0 -0
  31. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/purge.py +0 -0
  32. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/reset.py +0 -0
  33. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/serve.py +0 -0
  34. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/status.py +0 -0
  35. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/toggle.py +0 -0
  36. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/up.py +0 -0
  37. {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/update.py +0 -0
  38. {devservices-1.3.2 → devservices-1.4.1}/devservices/configs/service_config.py +0 -0
  39. {devservices-1.3.2 → devservices-1.4.1}/devservices/main.py +0 -0
  40. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/__init__.py +0 -0
  41. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/check_for_update.py +0 -0
  42. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/console.py +0 -0
  43. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/devenv.py +0 -0
  44. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/docker.py +0 -0
  45. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/docker_compose.py +0 -0
  46. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/file_lock.py +0 -0
  47. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/git.py +0 -0
  48. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/services.py +0 -0
  49. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/state.py +0 -0
  50. {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/supervisor.py +0 -0
  51. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/dependency_links.txt +0 -0
  52. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/entry_points.txt +0 -0
  53. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/requires.txt +0 -0
  54. {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/top_level.txt +0 -0
  55. {devservices-1.3.2 → devservices-1.4.1}/setup.cfg +0 -0
  56. {devservices-1.3.2 → devservices-1.4.1}/testing/__init__.py +0 -0
  57. {devservices-1.3.2 → devservices-1.4.1}/tests/__init__.py +0 -0
  58. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_foreground.py +0 -0
  59. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_list_dependencies.py +0 -0
  60. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_list_services.py +0 -0
  61. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_logs.py +0 -0
  62. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_purge.py +0 -0
  63. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_reset.py +0 -0
  64. {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_update.py +0 -0
  65. {devservices-1.3.2 → devservices-1.4.1}/tests/configs/test_service_config.py +0 -0
  66. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_check_for_update.py +0 -0
  67. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_docker.py +0 -0
  68. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_docker_compose.py +0 -0
  69. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_git.py +0 -0
  70. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_install_binary.py +0 -0
  71. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_services.py +0 -0
  72. {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_state.py +0 -0
  73. {devservices-1.3.2 → devservices-1.4.1}/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.2
3
+ Version: 1.4.1
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.2
36
+ devservices==1.4.1
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,19 @@ 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
33
+ from devservices.utils import github
40
34
  from devservices.utils.file_lock import lock
35
+ from devservices.utils.retry import retry
41
36
  from devservices.utils.services import Service
42
37
  from devservices.utils.services import find_matching_service
43
38
  from devservices.utils.services import get_active_service_names
@@ -45,17 +40,6 @@ from devservices.utils.state import ServiceRuntime
45
40
  from devservices.utils.state import State
46
41
  from devservices.utils.state import StateTables
47
42
 
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
43
 
60
44
  @dataclass(frozen=True, eq=True)
61
45
  class DependencyNode:
@@ -129,87 +113,6 @@ class InstalledRemoteDependency:
129
113
  mode: str = "default"
130
114
 
131
115
 
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
116
  def install_and_verify_dependencies(
214
117
  service: Service,
215
118
  force_update_dependencies: bool = False,
@@ -435,14 +338,7 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
435
338
  DEVSERVICES_DEPENDENCIES_CACHE_DIR, f"{dependency.repo_name}.lock"
436
339
  )
437
340
  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)
341
+ _fetch_dependency(dependency, dependency_repo_dir)
446
342
 
447
343
  if not verify_local_dependency(dependency):
448
344
  # TODO: what should we do if the local dependency isn't installed correctly?
@@ -503,185 +399,91 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
503
399
  return installed_dependencies
504
400
 
505
401
 
506
- def _update_dependency(
402
+ def _fetch_dependency(
507
403
  dependency: RemoteConfig,
508
404
  dependency_repo_dir: str,
509
405
  ) -> None:
510
406
  sentry_logger.info(
511
- "Updating dependency",
407
+ "Fetching dependency",
512
408
  extra={
513
409
  "repo_name": dependency.repo_name,
514
410
  "repo_link": dependency.repo_link,
515
411
  "branch": dependency.branch,
516
412
  },
517
413
  )
518
- git_config_manager = GitConfigManager(
519
- dependency_repo_dir,
520
- DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
521
- f"{DEVSERVICES_DIR_NAME}/",
522
- )
523
414
  try:
524
- git_config_manager.ensure_config()
525
- except FailedToSetGitConfigError as e:
415
+ repo_path = github.parse_repo_path(dependency.repo_link)
416
+ except ValueError as e:
526
417
  raise DependencyError(
527
418
  repo_name=dependency.repo_name,
528
419
  repo_link=dependency.repo_link,
529
420
  branch=dependency.branch,
530
421
  ) from e
531
422
 
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,
543
- )
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
423
+ zip_url = github.zipball_url(repo_path, dependency.branch)
553
424
 
554
- # Check if the local repo is up-to-date
555
- try:
556
- local_commit = _rev_parse(dependency_repo_dir, "HEAD")
557
- except subprocess.CalledProcessError as e:
558
- raise DependencyError(
559
- repo_name=dependency.repo_name,
560
- repo_link=dependency.repo_link,
561
- branch=dependency.branch,
562
- stderr=e.stderr,
563
- ) from e
425
+ def _download() -> bytes:
426
+ req = github.build_api_request(zip_url)
427
+ with urllib.request.urlopen(req) as response:
428
+ return bytes(response.read())
564
429
 
565
430
  try:
566
- remote_commit = _rev_parse(dependency_repo_dir, "FETCH_HEAD")
567
- except subprocess.CalledProcessError as e:
431
+ zip_data = io.BytesIO(
432
+ retry(
433
+ _download,
434
+ exceptions=(OSError,),
435
+ should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
436
+ )
437
+ )
438
+ except (OSError, ValueError) as e:
568
439
  raise DependencyError(
569
440
  repo_name=dependency.repo_name,
570
441
  repo_link=dependency.repo_link,
571
442
  branch=dependency.branch,
572
- stderr=e.stderr,
573
443
  ) from e
574
444
 
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
583
-
584
- # If it's not up-to-date, checkout the latest changes (forcibly)
585
445
  try:
586
- _run_command(["git", "checkout", "-f", "FETCH_HEAD"], cwd=dependency_repo_dir)
587
- except subprocess.CalledProcessError as e:
446
+ with zipfile.ZipFile(zip_data) as zf:
447
+ names = zf.namelist()
448
+ if not names:
449
+ raise DependencyError(
450
+ repo_name=dependency.repo_name,
451
+ repo_link=dependency.repo_link,
452
+ branch=dependency.branch,
453
+ )
454
+ # GitHub zips always have a single top-level directory: "owner-repo-sha/"
455
+ prefix = names[0].split("/")[0] + "/"
456
+ devservices_prefix = f"{prefix}{DEVSERVICES_DIR_NAME}/"
457
+ to_extract = [
458
+ name
459
+ for name in names
460
+ if name.startswith(devservices_prefix) and not name.endswith("/")
461
+ ]
462
+ if not to_extract:
463
+ raise DependencyError(
464
+ repo_name=dependency.repo_name,
465
+ repo_link=dependency.repo_link,
466
+ branch=dependency.branch,
467
+ )
468
+
469
+ if os.path.exists(dependency_repo_dir):
470
+ shutil.rmtree(dependency_repo_dir)
471
+ os.makedirs(dependency_repo_dir)
472
+
473
+ for name in to_extract:
474
+ relative_path = name[len(prefix) :]
475
+ target = os.path.join(dependency_repo_dir, relative_path)
476
+ os.makedirs(os.path.dirname(target), exist_ok=True)
477
+ with zf.open(name) as src, open(target, "wb") as dst:
478
+ shutil.copyfileobj(src, dst)
479
+ except (zipfile.BadZipFile, OSError) as e:
588
480
  raise DependencyError(
589
481
  repo_name=dependency.repo_name,
590
482
  repo_link=dependency.repo_link,
591
483
  branch=dependency.branch,
592
- stderr=e.stderr,
593
484
  ) from e
594
485
 
595
486
 
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(ignore_cleanup_errors=True) 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
487
  def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
686
488
  return [
687
489
  dependency.remote
@@ -694,64 +496,6 @@ def _has_remote_config(remote_config: RemoteConfig | None) -> TypeGuard[RemoteCo
694
496
  return remote_config is not None
695
497
 
696
498
 
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
499
  def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
756
500
  dependency_repo_dir = os.path.join(
757
501
  DEVSERVICES_DEPENDENCIES_CACHE_DIR,
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import threading
8
+ import urllib.request
9
+
10
+ from devservices.utils.console import Console
11
+
12
+ GITHUB_API_ACCEPT = "application/vnd.github+json"
13
+
14
+ _auth_warning_lock = threading.Lock()
15
+ _auth_warned = False
16
+
17
+
18
+ def parse_repo_path(repo_link: str) -> str:
19
+ """Extract the "owner/repo" path from a GitHub URL."""
20
+ url = repo_link.rstrip("/").removesuffix(".git")
21
+ if "github.com/" not in url:
22
+ raise ValueError(f"Not a GitHub URL: {repo_link}")
23
+ return url.split("github.com/", 1)[1]
24
+
25
+
26
+ def zipball_url(repo_path: str, ref: str) -> str:
27
+ return f"https://api.github.com/repos/{repo_path}/zipball/{ref}"
28
+
29
+
30
+ def build_api_request(url: str) -> urllib.request.Request:
31
+ """Build an authenticated GitHub API request, authenticating when possible.
32
+
33
+ Authenticated requests get the 5000 req/hr rate limit instead of the 60
34
+ req/hr unauthenticated limit (which CI runners, sharing egress IPs, exhaust
35
+ quickly and then receive HTTP 403s).
36
+ """
37
+ req = urllib.request.Request(url, headers={"Accept": GITHUB_API_ACCEPT})
38
+ token = _get_token()
39
+ if token:
40
+ # Use an unredirected header so the token is only sent to api.github.com
41
+ # and is NOT forwarded when the API 302-redirects the download to
42
+ # codeload.github.com (and S3-backed hosts for other endpoints), which
43
+ # reject or mishandle requests that carry an Authorization header.
44
+ req.add_unredirected_header("Authorization", f"Bearer {token}")
45
+ return req
46
+
47
+
48
+ def _get_token() -> str | None:
49
+ # Prefer an explicit token from the environment (CI sets these), otherwise
50
+ # fall back to the locally authenticated `gh` CLI so local users are authed
51
+ # without extra setup.
52
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
53
+ if token:
54
+ return token
55
+ if shutil.which("gh") is not None:
56
+ try:
57
+ result = subprocess.run(
58
+ ["gh", "auth", "token"],
59
+ capture_output=True,
60
+ text=True,
61
+ timeout=5,
62
+ )
63
+ except (OSError, subprocess.SubprocessError):
64
+ result = None
65
+ if result is not None and result.returncode == 0:
66
+ token = result.stdout.strip()
67
+ if token:
68
+ return token
69
+ _warn_unauthenticated()
70
+ return None
71
+
72
+
73
+ def _warn_unauthenticated() -> None:
74
+ # Fetches run concurrently across dependencies; only warn once per process so
75
+ # we don't spam the same message for every dependency.
76
+ global _auth_warned
77
+ with _auth_warning_lock:
78
+ if _auth_warned:
79
+ return
80
+ _auth_warned = True
81
+ if sys.platform == "darwin":
82
+ install_hint = "Install the GitHub CLI (brew install gh)"
83
+ else:
84
+ install_hint = "Install the GitHub CLI (https://github.com/cli/cli)"
85
+ Console().warning(
86
+ "Could not authenticate with GitHub; falling back to unauthenticated "
87
+ f"downloads, which are heavily rate-limited. {install_hint} and run "
88
+ "`gh auth login` to authenticate."
89
+ )
@@ -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(
@@ -21,26 +21,21 @@ def install_binary(
21
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: