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.
- {devservices-1.4.0 → devservices-1.4.2}/PKG-INFO +1 -1
- {devservices-1.4.0 → devservices-1.4.2}/README.md +1 -1
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/dependencies.py +4 -13
- devservices-1.4.2/devservices/utils/github.py +95 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/PKG-INFO +1 -1
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/SOURCES.txt +2 -0
- {devservices-1.4.0 → devservices-1.4.2}/pyproject.toml +6 -1
- devservices-1.4.2/tests/conftest.py +32 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_dependencies.py +24 -29
- devservices-1.4.2/tests/utils/test_github.py +156 -0
- devservices-1.4.0/tests/conftest.py +0 -10
- {devservices-1.4.0 → devservices-1.4.2}/LICENSE.md +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/__init__.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/__init__.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/down.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/foreground.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/list_dependencies.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/list_services.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/logs.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/purge.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/reset.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/serve.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/status.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/toggle.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/up.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/commands/update.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/configs/service_config.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/constants.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/exceptions.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/main.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/__init__.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/check_for_update.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/console.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/devenv.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/docker.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/docker_compose.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/file_lock.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/git.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/install_binary.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/retry.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/services.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/state.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices/utils/supervisor.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/dependency_links.txt +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/entry_points.txt +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/requires.txt +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/devservices.egg-info/top_level.txt +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/setup.cfg +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/testing/__init__.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/testing/utils.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/__init__.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_down.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_foreground.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_list_dependencies.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_list_services.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_logs.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_purge.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_reset.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_serve.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_status.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_toggle.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_up.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/commands/test_update.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/configs/test_service_config.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_check_for_update.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_docker.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_docker_compose.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_git.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_install_binary.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_services.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_state.py +0 -0
- {devservices-1.4.0 → devservices-1.4.2}/tests/utils/test_supervisor.py +0 -0
|
@@ -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.
|
|
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 =
|
|
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 =
|
|
423
|
+
zip_url = github.zipball_url(repo_path, dependency.branch)
|
|
430
424
|
|
|
431
425
|
def _download() -> bytes:
|
|
432
|
-
req =
|
|
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
|
+
)
|
|
@@ -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.
|
|
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
|
|
126
|
+
with urlopen_mock as mock_urlopen, pytest.raises(DependencyError):
|
|
133
127
|
_fetch_dependency(dep, str(tmp_path / "dest"))
|
|
134
|
-
|
|
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-")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|