devservices 1.4.0__tar.gz → 1.4.2__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 (72) hide show
  1. {devservices-1.4.0 → devservices-1.4.2}/PKG-INFO +1 -1
  2. {devservices-1.4.0 → devservices-1.4.2}/README.md +1 -1
  3. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/dependencies.py +4 -13
  4. devservices-1.4.2/devservices/utils/github.py +95 -0
  5. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/PKG-INFO +1 -1
  6. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/SOURCES.txt +2 -0
  7. {devservices-1.4.0 → devservices-1.4.2}/pyproject.toml +6 -1
  8. devservices-1.4.2/tests/conftest.py +32 -0
  9. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_dependencies.py +24 -29
  10. devservices-1.4.2/tests/utils/test_github.py +156 -0
  11. devservices-1.4.0/tests/conftest.py +0 -10
  12. {devservices-1.4.0 → devservices-1.4.2}/LICENSE.md +0 -0
  13. {devservices-1.4.0 → devservices-1.4.2}/devservices/__init__.py +0 -0
  14. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/__init__.py +0 -0
  15. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/down.py +0 -0
  16. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/foreground.py +0 -0
  17. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/list_dependencies.py +0 -0
  18. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/list_services.py +0 -0
  19. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/logs.py +0 -0
  20. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/purge.py +0 -0
  21. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/reset.py +0 -0
  22. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/serve.py +0 -0
  23. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/status.py +0 -0
  24. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/toggle.py +0 -0
  25. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/up.py +0 -0
  26. {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/update.py +0 -0
  27. {devservices-1.4.0 → devservices-1.4.2}/devservices/configs/service_config.py +0 -0
  28. {devservices-1.4.0 → devservices-1.4.2}/devservices/constants.py +0 -0
  29. {devservices-1.4.0 → devservices-1.4.2}/devservices/exceptions.py +0 -0
  30. {devservices-1.4.0 → devservices-1.4.2}/devservices/main.py +0 -0
  31. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/__init__.py +0 -0
  32. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/check_for_update.py +0 -0
  33. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/console.py +0 -0
  34. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/devenv.py +0 -0
  35. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/docker.py +0 -0
  36. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/docker_compose.py +0 -0
  37. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/file_lock.py +0 -0
  38. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/git.py +0 -0
  39. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/install_binary.py +0 -0
  40. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/retry.py +0 -0
  41. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/services.py +0 -0
  42. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/state.py +0 -0
  43. {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/supervisor.py +0 -0
  44. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/dependency_links.txt +0 -0
  45. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/entry_points.txt +0 -0
  46. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/requires.txt +0 -0
  47. {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/top_level.txt +0 -0
  48. {devservices-1.4.0 → devservices-1.4.2}/setup.cfg +0 -0
  49. {devservices-1.4.0 → devservices-1.4.2}/testing/__init__.py +0 -0
  50. {devservices-1.4.0 → devservices-1.4.2}/testing/utils.py +0 -0
  51. {devservices-1.4.0 → devservices-1.4.2}/tests/__init__.py +0 -0
  52. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_down.py +0 -0
  53. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_foreground.py +0 -0
  54. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_list_dependencies.py +0 -0
  55. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_list_services.py +0 -0
  56. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_logs.py +0 -0
  57. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_purge.py +0 -0
  58. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_reset.py +0 -0
  59. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_serve.py +0 -0
  60. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_status.py +0 -0
  61. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_toggle.py +0 -0
  62. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_up.py +0 -0
  63. {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_update.py +0 -0
  64. {devservices-1.4.0 → devservices-1.4.2}/tests/configs/test_service_config.py +0 -0
  65. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_check_for_update.py +0 -0
  66. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_docker.py +0 -0
  67. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_docker_compose.py +0 -0
  68. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_git.py +0 -0
  69. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_install_binary.py +0 -0
  70. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_services.py +0 -0
  71. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_state.py +0 -0
  72. {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_supervisor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.4.0
3
+ Version: 1.4.2
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.4.0
36
+ devservices==1.4.2
37
37
  ```
38
38
 
39
39
  ### 2. Add devservices config files
@@ -30,6 +30,7 @@ from devservices.exceptions import DependencyError
30
30
  from devservices.exceptions import DependencyNotInstalledError
31
31
  from devservices.exceptions import InvalidDependencyConfigError
32
32
  from devservices.exceptions import ModeDoesNotExistError
33
+ from devservices.utils import github
33
34
  from devservices.utils.file_lock import lock
34
35
  from devservices.utils.retry import retry
35
36
  from devservices.utils.services import Service
@@ -398,13 +399,6 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
398
399
  return installed_dependencies
399
400
 
400
401
 
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
402
  def _fetch_dependency(
409
403
  dependency: RemoteConfig,
410
404
  dependency_repo_dir: str,
@@ -418,7 +412,7 @@ def _fetch_dependency(
418
412
  },
419
413
  )
420
414
  try:
421
- repo_path = _parse_github_repo_path(dependency.repo_link)
415
+ repo_path = github.parse_repo_path(dependency.repo_link)
422
416
  except ValueError as e:
423
417
  raise DependencyError(
424
418
  repo_name=dependency.repo_name,
@@ -426,13 +420,10 @@ def _fetch_dependency(
426
420
  branch=dependency.branch,
427
421
  ) from e
428
422
 
429
- zip_url = f"https://api.github.com/repos/{repo_path}/zipball/{dependency.branch}"
423
+ zip_url = github.zipball_url(repo_path, dependency.branch)
430
424
 
431
425
  def _download() -> bytes:
432
- req = urllib.request.Request(
433
- zip_url,
434
- headers={"Accept": "application/vnd.github+json"},
435
- )
426
+ req = github.build_api_request(zip_url)
436
427
  with urllib.request.urlopen(req) as response:
437
428
  return bytes(response.read())
438
429
 
@@ -0,0 +1,95 @@
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
+
21
+ Handles both HTTPS (https://github.com/owner/repo) and SSH
22
+ (git@github.com:owner/repo) formats.
23
+ """
24
+ url = repo_link.rstrip("/").removesuffix(".git")
25
+ if "github.com/" in url:
26
+ return url.split("github.com/", 1)[1]
27
+ if "github.com:" in url:
28
+ return url.split("github.com:", 1)[1]
29
+ raise ValueError(f"Not a GitHub URL: {repo_link}")
30
+
31
+
32
+ def zipball_url(repo_path: str, ref: str) -> str:
33
+ return f"https://api.github.com/repos/{repo_path}/zipball/{ref}"
34
+
35
+
36
+ def build_api_request(url: str) -> urllib.request.Request:
37
+ """Build an authenticated GitHub API request, authenticating when possible.
38
+
39
+ Authenticated requests get the 5000 req/hr rate limit instead of the 60
40
+ req/hr unauthenticated limit (which CI runners, sharing egress IPs, exhaust
41
+ quickly and then receive HTTP 403s).
42
+ """
43
+ req = urllib.request.Request(url, headers={"Accept": GITHUB_API_ACCEPT})
44
+ token = _get_token()
45
+ if token:
46
+ # Use an unredirected header so the token is only sent to api.github.com
47
+ # and is NOT forwarded when the API 302-redirects the download to
48
+ # codeload.github.com (and S3-backed hosts for other endpoints), which
49
+ # reject or mishandle requests that carry an Authorization header.
50
+ req.add_unredirected_header("Authorization", f"Bearer {token}")
51
+ return req
52
+
53
+
54
+ def _get_token() -> str | None:
55
+ # Prefer an explicit token from the environment (CI sets these), otherwise
56
+ # fall back to the locally authenticated `gh` CLI so local users are authed
57
+ # without extra setup.
58
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
59
+ if token:
60
+ return token
61
+ if shutil.which("gh") is not None:
62
+ try:
63
+ result = subprocess.run(
64
+ ["gh", "auth", "token"],
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=5,
68
+ )
69
+ except (OSError, subprocess.SubprocessError):
70
+ result = None
71
+ if result is not None and result.returncode == 0:
72
+ token = result.stdout.strip()
73
+ if token:
74
+ return token
75
+ _warn_unauthenticated()
76
+ return None
77
+
78
+
79
+ def _warn_unauthenticated() -> None:
80
+ # Fetches run concurrently across dependencies; only warn once per process so
81
+ # we don't spam the same message for every dependency.
82
+ global _auth_warned
83
+ with _auth_warning_lock:
84
+ if _auth_warned:
85
+ return
86
+ _auth_warned = True
87
+ if sys.platform == "darwin":
88
+ install_hint = "Install the GitHub CLI (brew install gh)"
89
+ else:
90
+ install_hint = "Install the GitHub CLI (https://github.com/cli/cli)"
91
+ Console().warning(
92
+ "Could not authenticate with GitHub; falling back to unauthenticated "
93
+ f"downloads, which are heavily rate-limited. {install_hint} and run "
94
+ "`gh auth login` to authenticate."
95
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devservices
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Requires-Python: >=3.11
5
5
  License-File: LICENSE.md
6
6
  Requires-Dist: pyyaml
@@ -34,6 +34,7 @@ devservices/utils/docker.py
34
34
  devservices/utils/docker_compose.py
35
35
  devservices/utils/file_lock.py
36
36
  devservices/utils/git.py
37
+ devservices/utils/github.py
37
38
  devservices/utils/install_binary.py
38
39
  devservices/utils/retry.py
39
40
  devservices/utils/services.py
@@ -61,6 +62,7 @@ tests/utils/test_dependencies.py
61
62
  tests/utils/test_docker.py
62
63
  tests/utils/test_docker_compose.py
63
64
  tests/utils/test_git.py
65
+ tests/utils/test_github.py
64
66
  tests/utils/test_install_binary.py
65
67
  tests/utils/test_services.py
66
68
  tests/utils/test_state.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devservices"
7
- version = "1.4.0"
7
+ version = "1.4.2"
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"
@@ -38,6 +38,11 @@ devservices = "devservices.main:main"
38
38
  [tool.setuptools.packages]
39
39
  find = {}
40
40
 
41
+ [tool.pytest.ini_options]
42
+ markers = [
43
+ "integration: tests that hit real external services (run in CI only)",
44
+ ]
45
+
41
46
  [[tool.uv.index]]
42
47
  url = "https://pypi.devinfra.sentry.io/simple"
43
48
 
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from devservices.utils.state import State
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def clear_singleton_instance() -> None:
10
+ State._instance = None
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def deterministic_github_auth(
15
+ request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
16
+ ) -> None:
17
+ """Keep GitHub token resolution deterministic and offline.
18
+
19
+ Without this, any test that fetches a dependency would consult the ambient
20
+ GITHUB_TOKEN/GH_TOKEN env vars and shell out to the real `gh` CLI, which is
21
+ slow and varies between local and CI environments. Tests that exercise the
22
+ auth logic itself override these patches.
23
+
24
+ Integration tests opt out so they can authenticate against real GitHub.
25
+ """
26
+ if request.node.get_closest_marker("integration"):
27
+ return
28
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
29
+ monkeypatch.delenv("GH_TOKEN", raising=False)
30
+ monkeypatch.setattr("devservices.utils.github.shutil.which", lambda _: None)
31
+ # Treat the warning as already emitted so unauthenticated fetches stay quiet.
32
+ monkeypatch.setattr("devservices.utils.github._auth_warned", True)
@@ -24,7 +24,6 @@ from devservices.exceptions import ServiceNotFoundError
24
24
  from devservices.utils.dependencies import DependencyNode
25
25
  from devservices.utils.dependencies import InstalledRemoteDependency
26
26
  from devservices.utils.dependencies import _fetch_dependency
27
- from devservices.utils.dependencies import _parse_github_repo_path
28
27
  from devservices.utils.dependencies import construct_dependency_graph
29
28
  from devservices.utils.dependencies import get_installed_remote_dependencies
30
29
  from devservices.utils.dependencies import get_non_shared_remote_dependencies
@@ -53,31 +52,6 @@ BASIC_SERVICE_CONFIG = {
53
52
  INVALID_SERVICE_CONFIG_YAML = "not_a_service_config: true\n"
54
53
 
55
54
 
56
- def test_parse_github_repo_path_valid() -> None:
57
- assert (
58
- _parse_github_repo_path("https://github.com/getsentry/test-repo")
59
- == "getsentry/test-repo"
60
- )
61
- assert (
62
- _parse_github_repo_path("https://github.com/getsentry/test-repo.git")
63
- == "getsentry/test-repo"
64
- )
65
- assert (
66
- _parse_github_repo_path("https://github.com/getsentry/test-repo/")
67
- == "getsentry/test-repo"
68
- )
69
- assert _parse_github_repo_path("http://github.com/org/repo") == "org/repo"
70
-
71
-
72
- def test_parse_github_repo_path_non_github() -> None:
73
- with pytest.raises(ValueError):
74
- _parse_github_repo_path("file:///path/to/repo")
75
- with pytest.raises(ValueError):
76
- _parse_github_repo_path("invalid-link")
77
- with pytest.raises(ValueError):
78
- _parse_github_repo_path("https://gitlab.com/org/repo")
79
-
80
-
81
55
  def test_fetch_dependency_success(tmp_path: Path) -> None:
82
56
  dep = RemoteConfig(
83
57
  repo_name="test-repo",
@@ -94,6 +68,27 @@ def test_fetch_dependency_success(tmp_path: Path) -> None:
94
68
  assert (Path(dest) / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME).exists()
95
69
 
96
70
 
71
+ def test_fetch_dependency_uses_authenticated_request(
72
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
73
+ ) -> None:
74
+ monkeypatch.setenv("GITHUB_TOKEN", "tok")
75
+ dep = RemoteConfig(
76
+ repo_name="test-repo",
77
+ branch="main",
78
+ repo_link="https://github.com/getsentry/test-repo",
79
+ )
80
+ zip_bytes = _make_zip_bytes(BASIC_SERVICE_CONFIG)
81
+ with mock.patch(
82
+ "devservices.utils.dependencies.urllib.request.urlopen",
83
+ return_value=_make_urlopen_response(zip_bytes),
84
+ ) as urlopen_mock:
85
+ _fetch_dependency(dep, str(tmp_path / "dest"))
86
+ req = urlopen_mock.call_args.args[0]
87
+ # Sent as an unredirected header so it isn't forwarded to the redirect host.
88
+ assert req.unredirected_hdrs.get("Authorization") == "Bearer tok"
89
+ assert "Authorization" not in req.headers
90
+
91
+
97
92
  def test_fetch_dependency_network_error(tmp_path: Path) -> None:
98
93
  dep = RemoteConfig(
99
94
  repo_name="test-repo",
@@ -124,14 +119,14 @@ def test_fetch_dependency_http_error_not_retried(tmp_path: Path) -> None:
124
119
  hdrs=None, # type: ignore[arg-type]
125
120
  fp=None,
126
121
  )
127
- sleep_mock = mock.patch("devservices.utils.retry.time.sleep")
128
122
  urlopen_mock = mock.patch(
129
123
  "devservices.utils.dependencies.urllib.request.urlopen",
130
124
  side_effect=http_error,
131
125
  )
132
- with sleep_mock as mock_sleep, urlopen_mock, pytest.raises(DependencyError):
126
+ with urlopen_mock as mock_urlopen, pytest.raises(DependencyError):
133
127
  _fetch_dependency(dep, str(tmp_path / "dest"))
134
- mock_sleep.assert_not_called()
128
+ # HTTP errors must not be retried, so urlopen is only ever called once.
129
+ mock_urlopen.assert_called_once()
135
130
 
136
131
 
137
132
  def test_fetch_dependency_read_error(tmp_path: Path) -> None:
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ import subprocess
6
+ import urllib.request
7
+ import zipfile
8
+ from unittest import mock
9
+
10
+ import pytest
11
+
12
+ from devservices.utils import github
13
+
14
+
15
+ def test_parse_repo_path_valid() -> None:
16
+ assert (
17
+ github.parse_repo_path("https://github.com/getsentry/test-repo")
18
+ == "getsentry/test-repo"
19
+ )
20
+ assert (
21
+ github.parse_repo_path("https://github.com/getsentry/test-repo.git")
22
+ == "getsentry/test-repo"
23
+ )
24
+ assert (
25
+ github.parse_repo_path("https://github.com/getsentry/test-repo/")
26
+ == "getsentry/test-repo"
27
+ )
28
+ assert github.parse_repo_path("http://github.com/org/repo") == "org/repo"
29
+
30
+
31
+ def test_parse_repo_path_ssh() -> None:
32
+ assert (
33
+ github.parse_repo_path("git@github.com:getsentry/test-repo")
34
+ == "getsentry/test-repo"
35
+ )
36
+ assert (
37
+ github.parse_repo_path("git@github.com:getsentry/test-repo.git")
38
+ == "getsentry/test-repo"
39
+ )
40
+
41
+
42
+ def test_parse_repo_path_non_github() -> None:
43
+ with pytest.raises(ValueError):
44
+ github.parse_repo_path("file:///path/to/repo")
45
+ with pytest.raises(ValueError):
46
+ github.parse_repo_path("invalid-link")
47
+ with pytest.raises(ValueError):
48
+ github.parse_repo_path("https://gitlab.com/org/repo")
49
+
50
+
51
+ def test_zipball_url() -> None:
52
+ assert (
53
+ github.zipball_url("getsentry/test-repo", "main")
54
+ == "https://api.github.com/repos/getsentry/test-repo/zipball/main"
55
+ )
56
+
57
+
58
+ def test_build_api_request_with_env_token(monkeypatch: pytest.MonkeyPatch) -> None:
59
+ monkeypatch.setenv("GITHUB_TOKEN", "secret-token")
60
+ monkeypatch.delenv("GH_TOKEN", raising=False)
61
+ req = github.build_api_request("https://api.github.com/x")
62
+ assert req.get_header("Accept") == github.GITHUB_API_ACCEPT
63
+ # Token must be an unredirected header so it isn't forwarded on redirect.
64
+ assert req.unredirected_hdrs.get("Authorization") == "Bearer secret-token"
65
+ assert "Authorization" not in req.headers
66
+
67
+
68
+ def test_build_api_request_falls_back_to_gh_token(
69
+ monkeypatch: pytest.MonkeyPatch,
70
+ ) -> None:
71
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
72
+ monkeypatch.setenv("GH_TOKEN", "gh-env-token")
73
+ req = github.build_api_request("https://api.github.com/x")
74
+ assert req.unredirected_hdrs.get("Authorization") == "Bearer gh-env-token"
75
+
76
+
77
+ def test_build_api_request_with_gh_cli(monkeypatch: pytest.MonkeyPatch) -> None:
78
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
79
+ monkeypatch.delenv("GH_TOKEN", raising=False)
80
+ with (
81
+ mock.patch("devservices.utils.github.shutil.which", return_value="/usr/bin/gh"),
82
+ mock.patch(
83
+ "devservices.utils.github.subprocess.run",
84
+ return_value=subprocess.CompletedProcess(
85
+ args=[], returncode=0, stdout="gh-token\n", stderr=""
86
+ ),
87
+ ),
88
+ ):
89
+ req = github.build_api_request("https://api.github.com/x")
90
+ assert req.unredirected_hdrs.get("Authorization") == "Bearer gh-token"
91
+
92
+
93
+ def test_build_api_request_unauthenticated_warns(
94
+ monkeypatch: pytest.MonkeyPatch,
95
+ ) -> None:
96
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
97
+ monkeypatch.delenv("GH_TOKEN", raising=False)
98
+ monkeypatch.setattr("devservices.utils.github._auth_warned", False)
99
+ with (
100
+ mock.patch("devservices.utils.github.shutil.which", return_value=None),
101
+ mock.patch("devservices.utils.github.Console.warning") as warning_mock,
102
+ ):
103
+ req = github.build_api_request("https://api.github.com/x")
104
+ assert req.get_header("Authorization") is None
105
+ warning_mock.assert_called_once()
106
+
107
+
108
+ def test_build_api_request_gh_cli_not_authenticated(
109
+ monkeypatch: pytest.MonkeyPatch,
110
+ ) -> None:
111
+ monkeypatch.delenv("GITHUB_TOKEN", raising=False)
112
+ monkeypatch.delenv("GH_TOKEN", raising=False)
113
+ monkeypatch.setattr("devservices.utils.github._auth_warned", False)
114
+ with (
115
+ mock.patch("devservices.utils.github.shutil.which", return_value="/usr/bin/gh"),
116
+ mock.patch(
117
+ "devservices.utils.github.subprocess.run",
118
+ return_value=subprocess.CompletedProcess(
119
+ args=[], returncode=1, stdout="", stderr="not logged in"
120
+ ),
121
+ ),
122
+ mock.patch("devservices.utils.github.Console.warning") as warning_mock,
123
+ ):
124
+ req = github.build_api_request("https://api.github.com/x")
125
+ assert req.get_header("Authorization") is None
126
+ warning_mock.assert_called_once()
127
+
128
+
129
+ def test_warn_unauthenticated_only_warns_once(
130
+ monkeypatch: pytest.MonkeyPatch,
131
+ ) -> None:
132
+ monkeypatch.setattr("devservices.utils.github._auth_warned", False)
133
+ with mock.patch("devservices.utils.github.Console.warning") as warning_mock:
134
+ github._warn_unauthenticated()
135
+ github._warn_unauthenticated()
136
+ warning_mock.assert_called_once()
137
+
138
+
139
+ @pytest.mark.integration
140
+ @pytest.mark.skipif(
141
+ not os.environ.get("CI"), reason="hits real GitHub; runs in CI only"
142
+ )
143
+ def test_build_api_request_downloads_real_zip() -> None:
144
+ # End-to-end check that a real zipball download works: the request follows
145
+ # the api.github.com -> codeload.github.com 302 redirect, and the (possibly
146
+ # authenticated) Authorization header isn't forwarded in a way that breaks
147
+ # the download. chartcuterie is a small public repo (~280KB zip).
148
+ url = github.zipball_url("getsentry/chartcuterie", "master")
149
+ req = github.build_api_request(url)
150
+ with urllib.request.urlopen(req) as response:
151
+ data = response.read()
152
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
153
+ # A valid, non-empty zip with the usual single top-level directory.
154
+ names = zf.namelist()
155
+ assert names
156
+ assert names[0].split("/")[0].startswith("getsentry-chartcuterie-")
@@ -1,10 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import pytest
4
-
5
- from devservices.utils.state import State
6
-
7
-
8
- @pytest.fixture(autouse=True)
9
- def clear_singleton_instance() -> None:
10
- State._instance = None
File without changes
File without changes