github2gerrit 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

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.
@@ -106,7 +106,9 @@ class ApiCallContext:
106
106
 
107
107
  # Global metrics storage - in production this could be replaced with
108
108
  # proper metrics collection system (Prometheus, etc.)
109
- _METRICS: dict[ApiType, ApiMetrics] = {api_type: ApiMetrics() for api_type in ApiType}
109
+ _METRICS: dict[ApiType, ApiMetrics] = {
110
+ api_type: ApiMetrics() for api_type in ApiType
111
+ }
110
112
 
111
113
 
112
114
  def get_api_metrics(api_type: ApiType) -> ApiMetrics:
@@ -159,7 +161,10 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
159
161
  reason = getattr(exc, "reason", None)
160
162
  if isinstance(
161
163
  reason,
162
- socket.timeout | TimeoutError | ConnectionResetError | ConnectionAbortedError,
164
+ socket.timeout
165
+ | TimeoutError
166
+ | ConnectionResetError
167
+ | ConnectionAbortedError,
163
168
  ):
164
169
  return True
165
170
 
@@ -175,11 +180,14 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
175
180
 
176
181
  # Check by class name or isinstance for mock/test exceptions
177
182
  exc_name = exc.__class__.__name__
178
- if exc_name in ("RateLimitExceededException", "RateLimitExceededExceptionType") or isinstance(
179
- exc, RateLimitExceededExceptionType
180
- ):
183
+ if exc_name in (
184
+ "RateLimitExceededException",
185
+ "RateLimitExceededExceptionType",
186
+ ) or isinstance(exc, RateLimitExceededExceptionType):
181
187
  return True
182
- if exc_name in ("GithubException", "GithubExceptionType") or isinstance(exc, GithubExceptionType):
188
+ if exc_name in ("GithubException", "GithubExceptionType") or isinstance(
189
+ exc, GithubExceptionType
190
+ ):
183
191
  status = getattr(exc, "status", None)
184
192
  if isinstance(status, int) and 500 <= status <= 599:
185
193
  return True
@@ -187,12 +195,19 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
187
195
  data = getattr(exc, "data", "")
188
196
  if isinstance(data, str | bytes):
189
197
  try:
190
- text = data.decode("utf-8") if isinstance(data, bytes) else data
198
+ text = (
199
+ data.decode("utf-8")
200
+ if isinstance(data, bytes)
201
+ else data
202
+ )
191
203
  if "rate limit" in text.lower():
192
204
  return True
193
205
  except Exception:
194
206
  # Ignore decode errors when checking for rate limit text
195
- log.debug("Failed to decode GitHub API error data for rate limit check")
207
+ log.debug(
208
+ "Failed to decode GitHub API error data for rate "
209
+ "limit check"
210
+ )
196
211
  return False
197
212
 
198
213
  # Gerrit REST specific errors - check for wrapped HTTP errors
@@ -201,9 +216,13 @@ def _is_transient_error(exc: BaseException, api_type: ApiType) -> bool:
201
216
  if "HTTP 5" in str(exc) or "HTTP 429" in str(exc):
202
217
  return True
203
218
  # Also check for original HTTP errors that caused the GerritRestError
204
- if hasattr(exc, "__cause__") and isinstance(exc.__cause__, urllib.error.HTTPError):
219
+ if hasattr(exc, "__cause__") and isinstance(
220
+ exc.__cause__, urllib.error.HTTPError
221
+ ):
205
222
  status = getattr(exc.__cause__, "code", None)
206
- return (500 <= status <= 599) or (status == 429) if status else False
223
+ return (
224
+ (500 <= status <= 599) or (status == 429) if status else False
225
+ )
207
226
 
208
227
  # SSH/Git command errors - check stderr for common transient messages
209
228
  if api_type == ApiType.SSH:
@@ -321,7 +340,9 @@ def external_api_call(
321
340
  attempt,
322
341
  policy.max_attempts,
323
342
  target,
324
- f"(timeout={policy.timeout}s)" if policy.timeout else "",
343
+ f"(timeout={policy.timeout}s)"
344
+ if policy.timeout
345
+ else "",
325
346
  )
326
347
 
327
348
  # Call the actual function
@@ -343,7 +364,8 @@ def external_api_call(
343
364
  policy.jitter_factor,
344
365
  )
345
366
  log.warning(
346
- "[%s] %s attempt %d/%d failed (%.2fs): %s; retrying in %.2fs",
367
+ "[%s] %s attempt %d/%d failed (%.2fs): %s; "
368
+ "retrying in %.2fs",
347
369
  api_type.value,
348
370
  operation,
349
371
  attempt,
@@ -355,11 +377,14 @@ def external_api_call(
355
377
  time.sleep(delay)
356
378
  continue
357
379
  # Final failure - log and re-raise
358
- reason = "final attempt" if is_final_attempt else "non-retryable"
380
+ reason = (
381
+ "final attempt" if is_final_attempt else "non-retryable"
382
+ )
359
383
  log_exception_conditionally(
360
384
  log,
361
385
  f"[{api_type.value}] {operation} failed ({reason}) "
362
- f"after {attempt} attempt(s) in {duration:.2f}s: {target}",
386
+ f"after {attempt} attempt(s) in {duration:.2f}s: "
387
+ f"{target}",
363
388
  )
364
389
  _update_metrics(api_type, context, success=False, exc=exc)
365
390
  raise
@@ -394,17 +419,26 @@ def external_api_call(
394
419
 
395
420
  def log_api_metrics_summary() -> None:
396
421
  """Log a summary of all API metrics."""
397
- log.info("=== External API Metrics Summary ===")
422
+ log.debug("=== External API Metrics Summary ===")
398
423
  for api_type in ApiType:
399
424
  metrics = _METRICS[api_type]
400
425
  if metrics.total_calls == 0:
401
426
  continue
402
427
 
403
- success_rate = (metrics.successful_calls / metrics.total_calls * 100) if metrics.total_calls > 0 else 0.0
404
- avg_duration = metrics.total_duration / metrics.total_calls if metrics.total_calls > 0 else 0.0
428
+ success_rate = (
429
+ (metrics.successful_calls / metrics.total_calls * 100)
430
+ if metrics.total_calls > 0
431
+ else 0.0
432
+ )
433
+ avg_duration = (
434
+ metrics.total_duration / metrics.total_calls
435
+ if metrics.total_calls > 0
436
+ else 0.0
437
+ )
405
438
 
406
- log.info(
407
- "[%s] Calls: %d, Success: %.1f%%, Avg Duration: %.2fs, Retries: %d, Timeouts: %d, Transient Errors: %d",
439
+ log.debug(
440
+ "[%s] Calls: %d, Success: %.1f%%, Avg Duration: %.2fs, "
441
+ "Retries: %d, Timeouts: %d, Transient Errors: %d",
408
442
  api_type.value,
409
443
  metrics.total_calls,
410
444
  success_rate,
@@ -447,7 +481,9 @@ def curl_download(
447
481
  if policy is None:
448
482
  policy = RetryPolicy(max_attempts=3, timeout=timeout)
449
483
 
450
- @external_api_call(ApiType.HTTP_DOWNLOAD, "curl_download", target=url, policy=policy)
484
+ @external_api_call(
485
+ ApiType.HTTP_DOWNLOAD, "curl_download", target=url, policy=policy
486
+ )
451
487
  def _do_curl() -> tuple[int, str]:
452
488
  cmd = ["curl"]
453
489
 
@@ -473,16 +509,28 @@ def curl_download(
473
509
 
474
510
  # Helper functions for raising errors to comply with TRY301
475
511
  def _raise_curl_failed(returncode: int, error_msg: str) -> None:
476
- raise RuntimeError(_MSG_CURL_FAILED_WITH_RC.format(_MSG_CURL_FAILED, returncode, error_msg))
512
+ raise RuntimeError(
513
+ _MSG_CURL_FAILED_WITH_RC.format(
514
+ _MSG_CURL_FAILED, returncode, error_msg
515
+ )
516
+ )
477
517
 
478
518
  def _raise_no_output() -> None:
479
519
  raise RuntimeError(_MSG_CURL_NO_OUTPUT)
480
520
 
481
521
  def _raise_timeout(timeout_val: float) -> None:
482
- raise TimeoutError(_MSG_CURL_TIMEOUT_WITH_TIME.format(_MSG_CURL_TIMEOUT, timeout_val))
522
+ raise TimeoutError(
523
+ _MSG_CURL_TIMEOUT_WITH_TIME.format(
524
+ _MSG_CURL_TIMEOUT, timeout_val
525
+ )
526
+ )
483
527
 
484
528
  def _raise_download_failed(exc: Exception) -> None:
485
- raise RuntimeError(_MSG_CURL_DOWNLOAD_FAILED_WITH_EXC.format(_MSG_CURL_DOWNLOAD_FAILED, exc)) from exc
529
+ raise RuntimeError(
530
+ _MSG_CURL_DOWNLOAD_FAILED_WITH_EXC.format(
531
+ _MSG_CURL_DOWNLOAD_FAILED, exc
532
+ )
533
+ ) from exc
486
534
 
487
535
  # Initialize variables
488
536
  result = None
@@ -493,7 +541,8 @@ def curl_download(
493
541
  cmd,
494
542
  capture_output=True,
495
543
  text=True,
496
- timeout=timeout + 5, # Give subprocess a bit more time than curl
544
+ timeout=timeout
545
+ + 5, # Give subprocess a bit more time than curl
497
546
  check=False,
498
547
  )
499
548
 
@@ -501,7 +550,9 @@ def curl_download(
501
550
  http_status = result.stdout.strip() if result.stdout else "unknown"
502
551
 
503
552
  if result.returncode != 0:
504
- error_msg = result.stderr.strip() if result.stderr else _MSG_CURL_FAILED
553
+ error_msg = (
554
+ result.stderr.strip() if result.stderr else _MSG_CURL_FAILED
555
+ )
505
556
  _raise_curl_failed(result.returncode, error_msg)
506
557
 
507
558
  # Verify file was created
@@ -0,0 +1,286 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+ """
4
+ Gerrit query utilities for topic-based change discovery.
5
+
6
+ This module provides functions to query Gerrit REST API for changes
7
+ based on topics, with support for pagination and safe parsing.
8
+ """
9
+
10
+ import logging
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from .gerrit_rest import GerritRestClient
15
+
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class GerritChange:
22
+ """Represents a Gerrit change from query results."""
23
+
24
+ change_id: str
25
+ number: str
26
+ subject: str
27
+ status: str
28
+ current_revision: str
29
+ files: list[str]
30
+ commit_message: str
31
+ topic: str | None = None
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: dict[str, Any]) -> "GerritChange":
35
+ """Create GerritChange from Gerrit REST API response."""
36
+ # Extract files from current revision
37
+ files = []
38
+ current_revision = data.get("current_revision", "")
39
+ if current_revision and "revisions" in data:
40
+ revision_data = data["revisions"].get(current_revision, {})
41
+ files = list(revision_data.get("files", {}).keys())
42
+
43
+ # Extract commit message
44
+ commit_message = ""
45
+ if current_revision and "revisions" in data:
46
+ revision_data = data["revisions"].get(current_revision, {})
47
+ commit_message = revision_data.get("commit", {}).get("message", "")
48
+
49
+ return cls(
50
+ change_id=data.get("change_id", ""),
51
+ number=str(data.get("_number", "")),
52
+ subject=data.get("subject", ""),
53
+ status=data.get("status", ""),
54
+ current_revision=current_revision,
55
+ files=files,
56
+ commit_message=commit_message,
57
+ topic=data.get("topic"),
58
+ )
59
+
60
+
61
+ def query_changes_by_topic(
62
+ client: GerritRestClient,
63
+ topic: str,
64
+ *,
65
+ statuses: list[str] | None = None,
66
+ max_results: int = 100,
67
+ ) -> list[GerritChange]:
68
+ """
69
+ Query Gerrit for changes matching the given topic.
70
+
71
+ Args:
72
+ client: Gerrit REST client
73
+ topic: Topic name to search for
74
+ statuses: List of change statuses to include (default: ["NEW"])
75
+ max_results: Maximum number of results to return
76
+
77
+ Returns:
78
+ List of GerritChange objects
79
+ """
80
+ if statuses is None:
81
+ statuses = ["NEW"]
82
+
83
+ # Build query string
84
+ status_query = " OR ".join(f"status:{status}" for status in statuses)
85
+ query = f"topic:{topic} AND ({status_query})"
86
+
87
+ log.debug("Querying Gerrit for changes: %s", query)
88
+
89
+ try:
90
+ changes = _execute_query_with_pagination(
91
+ client, query, max_results=max_results
92
+ )
93
+ log.debug(
94
+ "Found %d changes for topic '%s' with statuses %s",
95
+ len(changes),
96
+ topic,
97
+ statuses,
98
+ )
99
+ except Exception as exc:
100
+ log.warning(
101
+ "Failed to query Gerrit changes for topic '%s': %s", topic, exc
102
+ )
103
+ return []
104
+ else:
105
+ return changes
106
+
107
+
108
+ def _execute_query_with_pagination(
109
+ client: GerritRestClient,
110
+ query: str,
111
+ *,
112
+ max_results: int = 100,
113
+ page_size: int = 25,
114
+ ) -> list[GerritChange]:
115
+ """
116
+ Execute Gerrit query with pagination support.
117
+
118
+ Args:
119
+ client: Gerrit REST client
120
+ query: Gerrit query string
121
+ max_results: Maximum total results to return
122
+ page_size: Results per page
123
+
124
+ Returns:
125
+ List of GerritChange objects
126
+ """
127
+ all_changes: list[GerritChange] = []
128
+ start = 0
129
+
130
+ while len(all_changes) < max_results:
131
+ remaining = max_results - len(all_changes)
132
+ current_limit = min(page_size, remaining)
133
+
134
+ try:
135
+ # Build query URL with parameters
136
+ # Gerrit REST API: /changes/?q=query&n=limit&S=skip&o=options
137
+ query_params = [
138
+ f"q={query}",
139
+ f"n={current_limit}",
140
+ f"S={start}",
141
+ "o=CURRENT_REVISION",
142
+ "o=CURRENT_FILES",
143
+ "o=CURRENT_COMMIT",
144
+ ]
145
+ query_path = f"/changes/?{'&'.join(query_params)}"
146
+
147
+ response = client.get(query_path)
148
+
149
+ if not response:
150
+ break
151
+
152
+ # Gerrit REST API returns a list of change objects
153
+ if not isinstance(response, list):
154
+ log.warning(
155
+ "Unexpected Gerrit query response format: %s",
156
+ type(response),
157
+ )
158
+ break
159
+
160
+ page_changes = []
161
+ for change_data in response:
162
+ try:
163
+ change = GerritChange.from_dict(change_data)
164
+ page_changes.append(change)
165
+ except Exception as exc:
166
+ log.debug("Skipping malformed change data: %s", exc)
167
+ continue
168
+
169
+ all_changes.extend(page_changes)
170
+
171
+ # If we got fewer results than requested, we've reached the end
172
+ if len(page_changes) < current_limit:
173
+ break
174
+
175
+ start += len(page_changes)
176
+
177
+ except Exception as exc:
178
+ log.warning(
179
+ "Failed to fetch Gerrit changes page (start=%d, limit=%d): %s",
180
+ start,
181
+ current_limit,
182
+ exc,
183
+ )
184
+ break
185
+
186
+ return all_changes[:max_results]
187
+
188
+
189
+ def extract_pr_metadata_from_commit_message(
190
+ commit_message: str,
191
+ ) -> dict[str, str]:
192
+ """
193
+ Extract GitHub PR metadata trailers from a commit message.
194
+
195
+ Args:
196
+ commit_message: Full commit message text
197
+
198
+ Returns:
199
+ Dictionary with extracted metadata (GitHub-PR, GitHub-Hash, etc.)
200
+ """
201
+ metadata = {}
202
+
203
+ # Look for trailer-style metadata at the end of the commit message
204
+ lines = commit_message.strip().split("\n")
205
+
206
+ # Find the start of trailers (after the last blank line)
207
+ trailer_start = 0
208
+ for i in range(len(lines) - 1, -1, -1):
209
+ if not lines[i].strip():
210
+ trailer_start = i + 1
211
+ break
212
+
213
+ # Parse trailer lines
214
+ for raw_line in lines[trailer_start:]:
215
+ line = raw_line.strip()
216
+ if ":" in line:
217
+ key, value = line.split(":", 1)
218
+ key = key.strip()
219
+ value = value.strip()
220
+ if key.startswith("GitHub-"):
221
+ metadata[key] = value
222
+
223
+ return metadata
224
+
225
+
226
+ def validate_pr_metadata_match(
227
+ gerrit_changes: list[GerritChange],
228
+ expected_pr_url: str,
229
+ expected_github_hash: str,
230
+ ) -> list[GerritChange]:
231
+ """
232
+ Filter Gerrit changes to only those matching the expected PR metadata.
233
+
234
+ This prevents cross-PR contamination by ensuring changes belong to
235
+ the same GitHub PR based on trailer metadata.
236
+
237
+ Args:
238
+ gerrit_changes: List of changes from Gerrit query
239
+ expected_pr_url: Expected GitHub PR URL
240
+ expected_github_hash: Expected GitHub-Hash trailer value
241
+
242
+ Returns:
243
+ Filtered list of changes matching the PR metadata
244
+ """
245
+ validated_changes = []
246
+
247
+ for change in gerrit_changes:
248
+ metadata = extract_pr_metadata_from_commit_message(
249
+ change.commit_message
250
+ )
251
+
252
+ # Check GitHub-PR URL match
253
+ pr_url = metadata.get("GitHub-PR", "")
254
+ if pr_url and pr_url != expected_pr_url:
255
+ log.debug(
256
+ "Excluding change %s: PR URL mismatch (expected=%s, found=%s)",
257
+ change.change_id,
258
+ expected_pr_url,
259
+ pr_url,
260
+ )
261
+ continue
262
+
263
+ # Check GitHub-Hash match
264
+ github_hash = metadata.get("GitHub-Hash", "")
265
+ if github_hash and github_hash != expected_github_hash:
266
+ log.debug(
267
+ "Excluding change %s: GitHub-Hash mismatch "
268
+ "(expected=%s, found=%s)",
269
+ change.change_id,
270
+ expected_github_hash,
271
+ github_hash,
272
+ )
273
+ continue
274
+
275
+ validated_changes.append(change)
276
+
277
+ if len(validated_changes) != len(gerrit_changes):
278
+ log.info(
279
+ "Filtered Gerrit changes: %d -> %d "
280
+ "(excluded %d due to metadata mismatch)",
281
+ len(gerrit_changes),
282
+ len(validated_changes),
283
+ len(gerrit_changes) - len(validated_changes),
284
+ )
285
+
286
+ return validated_changes
@@ -50,14 +50,20 @@ log = logging.getLogger("github2gerrit.gerrit_rest")
50
50
 
51
51
  # Optional pygerrit2 import
52
52
  try: # pragma: no cover - exercised indirectly by tests that monkeypatch
53
- from pygerrit2 import GerritRestAPI as _PygerritRestApi # type: ignore[import-not-found, unused-ignore]
54
- from pygerrit2 import HTTPBasicAuth as _PygerritHttpAuth # type: ignore[import-not-found, unused-ignore]
53
+ from pygerrit2 import (
54
+ GerritRestAPI as _PygerritRestApi, # type: ignore[import-not-found, unused-ignore]
55
+ )
56
+ from pygerrit2 import (
57
+ HTTPBasicAuth as _PygerritHttpAuth, # type: ignore[import-not-found, unused-ignore]
58
+ )
55
59
  except Exception: # pragma: no cover - absence path
56
60
  _PygerritRestApi = None
57
61
  _PygerritHttpAuth = None
58
62
 
59
63
 
60
- _MSG_PYGERRIT2_REQUIRED_AUTH: Final[str] = "pygerrit2 is required for HTTP authentication"
64
+ _MSG_PYGERRIT2_REQUIRED_AUTH: Final[str] = (
65
+ "pygerrit2 is required for HTTP authentication"
66
+ )
61
67
 
62
68
  _TRANSIENT_ERR_SUBSTRINGS: Final[tuple[str, ...]] = (
63
69
  "timed out",
@@ -129,7 +135,10 @@ class GerritRestClient:
129
135
  if _PygerritHttpAuth is None:
130
136
  raise GerritRestError(_MSG_PYGERRIT2_REQUIRED_AUTH)
131
137
  self._client: Any = _PygerritRestApi(
132
- url=self._base_url, auth=_PygerritHttpAuth(self._auth.user, self._auth.password)
138
+ url=self._base_url,
139
+ auth=_PygerritHttpAuth(
140
+ self._auth.user, self._auth.password
141
+ ),
133
142
  )
134
143
  else:
135
144
  self._client = _PygerritRestApi(url=self._base_url)
@@ -137,7 +146,8 @@ class GerritRestClient:
137
146
  self._client = None
138
147
 
139
148
  log.debug(
140
- "GerritRestClient(base_url=%s, timeout=%.1fs, attempts=%d, auth_user=%s)",
149
+ "GerritRestClient(base_url=%s, timeout=%.1fs, attempts=%d, "
150
+ "auth_user=%s)",
141
151
  self._base_url,
142
152
  self._timeout,
143
153
  self._attempts,
@@ -160,16 +170,22 @@ class GerritRestClient:
160
170
 
161
171
  # Internal helpers
162
172
 
163
- def _request_json_with_retry(self, method: str, path: str, data: Any | None = None) -> Any:
173
+ def _request_json_with_retry(
174
+ self, method: str, path: str, data: Any | None = None
175
+ ) -> Any:
164
176
  """Perform a JSON request with retry using external API framework."""
165
177
 
166
- @external_api_call(ApiType.GERRIT_REST, f"{method.lower()}", policy=self._retry_policy)
178
+ @external_api_call(
179
+ ApiType.GERRIT_REST, f"{method.lower()}", policy=self._retry_policy
180
+ )
167
181
  def _do_request() -> Any:
168
182
  return self._request_json(method, path, data)
169
183
 
170
184
  return _do_request()
171
185
 
172
- def _request_json(self, method: str, path: str, data: Any | None = None) -> Any:
186
+ def _request_json(
187
+ self, method: str, path: str, data: Any | None = None
188
+ ) -> Any:
173
189
  """Perform a JSON request (retry logic handled by decorator)."""
174
190
  if not path:
175
191
  msg_required = "path is required"
@@ -181,10 +197,14 @@ class GerritRestClient:
181
197
 
182
198
  try:
183
199
  if self._client is not None and method == "GET" and data is None:
184
- # pygerrit2 path: only using GET to keep behavior consistent with current usage
200
+ # pygerrit2 path: only using GET to keep behavior consistent
201
+ # with current usage
185
202
  log.debug("Gerrit REST GET via pygerrit2: %s", url)
186
- # pygerrit2.get expects a relative path; keep 'path' argument as-is
187
- return self._client.get("/" + rel if not path.startswith("/") else path)
203
+ # pygerrit2.get expects a relative path; keep 'path' argument
204
+ # as-is
205
+ return self._client.get(
206
+ "/" + rel if not path.startswith("/") else path
207
+ )
188
208
 
189
209
  # urllib path (or non-GET with pygerrit2 absent)
190
210
  headers = {"Accept": "application/json"}
@@ -194,19 +214,29 @@ class GerritRestClient:
194
214
  body_bytes = json.dumps(data).encode("utf-8")
195
215
 
196
216
  if self._auth is not None:
197
- token = base64.b64encode(f"{self._auth.user}:{self._auth.password}".encode()).decode("ascii")
217
+ token = base64.b64encode(
218
+ f"{self._auth.user}:{self._auth.password}".encode()
219
+ ).decode("ascii")
198
220
  headers["Authorization"] = f"Basic {token}"
199
221
  scheme = urllib.parse.urlparse(url).scheme
200
222
  if scheme not in ("http", "https"):
201
223
  msg_scheme = f"Unsupported URL scheme for Gerrit REST: {scheme}"
202
224
  raise GerritRestError(msg_scheme)
203
- req = urllib.request.Request(url, data=body_bytes, method=method, headers=headers)
204
- log.debug("Gerrit REST %s %s (auth_user=%s)", method, url, self._auth.user if self._auth else "")
225
+ req = urllib.request.Request(
226
+ url, data=body_bytes, method=method, headers=headers
227
+ )
228
+ log.debug(
229
+ "Gerrit REST %s %s (auth_user=%s)",
230
+ method,
231
+ url,
232
+ self._auth.user if self._auth else "",
233
+ )
205
234
 
206
235
  with urllib.request.urlopen(req, timeout=self._timeout) as resp:
207
236
  status = getattr(resp, "status", None)
208
237
  content = resp.read()
209
- # Gerrit prepends ")]}'" in JSON responses to prevent JSON hijacking; strip if present
238
+ # Gerrit prepends ")]}'" in JSON responses to prevent JSON
239
+ # hijacking; strip if present
210
240
  text = content.decode("utf-8", errors="replace")
211
241
  text = _strip_xssi_guard(text)
212
242
  return _json_loads(text)
@@ -267,7 +297,8 @@ def build_client_for_host(
267
297
  - Uses auto-discovered or environment-provided base path.
268
298
  - Reads HTTP auth from arguments or environment:
269
299
  GERRIT_HTTP_USER / GERRIT_HTTP_PASSWORD
270
- If user is not provided, falls back to GERRIT_SSH_USER_G2G per project norms.
300
+ If user is not provided, falls back to GERRIT_SSH_USER_G2G per project
301
+ norms.
271
302
 
272
303
  Args:
273
304
  host: Gerrit hostname (no scheme)
@@ -286,9 +317,13 @@ def build_client_for_host(
286
317
  or os.getenv("GERRIT_HTTP_USER", "").strip()
287
318
  or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
288
319
  )
289
- passwd = (http_password or "").strip() or os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
320
+ passwd = (http_password or "").strip() or os.getenv(
321
+ "GERRIT_HTTP_PASSWORD", ""
322
+ ).strip()
290
323
  auth: tuple[str, str] | None = (user, passwd) if user and passwd else None
291
- return GerritRestClient(base_url=base_url, auth=auth, timeout=timeout, max_attempts=max_attempts)
324
+ return GerritRestClient(
325
+ base_url=base_url, auth=auth, timeout=timeout, max_attempts=max_attempts
326
+ )
292
327
 
293
328
 
294
329
  __all__ = [