From 2f01aca12131853c3619f2dcac80c3bd4924084e Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 1 Oct 2026 15:53:11 +0000 Subject: [PATCH 1/7] tests: add system tests for resumable uploads --- .../gapic-generator/tests/system/conftest.py | 196 ++ .../test_resumable_upload_acceptance.py | 601 ++++++ .../system/test_resumable_upload_progress.py | 780 +++++++ .../system/test_resumable_upload_resume.py | 973 +++++++++ .../system/test_resumable_upload_scenarios.py | 1812 +++++++++++++++++ .../system/test_resumable_upload_stall.py | 221 ++ 6 files changed, 4583 insertions(+) create mode 100644 packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py create mode 100644 packages/gapic-generator/tests/system/test_resumable_upload_progress.py create mode 100644 packages/gapic-generator/tests/system/test_resumable_upload_resume.py create mode 100644 packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py create mode 100644 packages/gapic-generator/tests/system/test_resumable_upload_stall.py diff --git a/packages/gapic-generator/tests/system/conftest.py b/packages/gapic-generator/tests/system/conftest.py index 73169dd8a79f..4115b715742a 100644 --- a/packages/gapic-generator/tests/system/conftest.py +++ b/packages/gapic-generator/tests/system/conftest.py @@ -37,7 +37,14 @@ from google.showcase import EchoClient from google.showcase import IdentityClient from google.showcase import MessagingClient +try: + from google.showcase import ResumableUploadServiceClient + + HAS_RESUMABLE_UPLOAD_CLIENT = True +except ImportError: + HAS_RESUMABLE_UPLOAD_CLIENT = False +HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = False if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": from grpc.experimental import aio import asyncio @@ -61,6 +68,16 @@ HAS_ASYNC_REST_IDENTITY_TRANSPORT = True except: HAS_ASYNC_REST_IDENTITY_TRANSPORT = False + try: + from google.showcase import ResumableUploadServiceAsyncClient + from google.showcase_v1beta1.services.resumable_upload_service.transports.rest_asyncio import ( + AsyncResumableUploadServiceRestTransport, + AsyncResumableUploadServiceRestInterceptor, + ) + + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = True + except: + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT = False _GRPC_VERSION = grpc.__version__ @@ -288,6 +305,33 @@ def post_expand_with_metadata(self, request, metadata): return request, metadata +if HAS_RESUMABLE_UPLOAD_CLIENT: + try: + from google.showcase_v1beta1.services.resumable_upload_service.transports import ( + ResumableUploadServiceRestInterceptor, + ) + + class ResumableUploadMetadataClientRestInterceptor( + ResumableUploadServiceRestInterceptor + ): + request_metadata: Sequence[Tuple[str, str]] = [] + response_metadata: Sequence[Tuple[str, str]] = [] + + def pre_upload_media(self, request, metadata): + self.request_metadata = metadata + return request, metadata + + def post_upload_media_with_metadata(self, request, metadata): + self.response_metadata = metadata + return request, metadata + + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = True + except ImportError: + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = False +else: + HAS_RESUMABLE_UPLOAD_INTERCEPTOR = False + + if HAS_ASYNC_REST_ECHO_TRANSPORT: class EchoMetadataClientRestAsyncInterceptor(AsyncEchoRestInterceptor): @@ -311,6 +355,19 @@ async def post_expand_with_metadata(self, request, metadata): return request, metadata +if HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + + class ResumableUploadMetadataClientRestAsyncInterceptor( + AsyncResumableUploadServiceRestInterceptor + ): + request_metadata: Sequence[Tuple[str, str]] = [] + response_metadata: Sequence[Tuple[str, str]] = [] + + async def pre_upload_media(self, request, metadata): + self.request_metadata = metadata + return request, metadata + + class EchoMetadataClientGrpcInterceptor( grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor, @@ -516,3 +573,142 @@ def intercepted_echo_rest_async(): ) return EchoAsyncClient(transport=transport), interceptor + + +@pytest.fixture +def intercepted_resumable_upload_rest(use_mtls, use_tls): + if not HAS_RESUMABLE_UPLOAD_CLIENT or not HAS_RESUMABLE_UPLOAD_INTERCEPTOR: + pytest.skip("ResumableUploadServiceClient not available.") + + transport_name = "rest" + transport_cls = ResumableUploadServiceClient.get_transport_class(transport_name) + interceptor = ResumableUploadMetadataClientRestInterceptor() + + url_scheme = "https" if (use_mtls or use_tls) else "http" + transport = transport_cls( + credentials=ga_credentials.AnonymousCredentials(), + host="localhost:7469", + url_scheme=url_scheme, + interceptor=interceptor, + ) + if use_mtls or use_tls: + transport._session.verify = CERT_PATH + transport._session.mount("https://", HostNameIgnoringAdapter()) + if use_mtls: + transport._session.cert = (CERT_PATH, KEY_PATH) + + return ResumableUploadServiceClient(transport=transport), interceptor + + +@pytest.fixture +def intercepted_resumable_upload_rest_async(): + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Skipping test with async rest.") + + interceptor = ResumableUploadMetadataClientRestAsyncInterceptor() + + transport = AsyncResumableUploadServiceRestTransport( + credentials=async_anonymous_credentials(), + host="localhost:7469", + url_scheme="http", + interceptor=interceptor, + ) + + return ResumableUploadServiceAsyncClient(transport=transport), interceptor + + +try: + from google.api_core.resumable_transfer import ( + ResumableUploadConfig, + ResumableUploadSession, + ) +except ImportError: + ResumableUploadConfig = None + ResumableUploadSession = None + + +def make_resumable_upload( + transport, + request_body, + stream, + upload_url, + size=None, + config=None, + **kwargs, +): + content_type = kwargs.pop("content_type", "application/octet-stream") + response_type = kwargs.pop("response_type", None) + retry = kwargs.pop("retry", None) + timeout = kwargs.pop("timeout", None) + + if config is None: + config = ResumableUploadConfig(**kwargs) + elif kwargs: + for k, v in kwargs.items(): + if hasattr(config, k): + setattr(config, k, v) + + # ``make_resumable_upload`` instantiates ``ResumableUploadSession`` directly + # rather than calling the generated GAPIC client method (``client.upload_media``). + # Unlike the generated GAPIC REST transport, ``ResumableUploadSession`` in + # ``google-api-core`` is payload-format agnostic and does not set + # ``Content-Type: application/json`` on the start request automatically. + headers = dict(config.start_headers or []) + if request_body and "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + config.headers = headers + + session = ResumableUploadSession( + upload_url=upload_url, + config=config, + content_type=content_type, + response_type=response_type, + transport=transport, + ) + return session.upload( + stream=stream, + request_body=request_body, + content_type=content_type, + size=size, + transport=transport, + retry=retry, + timeout=timeout, + ) + + +def resume_resumable_upload( + transport, + upload_url, + stream, + size=None, + config=None, + **kwargs, +): + content_type = kwargs.pop("content_type", None) + response_type = kwargs.pop("response_type", None) + retry = kwargs.pop("retry", None) + timeout = kwargs.pop("timeout", None) + + if config is None: + config = ResumableUploadConfig(**kwargs) + elif kwargs: + for k, v in kwargs.items(): + if hasattr(config, k): + setattr(config, k, v) + + session = ResumableUploadSession( + upload_url=upload_url, + config=config, + transport=transport, + content_type=content_type, + response_type=response_type, + ) + return session.resume( + upload_url=upload_url, + stream=stream, + size=size, + transport=transport, + retry=retry, + timeout=timeout, + ) + diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py b/packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py new file mode 100644 index 000000000000..7c8ebd475291 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py @@ -0,0 +1,601 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Manual live Google Ads acceptance tests for GAPIC Resumable Upload client libraries. + +Skipped by default in CI unless `RUN_GOOGLE_ADS_ACCEPTANCE=true` is set. +Dynamically generates the `YouTubeVideoUploadService` GAPIC library via +`gapic-generator` and exercises `YouTubeVideoUploadServiceClient` and +`YouTubeVideoUploadServiceAsyncClient` (with `grpc`/`grpc_asyncio` and +`rest`/`rest_asyncio` transports) against `https://googleads.googleapis.com` +using two-tier service account impersonation. + +To run the manual live Google Ads acceptance suite: + RUN_GOOGLE_ADS_ACCEPTANCE=true \\ + GOOGLE_ADS_DEVELOPER_TOKEN="" \\ + GOOGLE_ADS_LOGIN_CUSTOMER_ID="7568249731" \\ + GOOGLE_ADS_CUSTOMER_ID="6040249544" \\ + GOOGLE_ADS_TARGET_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ + GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ + GOOGLE_ADS_VIDEO_PATH="~/Downloads/video.mp4" \\ + pytest tests/system/test_resumable_upload_acceptance.py +""" + +from datetime import datetime, timezone +import io +import logging +import os +from pathlib import Path +import re +import subprocess +import sys +from typing import Any, List, Sequence, Tuple +import uuid + +import grpc +import pytest + +import google.auth +from google.auth import credentials as ga_credentials +from google.auth import impersonated_credentials +import google.auth.transport.requests +from google.oauth2 import service_account +from google.api_core import exceptions +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + UploadProgress, +) + + +class _ClientPauseError(Exception): + """Client-side exception raised inside progress loop to simulate user pause.""" + + def __init__(self, message: str, upload_url: str, chunk_size: int) -> None: + super().__init__(message) + self.upload_url = upload_url + self.chunk_size = chunk_size + + +# ============================================================================= +# Manual Live Google Ads (YouTubeVideoUploadService) Acceptance Tests +# ============================================================================= + + +ADWORDS_SCOPE = "https://www.googleapis.com/auth/adwords" +SOURCE_SCOPES = ( + "https://www.googleapis.com/auth/iam", + "https://www.googleapis.com/auth/cloud-platform", +) + +YOUTUBE_VIDEO_UPLOAD_PROTO = """\ +syntax = "proto3"; + +package google.ads.googleads.v23.services; + +import "google/api/annotations.proto"; +import "google/api/client.proto"; + +enum YouTubeVideoPrivacy { + UNSPECIFIED = 0; + UNKNOWN = 1; + PUBLIC = 2; + UNLISTED = 3; +} + +message YouTubeVideoUpload { + string resource_name = 1; + int64 video_upload_id = 2; + string channel_id = 3; + string video_id = 4; + string video_title = 6; + string video_description = 7; + YouTubeVideoPrivacy video_privacy = 8; +} + +message CreateYouTubeVideoUploadRequest { + string customer_id = 1; + YouTubeVideoUpload you_tube_video_upload = 2; +} + +message CreateYouTubeVideoUploadResponse { + string resource_name = 1; +} + +service YouTubeVideoUploadService { + option (google.api.default_host) = "googleads.googleapis.com"; + option (google.api.oauth_scopes) = "https://www.googleapis.com/auth/adwords"; + + rpc CreateYouTubeVideoUpload(CreateYouTubeVideoUploadRequest) + returns (CreateYouTubeVideoUploadResponse) { + option (google.api.http) = { + post: "/v23/customers/{customer_id=*}/youTubeVideoUploads:create" + body: "*" + }; + } +} +""" + +GOOGLEADS_SERVICE_YAML = """\ +type: google.api.Service +config_version: 3 +name: googleads.googleapis.com +title: Google Ads API +apis: +- name: google.ads.googleads.v23.services.YouTubeVideoUploadService +publishing: + library_settings: + - version: google.ads.googleads.v23.services + python_settings: + experimental_features: + rest_async_io_enabled: true +""" + + +def _load_source_credentials() -> ga_credentials.Credentials: + creds, _ = google.auth.default(scopes=list(SOURCE_SCOPES)) + return creds + + +def _load_impersonated_credentials() -> impersonated_credentials.Credentials: + target_principal = os.environ.get("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT", "") + if not target_principal: + pytest.skip("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT is not set.") + source_creds = _load_source_credentials() + return impersonated_credentials.Credentials( + source_credentials=source_creds, + target_principal=target_principal, + target_scopes=[ADWORDS_SCOPE], + lifetime=3600, + ) + + +def _google_ads_metadata() -> Sequence[Tuple[str, str]]: + developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN", "") + login_customer_id = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "") + if not developer_token or not login_customer_id: + pytest.skip( + "GOOGLE_ADS_DEVELOPER_TOKEN and GOOGLE_ADS_LOGIN_CUSTOMER_ID are required." + ) + return ( + ("developer-token", developer_token), + ("login-customer-id", login_customer_id), + ) + + +def _google_ads_customer_id() -> str: + customer_id = os.environ.get( + "GOOGLE_ADS_CUSTOMER_ID", + os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", ""), + ) + if not customer_id: + pytest.skip("GOOGLE_ADS_CUSTOMER_ID is required.") + return customer_id + + +def _open_upload_file() -> Tuple[io.BufferedReader, int]: + raw_path = os.environ.get("GOOGLE_ADS_VIDEO_PATH", "~/Downloads/video.mp4") + expanded = Path(os.path.expanduser(raw_path)).resolve() + if not expanded.is_file(): + pytest.skip(f"Test media file not found at {expanded}") + f = open(expanded, "rb") + return f, expanded.stat().st_size + + +def _extract_detail_type_names(err_or_details: Any) -> List[str]: + details = ( + getattr(err_or_details, "details", None) + if isinstance(err_or_details, Exception) + else err_or_details + ) + if not details and isinstance(err_or_details, Exception): + import json + + try: + payload = json.loads(getattr(err_or_details, "message", "") or "") + if isinstance(payload, dict): + details = payload.get("error", {}).get("details", []) + except ValueError: + details = [] + if not details: + return [] + names: List[str] = [] + for detail in details: + if isinstance(detail, dict) and "@type" in detail: + names.append( + str(detail["@type"]).removeprefix("type.googleapis.com/") + ) + elif hasattr(detail, "DESCRIPTOR"): + full_name = detail.DESCRIPTOR.full_name + if ( + full_name == "google.rpc.ResourceInfo" + and getattr(detail, "resource_type", "").startswith( + "type.googleapis.com/" + ) + ): + names.append( + detail.resource_type.removeprefix("type.googleapis.com/") + ) + else: + names.append(full_name) + elif hasattr(detail, "type_url") and detail.type_url: + names.append( + str(detail.type_url).removeprefix("type.googleapis.com/") + ) + else: + names.append(type(detail).__name__) + return names + + +@pytest.fixture(scope="module") +def generated_google_ads_module(tmp_path_factory): + """Dynamically generates the Google Ads YouTubeVideoUploadService GAPIC client.""" + from google.api import annotations_pb2 + import grpc_tools + + tmp_dir = tmp_path_factory.mktemp("googleads_gapic") + proto_rel = Path( + "google", "ads", "googleads", "v23", "services", "you_tube_video_upload_service.proto" + ) + proto_path = tmp_dir / proto_rel + proto_path.parent.mkdir(parents=True, exist_ok=True) + proto_path.write_text(YOUTUBE_VIDEO_UPLOAD_PROTO, encoding="utf-8") + + yaml_path = tmp_dir / "googleads_v23.yaml" + yaml_path.write_text(GOOGLEADS_SERVICE_YAML, encoding="utf-8") + + googleapis_include = str(Path(annotations_pb2.__file__).resolve().parents[2]) + grpc_tools_include = str(Path(grpc_tools.__file__).resolve().parent / "_proto") + + cmd = [ + sys.executable, + "-m", + "grpc_tools.protoc", + f"-I{tmp_dir}", + f"-I{googleapis_include}", + f"-I{grpc_tools_include}", + "--experimental_allow_proto3_optional", + ( + "--python_gapic_opt=" + f"transport=grpc+rest,service-yaml={yaml_path}" + ), + f"--python_gapic_out={tmp_dir}", + str(proto_rel), + ] + env = os.environ.copy() + bin_dir = str(Path(sys.executable).resolve().parent) + env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" + repo_root = str(Path(__file__).resolve().parents[2]) + existing_pythonpath = env.get("PYTHONPATH", "") + env["PYTHONPATH"] = ( + f"{repo_root}{os.pathsep}{existing_pythonpath}" + if existing_pythonpath + else repo_root + ) + subprocess.run(cmd, check=True, env=env) + + sys.path.insert(0, str(tmp_dir)) + import importlib + + if "google" in sys.modules and hasattr(sys.modules["google"], "__path__"): + google_pkg_dir = str(tmp_dir / "google") + if google_pkg_dir not in sys.modules["google"].__path__: + sys.modules["google"].__path__.append(google_pkg_dir) + + module = importlib.import_module( + "google.ads.googleads_v23.services.you_tube_video_upload_service" + ) + return module + + +@pytest.mark.skipif( + os.environ.get("RUN_GOOGLE_ADS_ACCEPTANCE", "").lower() != "true", + reason=( + "Manual acceptance test against live Google Ads YouTubeVideoUploadService. " + "Set RUN_GOOGLE_ADS_ACCEPTANCE=true to run." + ), +) +class TestGoogleAdsLiveAcceptance: + """Manual live acceptance tests against Google Ads YouTubeVideoUploadService.""" + + @pytest.fixture(autouse=True) + def _disable_mtls(self, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + + @pytest.fixture(autouse=True) + def _capture_diagnostic_trace(self, request): + log_buffer = io.StringIO() + handler = logging.StreamHandler(log_buffer) + handler.setLevel(logging.DEBUG) + handler.setFormatter( + logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s") + ) + root_logger = logging.getLogger() + prev_level = root_logger.level + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(handler) + yield + root_logger.removeHandler(handler) + root_logger.setLevel(prev_level) + contents = log_buffer.getvalue() + if contents: + safe_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", request.node.name) + ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + log_path = Path(f"/tmp/googleads_acceptance_{safe_name}_{ts}.log") + log_path.write_text(contents, encoding="utf-8") + + def test_adc_source_credentials_are_service_account(self): + """Test Case 1: ADC Source Credential Validation.""" + creds = _load_source_credentials() + assert isinstance(creds, service_account.Credentials) + expected_source_sa = os.environ.get("GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT") + if expected_source_sa: + assert creds.service_account_email == expected_source_sa + + def test_impersonated_credentials_fetch_access_token(self): + """Test Case 2: Impersonated Access Token Generation.""" + creds = _load_impersonated_credentials() + creds.refresh(google.auth.transport.requests.Request()) + headers = {} + creds.apply(headers) + + assert creds.token is not None + assert len(creds.token) > 0 + auth_header = headers.get("authorization") or headers.get("Authorization") + assert auth_header is not None + assert auth_header.startswith("Bearer ") + assert creds.expiry is not None + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + assert creds.expiry > now_utc + + @pytest.mark.parametrize("transport", ["grpc", "rest"]) + def test_upload_with_impersonated_credentials( + self, generated_google_ads_module, transport + ): + """Test Case 4: Complete Resumable Media Upload.""" + client_cls = generated_google_ads_module.YouTubeVideoUploadServiceClient + client = client_cls( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + stream, upload_size = _open_upload_file() + try: + upload_session = client.create_you_tube_video_upload( + request={ + "customer_id": _google_ads_customer_id(), + "you_tube_video_upload": { + "video_title": "Test video", + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + }, + metadata=_google_ads_metadata(), + ) + progress_records: List[UploadProgress] = [] + for progress in upload_session.iter_upload( + stream, size=upload_size, content_type="video/mp4" + ): + progress_records.append(progress) + + response = upload_session.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records) >= 2 + assert progress_records[0].state == ProgressState.STARTED + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == upload_size + finally: + stream.close() + + @pytest.mark.parametrize("transport", ["grpc", "rest"]) + def test_golden_user_style_resume_seekable( + self, generated_google_ads_module, transport + ): + """Test Case 5: Mid-Stream Interruption & Seekable Resumption.""" + client_cls = generated_google_ads_module.YouTubeVideoUploadServiceClient + client = client_cls( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + stream, upload_size = _open_upload_file() + chunk_size = 512 * 1024 # 512 KiB + request_payload = { + "customer_id": _google_ads_customer_id(), + "you_tube_video_upload": { + "video_title": "Test video", + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + } + try: + session1 = client.create_you_tube_video_upload( + request=request_payload, + config=ResumableUploadConfig(chunk_size=chunk_size), + metadata=_google_ads_metadata(), + ) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload( + stream, size=upload_size, content_type="video/mp4" + ): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "User paused upload", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size >= chunk_size + assert session1.bytes_uploaded >= chunk_size + + stream.seek(0) + session2 = client.create_you_tube_video_upload( + request=request_payload, + metadata=_google_ads_metadata(), + ) + progress_records_2: List[UploadProgress] = [] + for progress in session2.iter_resume( + saved_url, + stream, + size=upload_size, + chunk_size=saved_chunk_size, + ): + progress_records_2.append(progress) + + response = session2.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records_2) >= 2 + assert progress_records_2[0].state == ProgressState.OFFSET_RECEIVED + assert progress_records_2[0].bytes_uploaded >= chunk_size + assert progress_records_2[-1].state == ProgressState.FINALIZED + assert progress_records_2[-1].bytes_uploaded == upload_size + finally: + stream.close() + + if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_upload_with_impersonated_credentials( + self, generated_google_ads_module, transport + ): + """Async Test Case 4: Complete Resumable Media Upload.""" + client_cls = ( + generated_google_ads_module.YouTubeVideoUploadServiceAsyncClient + ) + client = client_cls( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + stream, upload_size = _open_upload_file() + try: + upload_session = await client.create_you_tube_video_upload( + request={ + "customer_id": _google_ads_customer_id(), + "you_tube_video_upload": { + "video_title": "Test video", + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + }, + metadata=_google_ads_metadata(), + ) + progress_records: List[UploadProgress] = [] + async for progress in upload_session.upload( + stream, size=upload_size, content_type="video/mp4" + ): + progress_records.append(progress) + + response = upload_session.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records) >= 2 + assert progress_records[0].state == ProgressState.STARTED + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == upload_size + finally: + stream.close() + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_golden_user_style_resume_seekable( + self, generated_google_ads_module, transport + ): + """Async Test Case 5: Mid-Stream Interruption & Seekable Resumption.""" + client_cls = ( + generated_google_ads_module.YouTubeVideoUploadServiceAsyncClient + ) + client = client_cls( + credentials=_load_impersonated_credentials(), + transport=transport, + ) + stream, upload_size = _open_upload_file() + chunk_size = 512 * 1024 # 512 KiB + request_payload = { + "customer_id": _google_ads_customer_id(), + "you_tube_video_upload": { + "video_title": "Test video", + "video_description": "Testing defaults", + "video_privacy": "UNLISTED", + }, + } + try: + session1 = await client.create_you_tube_video_upload( + request=request_payload, + config=ResumableUploadConfig(chunk_size=chunk_size), + metadata=_google_ads_metadata(), + ) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload( + stream, size=upload_size, content_type="video/mp4" + ): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "User paused upload", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size >= chunk_size + assert session1.bytes_uploaded >= chunk_size + + stream.seek(0) + session2 = await client.create_you_tube_video_upload( + request=request_payload, + metadata=_google_ads_metadata(), + ) + progress_records_2: List[UploadProgress] = [] + async for progress in session2.resume( + saved_url, + stream, + size=upload_size, + chunk_size=saved_chunk_size, + ): + progress_records_2.append(progress) + + response = session2.response + assert response is not None + assert re.match( + r"^customers/\d+/youTubeVideoUploads/\d+$", + response.resource_name, + ) + assert len(progress_records_2) >= 2 + assert progress_records_2[0].state == ProgressState.OFFSET_RECEIVED + assert progress_records_2[0].bytes_uploaded >= chunk_size + assert progress_records_2[-1].state == ProgressState.FINALIZED + assert progress_records_2[-1].bytes_uploaded == upload_size + finally: + stream.close() diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_progress.py b/packages/gapic-generator/tests/system/test_resumable_upload_progress.py new file mode 100644 index 000000000000..3528f45d405b --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_progress.py @@ -0,0 +1,780 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""System tests for GAPIC Resumable Upload progress tracking and Golden Path workflows. + +Covers both direct `ResumableUploadSession` usage and end-to-end client-level +execution (`ResumableUploadServiceClient` and `ResumableUploadServiceAsyncClient`) +across default `grpc`/`grpc_asyncio` and `rest`/`rest_asyncio` transports, +including the generated sample patterns: +- Option 1: Direct upload from start to completion +- Option 2: Upload while receiving progress updates +- Section 2.1 Golden Path Suite (multi-chunk known size, small upload default + chunk size, standalone finalize on unseekable stream) +""" + +import io +import os +from typing import List +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core.resumable_transfer import ( + DEFAULT_CHUNK_SIZE, + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + make_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + + +class _UnknownSizeUnseekableStream: + """Stream wrapper with unknown total size and non-seekable semantics.""" + + def __init__(self, data: bytes) -> None: + self._buf = io.BytesIO(data) + + def read(self, size: int = -1) -> bytes: + return self._buf.read(size) + + def seekable(self) -> bool: + return False + + +def test_make_resumable_upload_end_to_end(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + stream = io.BytesIO(b"0123456789" * 100) + + # Use make_resumable_upload from start to finish + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "full_e2e_upload.txt"}' + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + ) + assert isinstance(response, bytes) + + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "full_e2e_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_generator_progress_tracking(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_list = [] + # PEP 255 generator progress tracking + for progress in session.iter_upload(stream, request_body='{"name": "generator_upload.txt"}'): + progress_list.append(progress) + assert isinstance(progress, UploadProgress) + assert "sid=" in progress.upload_url + assert progress.chunk_size == 256 + assert progress.total_bytes == len(payload) + + # Verify yielded snapshots + assert len(progress_list) >= 3 + assert progress_list[0].state == ProgressState.STARTED + assert progress_list[-1].state == ProgressState.FINALIZED + assert progress_list[-1].bytes_uploaded == len(payload) + + # Verify response populated on session after generator exhaustion + assert session.response is not None + assert session.response.name == "generator_upload.txt" + assert session.response.size == len(payload) + + +def test_resumable_upload_unseekable_stream_recovery(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "unseekable_stream_upload.txt"}' + + class UnseekableStream(io.BytesIO): + def seekable(self): + return False + + def seek(self, offset, whence=io.SEEK_SET): + raise io.UnsupportedOperation("Stream is not seekable") + + data = b"B" * 1024 + stream = UnseekableStream(data) + + # Injects 503 error on first chunk attempt, which ResumableUploadSession recovers via in-memory buffer + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + size=len(data), + chunk_size=512, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "unseekable_stream_upload.txt" + assert final_response.size == len(data) + + +def test_multi_chunk_known_size(intercepted_resumable_upload_rest): + """Golden Path Case 1: Multi-chunk upload with known size (1.5 MB, 512 KiB chunks).""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + payload = b"M" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "multi_chunk_known_size.mp4"}', + size=total_size, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "multi_chunk_known_size.mp4" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 524_288, 1_048_576, 1_500_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +def test_small_upload_default_chunk_size(intercepted_resumable_upload_rest): + """Golden Path Case 2: Default chunk size on small upload (~100 KB).""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 100_000 + payload = b"S" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig(headers={"Content-Type": "application/json"}) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "small_default_chunk.bin"}', + size=total_size, + ) + ) + + assert session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "small_default_chunk.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert offsets == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +def test_standalone_finalize_unseekable_stream(intercepted_resumable_upload_rest): + """Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 # 256 KiB + total_size = 3 * chunk_size # 786_432 bytes + payload = b"U" * total_size + stream = _UnknownSizeUnseekableStream(payload) + + config = ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "unseekable_exact_multiple.bin"}', + size=None, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "unseekable_exact_multiple.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 786_432, 786_432] + assert all(p.total_bytes is None for p in progress_records) + + +def test_client_upload_media_passes_request_body(intercepted_resumable_upload_rest): + """ Verify that invoking `client.upload_media(request=UploadMediaRequest(...))` + and calling `session.upload(stream=...)` forwards the serialized request body + on the initial upload session start request so the server echoes back the + expected resource name in `UploadMediaResponse.name`.""" + client, _ = intercepted_resumable_upload_rest + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + session = client.upload_media( + request=UploadMediaRequest(name="client_upload_media.txt"), + ) + response = session.upload(stream=stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "client_upload_media.txt" + assert response.size == len(payload) + + +def test_client_upload_media_with_custom_headers_sets_json_content_type( + intercepted_resumable_upload_rest, +): + """Verify that `client.upload_media` sets `Content-Type: application/json` + on the start request and forwards both `metadata` and `config.headers`.""" + client, interceptor = intercepted_resumable_upload_rest + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + # Pass the scenario header via `metadata` so the server only replies with + # `X-Goog-Upload-Chunk-Granularity: 256` (aligning chunk_size=300 -> 512 + # instead of the default 262,144) if `metadata` is sent on the start request. + request_metadata = ("X-Goog-Test-Scenario", "chunk_granularity") + + session = client.upload_media( + request=UploadMediaRequest(name="custom_headers_upload_media.txt"), + config=ResumableUploadConfig( + chunk_size=300, + headers=[("x-custom-config-header", "config-value")], + ), + metadata=[request_metadata], + ) + response = session.upload(stream=stream) + + assert request_metadata in interceptor.request_metadata + assert session.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "custom_headers_upload_media.txt" + assert response.size == len(payload) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_end_to_end( + intercepted_resumable_upload_rest_async, + ): + """Verify end-to-end async resumable upload via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_client_upload_media.txt"), + config=ResumableUploadConfig(chunk_size=256), + ) + response = await session.upload(stream=stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_client_upload_media.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + async def test_async_client_upload_media_with_custom_headers_sets_json_content_type( + intercepted_resumable_upload_rest_async, + ): + """Verify that `async_client.upload_media` sets `Content-Type: application/json` + on the start request and forwards both `metadata` and `config.headers`.""" + client, interceptor = intercepted_resumable_upload_rest_async + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + request_metadata = ("X-Goog-Test-Scenario", "chunk_granularity") + + session = await client.upload_media( + request=UploadMediaRequest(name="async_custom_headers_upload_media.txt"), + config=ResumableUploadConfig( + chunk_size=300, + headers=[("x-custom-config-header", "config-value")], + ), + metadata=[request_metadata], + ) + response = await session.upload(stream=stream) + + assert request_metadata in interceptor.request_metadata + assert session.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_custom_headers_upload_media.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + async def test_async_client_upload_media_progress_tracking( + intercepted_resumable_upload_rest_async, + ): + """Verify async progress iteration over ResumableUploadServiceAsyncClient.upload_media.""" + client, _ = intercepted_resumable_upload_rest_async + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"A" * total_size + stream = io.BytesIO(payload) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_multi_chunk.bin"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + + progress_records = [ + p async for p in session.upload(stream=stream, size=total_size) + ] + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "async_multi_chunk.bin" + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 600_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +# ============================================================================= +# End-to-End Client Sample Patterns & 2.1 Golden Path Suite (gRPC & REST) +# ============================================================================= + + +def _create_sync_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_1_direct_upload(transport, monkeypatch): + """Option 1: Direct upload from start to completion across gRPC and REST transports.""" + client = _create_sync_client(transport, monkeypatch) + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_1_sync.txt") + config = ResumableUploadConfig(chunk_size=262_144) + + upload_session = client.upload_media(request=request, config=config) + response = upload_session.upload(stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_1_sync.txt" + assert response.size == len(payload) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_2_progress_tracking_upload(transport, monkeypatch): + """Option 2: Upload while receiving progress updates across gRPC and REST transports.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"P" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_2_sync.bin") + config = ResumableUploadConfig(chunk_size=chunk_size) + + upload_session = client.upload_media(request=request, config=config) + progress_snapshots: List[UploadProgress] = [] + for progress in upload_session.iter_upload(stream): + progress_snapshots.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_2_sync.bin" + assert response.size == total_size + assert [p.state for p in progress_snapshots] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_snapshots] == [ + 0, + 262_144, + 524_288, + total_size, + ] + + +# ----------------------------------------------------------------------------- +# 2.1 Golden Path Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_multi_chunk_known_size(transport, monkeypatch): + """Golden Path Case 1: Multi-chunk upload with known size (1.5 MB, 512 KiB chunks).""" + client = _create_sync_client(transport, monkeypatch) + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + stream = io.BytesIO(b"M" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="multi_chunk_known_size.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "multi_chunk_known_size.mp4" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 524_288, + 1_048_576, + 1_500_000, + ] + assert all(p.total_bytes == total_size for p in progress_records) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_small_upload_default_chunk_size(transport, monkeypatch): + """Golden Path Case 2: Default chunk size on small upload (~100 KB).""" + client = _create_sync_client(transport, monkeypatch) + total_size = 100_000 + stream = io.BytesIO(b"S" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="small_default_chunk.bin"), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size) + ) + + assert upload_session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "small_default_chunk.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_standalone_finalize_unseekable_stream(transport, monkeypatch): + """Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 3 * chunk_size # 786_432 bytes + stream = _UnknownSizeUnseekableStream(b"U" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="unseekable_exact_multiple.bin"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = list(upload_session.iter_upload(stream, size=None)) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "unseekable_exact_multiple.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 786_432, + 786_432, + ] + assert all(p.total_bytes is None for p in progress_records) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_1_direct_upload(transport, monkeypatch): + """Async Option 1: Direct upload from start to completion.""" + client = _create_async_client(transport, monkeypatch) + payload = b"0123456789" * 100 + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_1_async.txt") + config = ResumableUploadConfig(chunk_size=262_144) + + upload_session = await client.upload_media( + request=request, config=config + ) + response = await upload_session.upload(stream) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_1_async.txt" + assert response.size == len(payload) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_2_progress_tracking_upload( + transport, monkeypatch + ): + """Async Option 2: Upload while receiving progress updates.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 # 256 KiB + total_size = 600_000 + payload = b"P" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_2_async.bin") + config = ResumableUploadConfig(chunk_size=chunk_size) + + upload_session = await client.upload_media( + request=request, config=config + ) + progress_snapshots: List[UploadProgress] = [] + async for progress in upload_session.upload(stream): + progress_snapshots.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_2_async.bin" + assert response.size == total_size + assert [p.state for p in progress_snapshots] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_snapshots] == [ + 0, + 262_144, + 524_288, + total_size, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_multi_chunk_known_size(transport, monkeypatch): + """Async Golden Path Case 1: Multi-chunk upload with known size.""" + client = _create_async_client(transport, monkeypatch) + total_size = 1_500_000 + chunk_size = 524_288 + stream = io.BytesIO(b"M" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_multi_chunk_known_size.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=total_size) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "async_multi_chunk_known_size.mp4" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 524_288, + 1_048_576, + 1_500_000, + ] + assert all(p.total_bytes == total_size for p in progress_records) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_small_upload_default_chunk_size( + transport, monkeypatch + ): + """Async Golden Path Case 2: Default chunk size on small upload.""" + client = _create_async_client(transport, monkeypatch) + total_size = 100_000 + stream = io.BytesIO(b"S" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_small_default_chunk.bin"), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=total_size) + ] + + assert upload_session.chunk_size <= DEFAULT_CHUNK_SIZE + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.name == "async_small_default_chunk.bin" + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [0, 100_000] + assert all(p.total_bytes == total_size for p in progress_records) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_standalone_finalize_unseekable_stream( + transport, monkeypatch + ): + """Async Golden Path Case 3: Unseekable stream with unknown size and exact chunk multiple.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = _UnknownSizeUnseekableStream(b"U" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest( + name="async_unseekable_exact_multiple.bin" + ), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=None) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert ( + upload_session.response.name + == "async_unseekable_exact_multiple.bin" + ) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 786_432, + 786_432, + ] + assert all(p.total_bytes is None for p in progress_records) diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_resume.py b/packages/gapic-generator/tests/system/test_resumable_upload_resume.py new file mode 100644 index 000000000000..5845c804f451 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_resume.py @@ -0,0 +1,973 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""System tests for GAPIC Resumable Upload session resumption workflows. + +Covers both direct `ResumableUploadSession` usage and end-to-end client-level +execution (`ResumableUploadServiceClient` and `ResumableUploadServiceAsyncClient`) +across default `grpc`/`grpc_asyncio` and `rest`/`rest_asyncio` transports, +including the generated sample patterns: +- Option 3: Resume an interrupted upload directly +- Option 4: Resume an interrupted upload while receiving progress updates +- Section 2.5 Resumption Suite (in-progress multi-chunk resumption, finalized upload + recovery, and unseekable stream rejection) +""" + +import io +import os +from typing import List +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core import exceptions +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + resume_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + + +class _UnseekableBytesStream: + """File-like stream wrapper that disables seek().""" + + def __init__(self, data: bytes): + self._buf = io.BytesIO(data) + + def read(self, size: int = -1) -> bytes: + return self._buf.read(size) + + def seekable(self) -> bool: + return False + + +class _ClientPauseError(Exception): + """Client-side exception raised inside progress loop to simulate user pause.""" + + def __init__( + self, + message: str, + upload_url: str = "", + chunk_size: int = 0, + ) -> None: + super().__init__(message) + self.upload_url = upload_url + self.chunk_size = chunk_size + + +def _create_sync_client(monkeypatch, transport: str) -> ResumableUploadServiceClient: + # Disable local machine mTLS auto-detection for showcase test endpoints. + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + + +def test_resumable_upload_resume_direct(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "resume_direct.txt"}' + data = b"R" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: Initiate and upload first chunk (512 bytes) + config1 = ResumableUploadConfig( + chunk_size=512, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Transmit only the first chunk + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 512 + assert not session1.finished + + # Session 2: Fresh session simulating resumption across process boundaries + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + # Rewind stream to simulate providing full file stream on resume + stream.seek(0) + final_response = session2.resume( + upload_url=saved_url, + stream=stream, + size=len(data), + transport=client.transport._session, + ) + + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "resume_direct.txt" + assert final_response.size == len(data) + assert session2.bytes_uploaded == len(data) + assert session2.finished + + +def test_resumable_upload_iter_resume_generator(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "iter_resume.txt"}' + data = b"I" * 1536 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + config1 = ResumableUploadConfig( + chunk_size=512, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Transmit first chunk + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 512 + + # Session 2: Resuming with PEP 255 generator iter_resume + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + stream.seek(0) + progress_snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=len(data), + transport=client.transport._session, + ) + ) + + # First event should be OFFSET_RECEIVED recovering to 512 bytes + assert len(progress_snapshots) >= 2 + offset_event = progress_snapshots[0] + assert offset_event.state == ProgressState.OFFSET_RECEIVED + assert offset_event.bytes_uploaded == 512 + + # Final event should be FINALIZED at 1536 bytes + final_event = progress_snapshots[-1] + assert final_event.state == ProgressState.FINALIZED + assert final_event.bytes_uploaded == len(data) + + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "iter_resume.txt" + assert session2.response.size == len(data) + + +def test_resumable_upload_resume_chunk_size_override( + intercepted_resumable_upload_rest, +): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "chunk_override.txt"}' + data = b"C" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: 256-byte chunks + config1 = ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ) + session1 = ResumableUploadSession( + upload_url=initial_url, + config=config1, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + + # Transmit 256 bytes + session1._transmit_chunk(client.transport._session, stream, len(data)) + assert session1.bytes_uploaded == 256 + + # Session 2: Resumes overriding chunk_size to 512 (valid multiple of 256) + config2 = ResumableUploadConfig( + chunk_size=512, + ) + session2 = ResumableUploadSession( + config=config2, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + stream.seek(0) + response = session2.resume( + upload_url=saved_url, + stream=stream, + chunk_size=512, + transport=client.transport._session, + ) + + assert session2.chunk_size == 512 + assert isinstance(response, UploadMediaResponse) + assert response.name == "chunk_override.txt" + assert response.size == len(data) + + +def test_resumable_upload_resume_helper_with_raw_bytes( + intercepted_resumable_upload_rest, +): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "helper_bytes.bin"}' + data = b"B" * 1024 + + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ] + + # Session 1: Start and upload first chunk + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=256, + headers=scenario_headers, + ), + transport=client.transport._session, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=len(data), + ) + saved_url = session1.upload_url + + stream1 = io.BytesIO(data) + session1._transmit_chunk(client.transport._session, stream1, len(data)) + assert session1.bytes_uploaded == 256 + + # Resume directly using resume_resumable_upload helper with raw bytes + config2 = ResumableUploadConfig( + chunk_size=512, + ) + final_response = resume_resumable_upload( + transport=client.transport._session, + upload_url=saved_url, + stream=data, + config=config2, + response_type=UploadMediaResponse, + ) + + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "helper_bytes.bin" + assert final_response.size == len(data) + + +def test_resume_in_progress_upload(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 1: Resume an in-progress multi-chunk upload.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "resume_in_progress.mp4"}' + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"P" * total_size + stream = io.BytesIO(data) + + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=total_size, + ) + saved_url = session1.upload_url + assert saved_url is not None + + # Upload only the first 512 KiB chunk + session1._transmit_chunk(client.transport._session, stream, total_size) + assert session1.bytes_uploaded == 524_288 + assert not session1.finished + + # Resume from a fresh session with a rewound stream + stream.seek(0) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=chunk_size, + transport=client.transport._session, + ) + ) + + assert [(p.state, p.bytes_uploaded) for p in snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "resume_in_progress.mp4" + assert session2.response.size == total_size + + +def test_resume_finalized_upload(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 2: Querying/recovering an already-finalized upload.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "already_finalized.txt"}' + total_size = 524_288 + data = b"F" * total_size + + # Complete the upload in Session 1 + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=total_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + resp1 = session1.upload( + stream=io.BytesIO(data), + request_body=request_body, + size=total_size, + ) + assert isinstance(resp1, UploadMediaResponse) + assert resp1.size == total_size + saved_url = session1.upload_url + assert saved_url is not None + + # Session 2 recovers against the already-finalized session URL + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=total_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session2._state._resumable_url = saved_url + session2._state._upload_url = saved_url + session2._needs_recovery = True + progress_queue = [] + snapshots = list( + session2._transmit_all_chunks( + client.transport._session, + io.BytesIO(data), + total_size, + progress_queue=progress_queue, + ) + ) + + assert session2.finished + assert [p.state for p in snapshots] == [ + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ] + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name + assert session2.response.size == total_size + + +def test_resume_unseekable_stream_raises(intercepted_resumable_upload_rest): + """2.5 Resumption Suite - Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "unseekable_resume.bin"}' + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + + # Session 1 uploads first chunk of 512 KiB + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1._initiate( + transport=client.transport._session, + request_body=request_body, + size=total_size, + ) + saved_url = session1.upload_url + session1._transmit_chunk(client.transport._session, stream1, total_size) + assert session1.bytes_uploaded == chunk_size + + # Attempting to resume from offset 524,288 with an unseekable stream must raise UnseekableStreamError + unseekable_stream = _UnseekableBytesStream(data) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + transport=client.transport._session, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + + +def test_golden_user_style_resume_seekable(intercepted_resumable_upload_rest): + """Test Case 3 / 2.5 Case 6: Mid-stream client interruption and resumption with a seekable stream.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "golden_user_resume.mp4"}' + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"G" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + # Phase 1: Start Session 1 and intentionally abort after the first 512 KiB chunk + session1 = ResumableUploadSession( + upload_url=initial_url, + config=ResumableUploadConfig( + chunk_size=chunk_size, + headers={"Content-Type": "application/json"}, + ), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session1_snapshots = [] + with pytest.raises(_ClientPauseError): + for progress in session1.iter_upload( + stream=stream, + request_body=request_body, + size=total_size, + ): + session1_snapshots.append(progress) + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + # Phase 2: State verification + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + assert [(p.state, p.bytes_uploaded) for p in session1_snapshots] == [ + (ProgressState.STARTED, 0), + (ProgressState.UPLOADING, 524_288), + ] + + # Phase 3: Rewind stream to byte 0 and resume in Session 2 + stream.seek(0) + session2 = ResumableUploadSession( + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session2_snapshots = list( + session2.iter_resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + transport=client.transport._session, + ) + ) + + assert [(p.state, p.bytes_uploaded) for p in session2_snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert session2.finished + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "golden_user_resume.mp4" + assert session2.response.size == total_size + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_resume_direct( + intercepted_resumable_upload_rest_async, + ): + """Verify direct async resumption via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"R" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + session1 = await client.upload_media( + request=UploadMediaRequest(name="async_resume_direct.txt"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError): + async for progress in session1.upload(stream=stream, size=total_size): + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + + stream.seek(0) + session2 = await client.upload_media( + request=UploadMediaRequest(name="async_resume_direct.txt"), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + ) + response = await session2.resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + ) + + assert session2.finished + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_resume_direct.txt" + assert response.size == total_size + + @pytest.mark.asyncio + async def test_async_client_upload_media_resume_seekable( + intercepted_resumable_upload_rest_async, + ): + """Verify async progress iteration during resumption via ResumableUploadServiceAsyncClient.""" + client, _ = intercepted_resumable_upload_rest_async + total_size = 1_500_000 + chunk_size = 524_288 # 512 KiB + data = b"G" * total_size + stream = io.BytesIO(data) + + class _ClientPauseError(Exception): + pass + + session1 = await client.upload_media( + request=UploadMediaRequest(name="async_golden_user_resume.mp4"), + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + session1_snapshots = [] + with pytest.raises(_ClientPauseError): + async for progress in session1.upload(stream=stream, size=total_size): + session1_snapshots.append(progress) + if progress.bytes_uploaded >= chunk_size: + raise _ClientPauseError("Simulated user pause after first chunk") + + saved_url = session1.upload_url + saved_chunk_size = session1.chunk_size + assert saved_url is not None + assert saved_chunk_size == chunk_size + assert [(p.state, p.bytes_uploaded) for p in session1_snapshots] == [ + (ProgressState.STARTED, 0), + (ProgressState.UPLOADING, 524_288), + ] + + stream.seek(0) + session2 = await client.upload_media( + request=UploadMediaRequest(name="async_golden_user_resume.mp4"), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + ) + session2_snapshots = [ + p + async for p in session2.resume( + upload_url=saved_url, + stream=stream, + size=total_size, + chunk_size=saved_chunk_size, + ) + ] + + assert [(p.state, p.bytes_uploaded) for p in session2_snapshots] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, 1_500_000), + ] + assert session2.finished + assert isinstance(session2.response, UploadMediaResponse) + assert session2.response.name == "async_golden_user_resume.mp4" + assert session2.response.size == total_size + + +# ============================================================================= +# End-to-End Client Sample Patterns (Options 3 & 4) & 2.5 Resumption Suite +# ============================================================================= + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_3_direct_resume(monkeypatch, transport): + """Option 3: Resume an interrupted upload directly across gRPC and REST transports.""" + client = _create_sync_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"R" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_3_sync.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + assert saved_url + assert saved_chunk_size == chunk_size + + # Create a fresh client and session to resume the interrupted upload + resume_client = _create_sync_client(monkeypatch, transport) + stream.seek(0) + upload_session = resume_client.upload_media(request=request) + response = upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_3_sync.mp4" + assert response.size == total_size + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_sample_option_4_progress_tracking_resume(monkeypatch, transport): + """Option 4: Resume an interrupted upload while receiving progress updates.""" + client = _create_sync_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_4_sync.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + for progress in session1.iter_upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_sync_client(monkeypatch, transport) + stream.seek(0) + upload_session = resume_client.upload_media(request=request) + resume_progress: List[UploadProgress] = [] + for progress in upload_session.iter_resume( + saved_url, stream, chunk_size=saved_chunk_size + ): + resume_progress.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_4_sync.mp4" + assert response.size == total_size + assert [(p.state, p.bytes_uploaded) for p in resume_progress] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, total_size), + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_resume_unseekable_stream_raises(monkeypatch, transport): + """Resumption Suite Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client = _create_sync_client(monkeypatch, transport) + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + request = UploadMediaRequest(name="unseekable_resume.bin") + + session1 = client.upload_media( + request=request, + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError) as pause_exc: + for progress in session1.iter_upload(stream1, size=total_size): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = pause_exc.value.upload_url + unseekable_stream = _UnseekableBytesStream(data) + session2 = client.upload_media(request=request) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client( + monkeypatch, transport: str + ) -> ResumableUploadServiceAsyncClient: + # Disable local machine mTLS auto-detection for showcase test endpoints. + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_3_direct_resume(monkeypatch, transport): + """Async Option 3: Resume an interrupted upload directly.""" + client = _create_async_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"R" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_3_async.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = await client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_async_client(monkeypatch, transport) + stream.seek(0) + upload_session = await resume_client.upload_media(request=request) + response = await upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ) + + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_3_async.mp4" + assert response.size == total_size + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sample_option_4_progress_tracking_resume( + monkeypatch, transport + ): + """Async Option 4: Resume an interrupted upload while receiving progress updates.""" + client = _create_async_client(monkeypatch, transport) + chunk_size = 524_288 # 512 KiB + total_size = 1_500_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + request = UploadMediaRequest(name="sample_option_4_async.mp4") + config = ResumableUploadConfig(chunk_size=chunk_size) + + session1 = await client.upload_media(request=request, config=config) + with pytest.raises(_ClientPauseError) as exc_info: + async for progress in session1.upload(stream): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Simulated pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = exc_info.value.upload_url + saved_chunk_size = exc_info.value.chunk_size + + resume_client = _create_async_client(monkeypatch, transport) + stream.seek(0) + upload_session = await resume_client.upload_media(request=request) + resume_progress: List[UploadProgress] = [] + async for progress in upload_session.resume( + saved_url, stream, chunk_size=saved_chunk_size + ): + resume_progress.append(progress) + + response = upload_session.response + assert isinstance(response, UploadMediaResponse) + assert response.name == "sample_option_4_async.mp4" + assert response.size == total_size + assert [(p.state, p.bytes_uploaded) for p in resume_progress] == [ + (ProgressState.OFFSET_RECEIVED, 524_288), + (ProgressState.UPLOADING, 1_048_576), + (ProgressState.FINALIZED, total_size), + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_client_resume_unseekable_stream_raises( + monkeypatch, transport + ): + """Async Resumption Suite Case 5: Resuming with an unseekable stream raises UnseekableStreamError.""" + client = _create_async_client(monkeypatch, transport) + total_size = 1_048_576 + chunk_size = 524_288 + data = b"U" * total_size + stream1 = io.BytesIO(data) + request = UploadMediaRequest(name="async_unseekable_resume.bin") + + session1 = await client.upload_media( + request=request, + config=ResumableUploadConfig(chunk_size=chunk_size), + ) + with pytest.raises(_ClientPauseError) as pause_exc: + async for progress in session1.upload(stream1, size=total_size): + if ( + progress.state == ProgressState.UPLOADING + and progress.bytes_uploaded >= chunk_size + ): + raise _ClientPauseError( + "Pause after first chunk", + upload_url=progress.upload_url, + chunk_size=session1.chunk_size, + ) + + saved_url = pause_exc.value.upload_url + unseekable_stream = _UnseekableBytesStream(data) + session2 = await client.upload_media(request=request) + with pytest.raises(exceptions.UnseekableStreamError) as exc_info: + await session2.resume( + upload_url=saved_url, + stream=unseekable_stream, + size=total_size, + chunk_size=chunk_size, + ) + + assert exc_info.value.upload_url == saved_url + assert exc_info.value.chunk_size == chunk_size + diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py b/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py new file mode 100644 index 000000000000..e258b712816e --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_scenarios.py @@ -0,0 +1,1812 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""System tests for GAPIC Resumable Upload protocol scenarios against gapic-showcase. + +Covers both `ResumableUploadSession` and end-to-end `ResumableUploadServiceClient` / +`ResumableUploadServiceAsyncClient` across default `grpc`/`grpc_asyncio` and +`rest`/`rest_asyncio` transports: +- Section 2.2 Chunk Granularity Suite (`chunk_granularity`) +- Section 2.3 Error Recovery Suite (`non_fatal_error_on_chunk_upload`: Category 1 + transient retries, Category 2 protocol recovery at offset 0 / chunk 2 / + finalizing chunk, and repeated no-header failures until global deadline exceeded) +- Section 2.4 Error on Start Suite (`non_fatal_error_on_start` & + `fatal_error_on_start`: 503 retry, 400 rejection, retry exhaustion, fatal + 403/404 immediate failure, and sequential session isolation) +""" + +import datetime +import io +import json +import os +import time +from typing import List +import uuid +import pytest + +from google.auth import credentials as ga_credentials +from google.api_core import exceptions as core_exceptions +from google.api_core import retry as retries +from google.api_core.resumable_transfer import ( + ProgressState, + ResumableUploadConfig, + ResumableUploadSession, + UploadProgress, +) +from google.showcase import ( + ResumableUploadServiceClient, + UploadMediaRequest, + UploadMediaResponse, +) + +from conftest import ( + HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT, + make_resumable_upload, +) + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + from conftest import async_anonymous_credentials + from google.showcase import ResumableUploadServiceAsyncClient + + +SHOWCASE_API_ENDPOINT = "http://localhost:7469" + +FAST_RETRY = retries.Retry(initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0) +FAST_STREAMING_RETRY = retries.StreamingRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) +FAST_ASYNC_RETRY = retries.AsyncRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) +FAST_ASYNC_STREAMING_RETRY = retries.AsyncStreamingRetry( + initial=0.05, maximum=0.2, multiplier=1.5, timeout=5.0 +) + + +def test_resumable_upload_scenario_non_fatal_start_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "retry_start_upload.txt"}' + stream = io.BytesIO(b"Hello world!") + + # Injects 503 error on start attempt, which gets automatically retried + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"client_uuid": str(uuid.uuid4()), "error_code": 503, "failure_count": 1}), + ), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "retry_start_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_scenario_fatal_start_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "fatal_start_upload.txt"}' + stream = io.BytesIO(b"Hello fatal error!") + + # Injects 403 Forbidden error on start attempt (Category 3 unretriable error) + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ("X-Goog-Test-Scenario-Config", '{"error_code":403}'), + ] + + with pytest.raises(core_exceptions.Forbidden): + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + headers=scenario_headers, + ) + + +@pytest.mark.skip(reason="https://github.com/googleapis/gapic-showcase/issues/1685") +def test_resumable_upload_scenario_missing_status_header_start_retry(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "missing_status_header_upload.txt"}' + stream = io.BytesIO(b"Retrying on missing status header!") + + # Intercept first start response and strip X-Goog-Upload-Status header to test Category 1 retry + original_send = client.transport._session.send + attempt_count = [0] + + def intercepting_send(request, **kwargs): + resp = original_send(request, **kwargs) + if request.headers.get("X-Goog-Upload-Command") == "start": + attempt_count[0] += 1 + if attempt_count[0] == 1: + # Strip X-Goog-Upload-Status on first attempt + resp.headers.pop("X-Goog-Upload-Status", None) + resp.headers.pop("x-goog-upload-status", None) + return resp + + client.transport._session.send = intercepting_send + try: + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + ) + assert isinstance(response, bytes) + assert attempt_count[0] >= 2 # Verified that start was retried upon missing status header + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "missing_status_header_upload.txt" + assert final_response.size == len(stream.getvalue()) + finally: + client.transport._session.send = original_send + + +def test_resumable_upload_scenario_non_fatal_chunk_error(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "recovered_chunk_upload.txt"}' + stream = io.BytesIO(b"A" * 1024) + + # Injects 503 error on first chunk attempt, which ResumableUploadSession recovers via in-memory buffer + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=512, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "recovered_chunk_upload.txt" + assert final_response.size == len(stream.getvalue()) + + +def test_resumable_upload_partial_commit_recovery(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "partial_commit_upload.txt"}' + data = b"0123456789" * 50 + stream = io.BytesIO(data) + + # Injects 503 error after server commits only 100 bytes of the chunk + scenario_headers = [ + ("X-Goog-Test-Scenario", "partial_commit_on_chunk_upload"), + ("X-Goog-Test-Scenario-Config", '{"error_code":503,"failure_count":1,"after_offset":0,"partial_bytes":100}'), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=256, + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "partial_commit_upload.txt" + assert final_response.size == len(data) + + +def test_resumable_upload_chunk_granularity_alignment(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "granularity_upload.txt"}' + data = b"X" * 1000 + stream = io.BytesIO(data) + + # Server enforces 256 byte chunk granularity + scenario_headers = [ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ] + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + chunk_size=300, # Request unaligned chunk size (300) -> state machine aligns up to 512 + headers=scenario_headers, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "granularity_upload.txt" + assert final_response.size == len(data) + + +def test_chunk_granularity_alignment_1mb(intercepted_resumable_upload_rest): + """Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + total_size = 1_000_000 + payload = b"G" * total_size + stream = io.BytesIO(payload) + + config = ResumableUploadConfig( + chunk_size=300_000, + headers=[ + ("X-Goog-Test-Scenario", "chunk_granularity"), + ("Content-Type", "application/json"), + ], + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "granularity_1mb.bin"}', + size=total_size, + timeout=5.0, + ) + ) + + assert session.chunk_size == 300_032 + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 300_032, 600_064, 900_096, 1_000_000] + + +def test_cat1_error_retried(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"C" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat1_503.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + +def test_simple_cat2_error_recovery(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"D" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_offset_0.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 0, 0, 262_144, 524_288, 786_432] + + +def test_two_consecutive_cat2_recoveries_on_chunk_2(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + payload = b"E" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_two_on_chunk_2.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 262_144, 262_144, 262_144, 262_144, 524_288, 786_432] + + +def test_cat2_failure_on_finalizing_chunk(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 # 786_332 + payload = b"F" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = list( + session.iter_upload( + stream, + request_body='{"name": "cat2_finalizing_chunk.bin"}', + size=total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == total_size + + phases = [p.state for p in progress_records] + offsets = [p.bytes_uploaded for p in progress_records] + assert phases == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert offsets == [0, 262_144, 524_288, 524_288, 524_288, 786_332] + + +def test_no_headers_failure_recovers_until_deadline_exceeded(intercepted_resumable_upload_rest): + """Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + payload = b"T" * chunk_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ("Content-Type", "application/json"), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=1.0) + config = ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = [] + with pytest.raises((core_exceptions.DeadlineExceeded, core_exceptions.RetryError)): + for progress in session.iter_upload( + stream, + request_body='{"name": "terminate_until_deadline.bin"}', + size=chunk_size, + retry=FAST_STREAMING_RETRY, + ): + progress_records.append(progress) + + recovering_count = sum(1 for p in progress_records if p.state == ProgressState.RECOVERING) + assert recovering_count >= 1 + + +def test_non_fatal_error_on_start_503(intercepted_resumable_upload_rest): + """Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"s" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 503, "failure_count": 1} + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=FAST_RETRY, + ) + + progress_records = list( + session.iter_upload(stream, request_body='{"name": "start_503.bin"}', size=100) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + +def test_upload_rejection_400_on_start(intercepted_resumable_upload_rest): + """Test Case 2: Protocol-level 400 Bad Request rejection during session initiation.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"r" * 100 + stream = io.BytesIO(payload) + + # Invalid JSON in X-Goog-Test-Scenario-Config or injected 400 on start + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 400, "failure_count": 1} + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + with pytest.raises(core_exceptions.BadRequest) as exc_info: + session.upload(stream, request_body="{}", size=100) + + assert exc_info.value.code == 400 + + +def test_retry_exhaustion_on_start_times_out(intercepted_resumable_upload_rest): + """Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"t" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ("Content-Type", "application/json"), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=2.0) + config = ResumableUploadConfig(headers=scenario_headers, deadline=deadline) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=retries.Retry(initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0), + ) + + progress_records = [] + t_start = time.monotonic() + with pytest.raises((core_exceptions.GoogleAPICallError, core_exceptions.RetryError)): + for progress in session.iter_upload( + stream, request_body='{"name": "start_exhaustion.bin"}', size=100 + ): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], +) +def test_fatal_error_on_start_raises_immediately( + intercepted_resumable_upload_rest, status_code, expected_exc +): + """Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately without retry.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"f" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ("X-Goog-Test-Scenario-Config", json.dumps({"error_code": status_code})), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + + progress_records = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + for progress in session.iter_upload( + stream, request_body='{"name": "fatal_start.bin"}', size=100 + ): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +def test_sequential_runs_session_isolation(intercepted_resumable_upload_rest): + """Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + start_retry=FAST_RETRY, + ) + resp = session.upload( + io.BytesIO(payload), + request_body=f'{{"name": "isolated_run_{run_idx}.bin"}}', + size=100, + ) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"isolated_run_{run_idx}.bin" + assert resp.size == 100 + + +def test_non_fatal_error_on_query_recovery(intercepted_resumable_upload_rest): + """Query retry scenario: transient 503 on query during session recovery.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 2 * chunk_size # 524_288 + payload = b"Q" * total_size + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_query"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": 503, "failure_count": 1}), + ), + ("Content-Type", "application/json"), + ] + config = ResumableUploadConfig(chunk_size=chunk_size, headers=scenario_headers) + session = ResumableUploadSession( + upload_url=initial_url, + config=config, + transport=client.transport._session, + response_type=UploadMediaResponse, + ) + session._initiate( + transport=client.transport._session, + request_body='{"name": "query_retry.bin"}', + size=total_size, + ) + session._transmit_chunk(client.transport._session, stream, total_size) + assert session.bytes_uploaded == chunk_size + + # Trigger recovery during _transmit_all_chunks; first query returns 503, second succeeds + session._needs_recovery = True + progress_records = list( + session._transmit_all_chunks( + client.transport._session, + stream, + total_size, + retry=FAST_STREAMING_RETRY, + ) + ) + + assert isinstance(session.response, UploadMediaResponse) + assert session.response.name == "query_retry.bin" + assert session.response.size == total_size + phases = [p.state for p in progress_records] + assert ProgressState.RECOVERING in phases + assert ProgressState.OFFSET_RECEIVED in phases + assert phases[-1] == ProgressState.FINALIZED + + +def test_client_upload_media_passes_start_retry(intercepted_resumable_upload_rest): + """Verify that `retry` passed to `client.upload_media` is forwarded as `start_retry`.""" + client, _ = intercepted_resumable_upload_rest + payload = b"s" * 100 + stream = io.BytesIO(payload) + + # Injects a single 409 Conflict on start, which is not retried by the default + # start retry policy unless the custom `retry` passed to `client.upload_media` + # reaches `ResumableUploadSession(start_retry=...)`. + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + {"client_uuid": str(uuid.uuid4()), "error_code": 409, "failure_count": 1} + ), + ), + ] + retried_errors = [] + custom_retry = retries.Retry( + predicate=retries.if_exception_type(core_exceptions.Conflict), + initial=0.05, + maximum=0.2, + multiplier=1.5, + timeout=5.0, + on_error=retried_errors.append, + ) + + session = client.upload_media( + request=UploadMediaRequest(name="client_start_retry.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=custom_retry, + ) + response = session.upload(stream=stream, size=len(payload)) + + assert len(retried_errors) == 1 + assert isinstance(retried_errors[0], core_exceptions.Conflict) + assert isinstance(response, UploadMediaResponse) + assert response.name == "client_start_retry.bin" + assert response.size == len(payload) + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + @pytest.mark.asyncio + async def test_async_client_upload_media_passes_start_retry( + intercepted_resumable_upload_rest_async, + ): + """Verify that `retry` passed to `async_client.upload_media` is forwarded as `start_retry`.""" + client, _ = intercepted_resumable_upload_rest_async + payload = b"s" * 100 + stream = io.BytesIO(payload) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 409, + "failure_count": 1, + } + ), + ), + ] + retried_errors = [] + custom_retry = retries.AsyncRetry( + predicate=retries.if_exception_type(core_exceptions.Conflict), + initial=0.05, + maximum=0.2, + multiplier=1.5, + timeout=5.0, + on_error=retried_errors.append, + ) + + session = await client.upload_media( + request=UploadMediaRequest(name="async_client_start_retry.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=custom_retry, + ) + response = await session.upload(stream=stream, size=len(payload)) + + assert len(retried_errors) == 1 + assert isinstance(retried_errors[0], core_exceptions.Conflict) + assert isinstance(response, UploadMediaResponse) + assert response.name == "async_client_start_retry.bin" + assert response.size == len(payload) + + +# ============================================================================= +# End-to-End Showcase Client Scenario Suites (gRPC & REST) +# ============================================================================= + + +def _create_sync_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + return ResumableUploadServiceClient( + credentials=ga_credentials.AnonymousCredentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + +# ----------------------------------------------------------------------------- +# 2.2 Chunk Granularity Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_chunk_granularity_alignment(transport, monkeypatch): + """Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client = _create_sync_client(transport, monkeypatch) + total_size = 1_000_000 + stream = io.BytesIO(b"G" * total_size) + + upload_session = client.upload_media( + request=UploadMediaRequest(name="granularity_1mb.bin"), + config=ResumableUploadConfig( + chunk_size=300_000, + headers=[("X-Goog-Test-Scenario", "chunk_granularity")], + ), + ) + progress_records = list( + upload_session.iter_upload(stream, size=total_size, timeout=5.0) + ) + + assert upload_session.chunk_size == 300_032 + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 300_032, + 600_064, + 900_096, + 1_000_000, + ] + + +# ----------------------------------------------------------------------------- +# 2.3 Error Recovery Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_cat1_error_retried_transparently(transport, monkeypatch): + """Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"C" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat1_503.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_simple_cat2_error_recovery(transport, monkeypatch): + """Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"D" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_offset_0.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 0, + 0, + 262_144, + 524_288, + 786_432, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_two_consecutive_cat2_recoveries_on_chunk_2( + transport, monkeypatch +): + """Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size # 786_432 + stream = io.BytesIO(b"E" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_two_on_chunk_2.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 262_144, + 262_144, + 262_144, + 262_144, + 524_288, + 786_432, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_cat2_failure_on_finalizing_chunk(transport, monkeypatch): + """Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 # 786_332 + stream = io.BytesIO(b"F" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="cat2_finalizing_chunk.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = list( + upload_session.iter_upload( + stream, size=total_size, retry=FAST_STREAMING_RETRY + ) + ) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 524_288, + 524_288, + 786_332, + ] + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_no_headers_failure_recovers_until_deadline_exceeded( + transport, monkeypatch +): + """Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client = _create_sync_client(transport, monkeypatch) + chunk_size = 262_144 + stream = io.BytesIO(b"T" * chunk_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=1.0 + ) + upload_session = client.upload_media( + request=UploadMediaRequest(name="terminate_until_deadline.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ), + ) + + progress_records: List[UploadProgress] = [] + with pytest.raises( + (core_exceptions.DeadlineExceeded, core_exceptions.RetryError) + ): + for progress in upload_session.iter_upload( + stream, size=chunk_size, retry=FAST_STREAMING_RETRY + ): + progress_records.append(progress) + + recovering_count = sum( + 1 for p in progress_records if p.state == ProgressState.RECOVERING + ) + assert recovering_count >= 1 + + +# ----------------------------------------------------------------------------- +# 2.4 Error on Start Suite (End-to-End Client across gRPC & REST) +# ----------------------------------------------------------------------------- + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_non_fatal_error_on_start_503(transport, monkeypatch): + """Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"s" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="start_503.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_RETRY, + ) + progress_records = list(upload_session.iter_upload(stream, size=100)) + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_upload_rejection_400_on_start(transport, monkeypatch): + """Error on Start Suite Case 2: Protocol-level 400 Bad Request rejection on start.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"r" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 400, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="reject_400.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + with pytest.raises(core_exceptions.BadRequest) as exc_info: + upload_session.upload(stream, size=100) + + assert exc_info.value.code == 400 + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_retry_exhaustion_on_start_times_out(transport, monkeypatch): + """Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"t" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + seconds=2.0 + ) + upload_session = client.upload_media( + request=UploadMediaRequest(name="start_exhaustion.bin"), + config=ResumableUploadConfig( + headers=scenario_headers, deadline=deadline + ), + retry=retries.Retry( + initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0 + ), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises( + (core_exceptions.GoogleAPICallError, core_exceptions.RetryError) + ): + for progress in upload_session.iter_upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +@pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], +) +def test_client_fatal_error_on_start_raises_immediately( + transport, status_code, expected_exc, monkeypatch +): + """Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately without retry.""" + client = _create_sync_client(transport, monkeypatch) + stream = io.BytesIO(b"f" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": status_code}), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name="fatal_start.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + for progress in upload_session.iter_upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + +@pytest.mark.parametrize("transport", ["grpc", "rest"]) +def test_client_sequential_runs_session_isolation(transport, monkeypatch): + """Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client = _create_sync_client(transport, monkeypatch) + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = client.upload_media( + request=UploadMediaRequest(name=f"isolated_run_{run_idx}.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_RETRY, + ) + resp = upload_session.upload(io.BytesIO(payload), size=100) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"isolated_run_{run_idx}.bin" + assert resp.size == 100 + + +if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": + + def _create_async_client(transport, monkeypatch): + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") + if not HAS_ASYNC_REST_RESUMABLE_UPLOAD_TRANSPORT: + pytest.skip("Async REST transport not available.") + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in ResumableUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + return ResumableUploadServiceAsyncClient( + credentials=async_anonymous_credentials(), + client_options={"api_endpoint": SHOWCASE_API_ENDPOINT}, + transport=transport, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_chunk_granularity_alignment(transport, monkeypatch): + """Async Chunk Granularity Suite Case 1: 1 MB upload with unaligned chunk_size=300_000.""" + client = _create_async_client(transport, monkeypatch) + total_size = 1_000_000 + stream = io.BytesIO(b"G" * total_size) + + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_granularity_1mb.bin"), + config=ResumableUploadConfig( + chunk_size=300_000, + headers=[("X-Goog-Test-Scenario", "chunk_granularity")], + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, timeout=5.0 + ) + ] + + assert upload_session.chunk_size == 300_032 + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 300_032, + 600_064, + 900_096, + 1_000_000, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_cat1_error_retried_transparently( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 1: 503 transient error on chunk 1 at offset 0.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"C" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":503,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat1_503.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert progress_records[-1].state == ProgressState.FINALIZED + assert progress_records[-1].bytes_uploaded == total_size + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_simple_cat2_error_recovery(transport, monkeypatch): + """Async Error Recovery Suite Case 2: Category 2 error recovery at offset 0.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"D" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":0}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_offset_0.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 0, + 0, + 262_144, + 524_288, + 786_432, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_two_consecutive_cat2_recoveries_on_chunk_2( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 3: Two consecutive Category 2 recoveries on chunk 2.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size + stream = io.BytesIO(b"E" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":2,"after_offset":262144}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_two_on_chunk_2.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.UPLOADING, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 262_144, + 262_144, + 262_144, + 262_144, + 524_288, + 786_432, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_cat2_failure_on_finalizing_chunk( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 4: Category 2 failure on the finalizing chunk.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + total_size = 3 * chunk_size - 100 + stream = io.BytesIO(b"F" * total_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"error_code":412,"failure_count":1,"after_offset":524288}', + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_cat2_finalizing_chunk.bin"), + config=ResumableUploadConfig( + chunk_size=chunk_size, headers=scenario_headers + ), + ) + progress_records = [ + p + async for p in upload_session.upload( + stream, size=total_size, retry=FAST_ASYNC_STREAMING_RETRY + ) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == total_size + assert [p.state for p in progress_records] == [ + ProgressState.STARTED, + ProgressState.UPLOADING, + ProgressState.UPLOADING, + ProgressState.RECOVERING, + ProgressState.OFFSET_RECEIVED, + ProgressState.FINALIZED, + ] + assert [p.bytes_uploaded for p in progress_records] == [ + 0, + 262_144, + 524_288, + 524_288, + 524_288, + 786_332, + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_no_headers_failure_recovers_until_deadline_exceeded( + transport, monkeypatch + ): + """Async Error Recovery Suite Case 5: Repeated no-header failures until global deadline exceeded.""" + client = _create_async_client(transport, monkeypatch) + chunk_size = 262_144 + stream = io.BytesIO(b"T" * chunk_size) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_chunk_upload"), + ( + "X-Goog-Test-Scenario-Config", + '{"failure_count":0,"action_after_failures":"terminate"}', + ), + ] + deadline = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=1.0) + upload_session = await client.upload_media( + request=UploadMediaRequest( + name="async_terminate_until_deadline.bin" + ), + config=ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ), + ) + + progress_records: List[UploadProgress] = [] + with pytest.raises( + (core_exceptions.DeadlineExceeded, core_exceptions.RetryError) + ): + async for progress in upload_session.upload( + stream, size=chunk_size, retry=FAST_ASYNC_STREAMING_RETRY + ): + progress_records.append(progress) + + recovering_count = sum( + 1 for p in progress_records if p.state == ProgressState.RECOVERING + ) + assert recovering_count >= 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_non_fatal_error_on_start_503(transport, monkeypatch): + """Async Error on Start Suite Case 1: Single 503 on start with fast retry.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"s" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_start_503.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_ASYNC_RETRY, + ) + progress_records = [ + p async for p in upload_session.upload(stream, size=100) + ] + + assert isinstance(upload_session.response, UploadMediaResponse) + assert upload_session.response.size == 100 + phases = [p.state for p in progress_records] + assert phases.count(ProgressState.STARTED) == 1 + assert phases[-1] == ProgressState.FINALIZED + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_upload_rejection_400_on_start(transport, monkeypatch): + """Async Error on Start Suite Case 2: Protocol-level 400 Bad Request rejection on start.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"r" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 400, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_reject_400.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + with pytest.raises(core_exceptions.BadRequest) as exc_info: + await upload_session.upload(stream, size=100) + + assert exc_info.value.code == 400 + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_retry_exhaustion_on_start_times_out( + transport, monkeypatch + ): + """Async Error on Start Suite Case 3: Repeated 503 on start until session deadline expires.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"t" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 10_000, + } + ), + ), + ] + deadline = datetime.datetime.now( + datetime.timezone.utc + ) + datetime.timedelta(seconds=2.0) + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_start_exhaustion.bin"), + config=ResumableUploadConfig( + headers=scenario_headers, deadline=deadline + ), + retry=retries.AsyncRetry( + initial=0.2, maximum=0.5, multiplier=1.5, timeout=2.0 + ), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises( + (core_exceptions.GoogleAPICallError, core_exceptions.RetryError) + ): + async for progress in upload_session.upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert elapsed >= 1.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + @pytest.mark.parametrize( + "status_code,expected_exc", + [ + (403, core_exceptions.Forbidden), + (404, core_exceptions.NotFound), + ], + ) + async def test_async_fatal_error_on_start_raises_immediately( + transport, status_code, expected_exc, monkeypatch + ): + """Async Error on Start Suite Case 4: Fatal 403 and 404 on start fail immediately.""" + client = _create_async_client(transport, monkeypatch) + stream = io.BytesIO(b"f" * 100) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps({"error_code": status_code}), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest(name="async_fatal_start.bin"), + config=ResumableUploadConfig(headers=scenario_headers), + ) + + progress_records: List[UploadProgress] = [] + t_start = time.monotonic() + with pytest.raises(expected_exc) as exc_info: + async for progress in upload_session.upload(stream, size=100): + progress_records.append(progress) + elapsed = time.monotonic() - t_start + + assert exc_info.value.code == status_code + assert elapsed < 0.5 + phases = [p.state for p in progress_records] + assert ProgressState.UPLOADING not in phases + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) + async def test_async_sequential_runs_session_isolation( + transport, monkeypatch + ): + """Async Error on Start Suite Case 5: Sequential runs with distinct client_uuid values.""" + client = _create_async_client(transport, monkeypatch) + payload = b"i" * 100 + + for run_idx in range(2): + scenario_headers = [ + ("X-Goog-Test-Scenario", "non_fatal_error_on_start"), + ( + "X-Goog-Test-Scenario-Config", + json.dumps( + { + "client_uuid": str(uuid.uuid4()), + "error_code": 503, + "failure_count": 1, + } + ), + ), + ] + upload_session = await client.upload_media( + request=UploadMediaRequest( + name=f"async_isolated_run_{run_idx}.bin" + ), + config=ResumableUploadConfig(headers=scenario_headers), + retry=FAST_ASYNC_RETRY, + ) + resp = await upload_session.upload(io.BytesIO(payload), size=100) + assert isinstance(resp, UploadMediaResponse) + assert resp.name == f"async_isolated_run_{run_idx}.bin" + assert resp.size == 100 diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_stall.py b/packages/gapic-generator/tests/system/test_resumable_upload_stall.py new file mode 100644 index 000000000000..ee4e70ac44c5 --- /dev/null +++ b/packages/gapic-generator/tests/system/test_resumable_upload_stall.py @@ -0,0 +1,221 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import io +import pytest + +from google.api_core import exceptions +from google.api_core.resumable_transfer import ( + ResumableUploadConfig, + TransferStalledError, +) +from google.showcase import UploadMediaResponse + + +from conftest import make_resumable_upload, resume_resumable_upload + + +def test_resumable_upload_stall_control_success(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "stall_control_success.txt"}' + data = b"S" * 1024 + stream = io.BytesIO(data) + + # Stall control: 100 bytes/s minimum rate, 5s timeout -> fast upload succeeds easily + config = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=100.0, + stall_timeout=5.0, + ) + + response = make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + assert isinstance(response, bytes) + final_response = UploadMediaResponse.from_json(response) + assert final_response.name == "stall_control_success.txt" + assert final_response.size == len(data) + + +def test_resumable_upload_stall_control_triggers_abort(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "stall_abort.txt"}' + data = b"S" * 2048 + stream = io.BytesIO(data) + + # Server injects 600ms delay per chunk; client requires high rate (10000000 bytes/s) with 0.5s stall timeout + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + config = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, # 10 MB/s minimum + stall_timeout=0.5, # Abort if lagging for > 500ms + headers=scenario_headers, + ) + + with pytest.raises(TransferStalledError) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + + assert "Upload stalled" in str(exc_info.value) + + +def test_resumable_upload_overall_deadline_exceeded(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + request_body = '{"name": "deadline_exceeded.txt"}' + data = b"D" * 2048 + stream = io.BytesIO(data) + + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + # Deadline is 300ms from now; 600ms server chunk delay forces deadline expiration + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(milliseconds=300) + config = ResumableUploadConfig( + chunk_size=512, + deadline=deadline, + headers=scenario_headers, + ) + + with pytest.raises(exceptions.DeadlineExceeded) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body=request_body, + stream=stream, + upload_url=initial_url, + config=config, + ) + + assert "deadline" in str(exc_info.value).lower() + + +def test_resumable_upload_stall_vs_deadline_conversion(intercepted_resumable_upload_rest): + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ("X-Goog-Test-Scenario-Config", '{"delay_ms":600}'), + ] + + # Case A: Stall occurs before deadline -> raises TransferStalledError + future_deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10.0) + config_stall = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, + stall_timeout=0.4, + deadline=future_deadline, + headers=scenario_headers, + ) + with pytest.raises(TransferStalledError) as exc_stall: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "stall_before_deadline.txt"}', + stream=io.BytesIO(b"A" * 1024), + upload_url=initial_url, + config=config_stall, + ) + assert "Upload stalled" in str(exc_stall.value) + + # Case B: Server delay causes overall deadline to expire -> raises DeadlineExceeded + near_deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(milliseconds=300) + config_deadline = ResumableUploadConfig( + chunk_size=512, + stall_minimum_rate=10_000_000.0, + stall_timeout=0.4, + deadline=near_deadline, + headers=scenario_headers, + ) + with pytest.raises(exceptions.DeadlineExceeded) as exc_dead: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "deadline_before_stall.txt"}', + stream=io.BytesIO(b"B" * 1024), + upload_url=initial_url, + config=config_deadline, + ) + assert "deadline" in str(exc_dead.value).lower() + + +def test_resumable_upload_deadline_timeout_and_resume( + intercepted_resumable_upload_rest, +): + """Timeout on chunk 2 via after_offset + delay_ms, followed by manual resumption via upload_url.""" + client, _ = intercepted_resumable_upload_rest + initial_url = f"{client.transport._host}/resumable/upload/v1beta1/media/upload" + chunk_size = 262_144 + total_size = 3 * chunk_size + data = b"R" * total_size + + # Inject 600ms delay starting on chunk 2 (offset 262144) so chunk 1 commits before deadline + scenario_headers = [ + ("X-Goog-Test-Scenario", "happy_path"), + ( + "X-Goog-Test-Scenario-Config", + f'{{"delay_ms":600,"after_offset":{chunk_size}}}', + ), + ] + deadline = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( + milliseconds=350 + ) + config1 = ResumableUploadConfig( + chunk_size=chunk_size, + deadline=deadline, + headers=scenario_headers, + ) + + with pytest.raises(exceptions.DeadlineExceeded) as exc_info: + make_resumable_upload( + transport=client.transport._session, + request_body='{"name": "timeout_then_resume.bin"}', + stream=io.BytesIO(data), + upload_url=initial_url, + config=config1, + response_type=UploadMediaResponse, + ) + + saved_url = getattr(exc_info.value, "upload_url", None) + saved_chunk_size = getattr(exc_info.value, "chunk_size", chunk_size) + assert saved_url is not None + + # Resume with a fresh session and no tight deadline + final_response = resume_resumable_upload( + transport=client.transport._session, + upload_url=saved_url, + stream=io.BytesIO(data), + config=ResumableUploadConfig(chunk_size=saved_chunk_size), + response_type=UploadMediaResponse, + ) + assert isinstance(final_response, UploadMediaResponse) + assert final_response.name == "timeout_then_resume.bin" + assert final_response.size == total_size + From b1f301f0194a4669a288a2b000322677b0f5372b Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 16:05:17 +0000 Subject: [PATCH 2/7] add acceptance tests --- .kokoro/system.sh | 17 +- packages/gapic-generator/noxfile.py | 116 +++- .../gapic-generator/tests/system/conftest.py | 4 +- .../tests/system_live/README.md | 47 ++ .../test_google_ads_resumable_upload.py} | 511 ++++++++++-------- 5 files changed, 451 insertions(+), 244 deletions(-) create mode 100644 packages/gapic-generator/tests/system_live/README.md rename packages/gapic-generator/tests/{system/test_resumable_upload_acceptance.py => system_live/test_google_ads_resumable_upload.py} (56%) diff --git a/.kokoro/system.sh b/.kokoro/system.sh index bdb56214026a..39ac1bae8233 100755 --- a/.kokoro/system.sh +++ b/.kokoro/system.sh @@ -68,6 +68,14 @@ run_package_test() { trap 'rm -rf "$gcloud_config_dir"' EXIT case "${package_name}" in + "gapic-generator") + source "${KOKORO_GFILE_DIR}/gapic-generator-env.sh" + PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/gapic-generator-project-id.json") + GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/gapic-generator-service-account.json" + export GOOGLE_ADS_VIDEO_PATH="${KOKORO_GFILE_DIR}/gapic-generator-video.mp4" + NOX_FILE="noxfile.py" + NOX_SESSION="${NOX_SESSION:-system-3.12}" + ;; "google-auth") # Copy files needed for google-auth system tests mkdir -p "${package_path}/system_tests/data" @@ -86,15 +94,6 @@ run_package_test() { NOX_FILE="noxfile.py" fi ;; - "google-cloud-dns") - # EXPERIMENTAL: Force running all system sessions to test mixed results. This will be reverted - # before merge. You can safely ignore it. - PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") - GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" - NOX_FILE="noxfile.py" - NOX_SESSION="system" - ;; - *) PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" NOX_FILE="noxfile.py" diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index b9d1870b9e21..65442cf01fed 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -959,12 +959,120 @@ def format(session): ) +@contextmanager +def google_ads_library(session): + """Generate and install the Google Ads GAPIC library for live system tests.""" + session.install("-e", ".") + session.install("grpcio-tools", "pyYAML") + + with tempfile.TemporaryDirectory() as tmp_dir: + googleapis_dir = path.join(tmp_dir, "googleapis") + sdk_dir = path.join(tmp_dir, "sdk") + os.makedirs(sdk_dir, exist_ok=True) + + session.run( + "git", + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + "https://github.com/googleapis/googleapis.git", + googleapis_dir, + external=True, + silent=True, + ) + session.run( + "git", + "-C", + googleapis_dir, + "sparse-checkout", + "set", + "google/ads/googleads/v23", + "google/api", + "google/rpc", + "google/longrunning", + "google/type", + external=True, + silent=True, + ) + + src_yaml = path.join( + googleapis_dir, "google", "ads", "googleads", "v23", "googleads_v23.yaml" + ) + dst_yaml = path.join(sdk_dir, "googleads_v23.yaml") + shutil.copyfile(src_yaml, dst_yaml) + session.run( + "python", + "-c", + ( + "import yaml; " + f"p = {dst_yaml!r}; " + "data = yaml.safe_load(open(p, encoding='utf-8')); " + "data['apis'] = [{'name': 'google.ads.googleads.v23.services.YouTubeVideoUploadService'}]; " + "data.setdefault('publishing', {})['library_settings'] = [" + "{'version': 'google.ads.googleads.v23', " + "'python_settings': {'experimental_features': {'rest_async_io_enabled': True}}}" + "]; " + "yaml.safe_dump(data, open(p, 'w', encoding='utf-8'), default_flow_style=False, sort_keys=False)" + ), + ) + + ads_protos = ( + "google/ads/googleads/v23/services/youtube_video_upload_service.proto", + "google/ads/googleads/v23/resources/youtube_video_upload.proto", + "google/ads/googleads/v23/enums/youtube_video_privacy.proto", + ) + desc_path = path.join(sdk_dir, "googleads.desc") + session.run( + "python", + "-m", + "grpc_tools.protoc", + "--experimental_allow_proto3_optional", + f"--proto_path={googleapis_dir}", + "--include_imports", + "--include_source_info", + f"-o{desc_path}", + *(path.join(googleapis_dir, p) for p in ads_protos), + external=True, + ) + + retry_config = path.join( + googleapis_dir, + "google", + "ads", + "googleads", + "v23", + "googleads_grpc_service_config.json", + ) + session.run( + "python", + "-m", + "grpc_tools.protoc", + "--experimental_allow_proto3_optional", + f"--descriptor_set_in={desc_path}", + ( + "--python_gapic_opt=" + f"transport=grpc+rest,autogen-snippets=False,service-yaml={dst_yaml},retry-config={retry_config}" + ), + f"--python_gapic_out={sdk_dir}", + *ads_protos, + external=True, + ) + + session.install("-e", f"{sdk_dir}[async_rest]") + yield sdk_dir + + @nox.session(python=ALL_PYTHON) def system(session): - # TODO(https://github.com/googleapis/google-cloud-python/issues/16190): - # Implement system test session. - """Run the system test suite (skipped for migration).""" - session.skip(f"system session is not yet implemented for gapic-generator-python.") + """Run the system test suite.""" + with google_ads_library(session): + session.install("pytest", "pytest-asyncio") + session.run( + "py.test", + *(session.posargs or ["-vv", path.join("tests", "system_live")]), + ) @nox.session(python=NEWEST_PYTHON) diff --git a/packages/gapic-generator/tests/system/conftest.py b/packages/gapic-generator/tests/system/conftest.py index 4115b715742a..88d7b62dfa74 100644 --- a/packages/gapic-generator/tests/system/conftest.py +++ b/packages/gapic-generator/tests/system/conftest.py @@ -98,7 +98,9 @@ def async_anonymous_credentials(): @pytest.fixture def event_loop(): - return asyncio.get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop @pytest_asyncio.fixture(params=["grpc_asyncio", "rest_asyncio"]) def async_echo(use_mtls, request, event_loop): diff --git a/packages/gapic-generator/tests/system_live/README.md b/packages/gapic-generator/tests/system_live/README.md new file mode 100644 index 000000000000..398b333de74a --- /dev/null +++ b/packages/gapic-generator/tests/system_live/README.md @@ -0,0 +1,47 @@ +# Live Google Ads Acceptance Tests (`tests/system_live/`) + +This directory contains live acceptance tests ([`test_google_ads_resumable_upload.py`](test_google_ads_resumable_upload.py)) that exercise end-to-end resumable uploads and mid-stream session resumptions against the generated Google Ads `YouTubeVideoUploadService` GAPIC client (`https://googleads.googleapis.com`) across `grpc`, `rest`, `grpc_asyncio`, and `rest_asyncio` transports. + +These tests are isolated in `tests/system_live/` (without `tests/system/conftest.py`) so Showcase system test runs (`tests/system/`) never depend on external credentials or live API endpoints. + +--- + +## 1. Configure Credentials and Environment Variables + +The acceptance suite uses two-tier service account impersonation (`google.auth.impersonated_credentials.Credentials`): + +1. **Source Service Account (`GOOGLE_APPLICATION_CREDENTIALS` / `GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT`)**: + - Service account JSON key with `roles/iam.serviceAccountTokenCreator` permission on the target service account (and `iamcredentials.googleapis.com` enabled on its project). +2. **Target Service Account (`GOOGLE_ADS_TARGET_SERVICE_ACCOUNT`)**: + - Service account granted access to the Google Ads Manager / Customer account (`GOOGLE_ADS_LOGIN_CUSTOMER_ID` / `GOOGLE_ADS_CUSTOMER_ID`) with `googleads.googleapis.com` enabled on its project. +3. **Sample Video Payload (`GOOGLE_ADS_VIDEO_PATH`)**: + - Local `.mp4` file (defaults to `~/Downloads/video.mp4` if unset; should be > 512 KiB to exercise multi-chunk progress and resumption). + +Export the required environment variables in the active shell: + +```bash +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/source-service-account-key.json" +export GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" +export GOOGLE_ADS_TARGET_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" +export GOOGLE_ADS_DEVELOPER_TOKEN="" +export GOOGLE_ADS_LOGIN_CUSTOMER_ID="" +export GOOGLE_ADS_CUSTOMER_ID="" +export GOOGLE_ADS_VIDEO_PATH="${HOME}/Downloads/video.mp4" +``` + +--- + +## 2. Run the Live Acceptance Tests + +From `packages/gapic-generator`, run the [`system`](../../noxfile.py) `nox` session (which sparse-clones `googleapis`, generates and installs the Google Ads GAPIC library via `protoc`, and runs `pytest` against `tests/system_live`): + +```bash +cd packages/gapic-generator +nox -s system-3.14 +``` + +### Diagnostic Logs +During execution, debug logs for each test case are captured automatically under: +```text +/tmp/googleads_acceptance__.log +``` diff --git a/packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py similarity index 56% rename from packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py rename to packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py index 7c8ebd475291..ab99936cb09a 100644 --- a/packages/gapic-generator/tests/system/test_resumable_upload_acceptance.py +++ b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py @@ -12,46 +12,39 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Manual live Google Ads acceptance tests for GAPIC Resumable Upload client libraries. +"""Live Google Ads acceptance tests for GAPIC Resumable Upload client libraries. -Skipped by default in CI unless `RUN_GOOGLE_ADS_ACCEPTANCE=true` is set. -Dynamically generates the `YouTubeVideoUploadService` GAPIC library via -`gapic-generator` and exercises `YouTubeVideoUploadServiceClient` and +Exercises `YouTubeVideoUploadServiceClient` and `YouTubeVideoUploadServiceAsyncClient` (with `grpc`/`grpc_asyncio` and `rest`/`rest_asyncio` transports) against `https://googleads.googleapis.com` using two-tier service account impersonation. -To run the manual live Google Ads acceptance suite: - RUN_GOOGLE_ADS_ACCEPTANCE=true \\ +To run the live Google Ads acceptance suite: GOOGLE_ADS_DEVELOPER_TOKEN="" \\ - GOOGLE_ADS_LOGIN_CUSTOMER_ID="7568249731" \\ - GOOGLE_ADS_CUSTOMER_ID="6040249544" \\ + GOOGLE_ADS_LOGIN_CUSTOMER_ID="" \\ + GOOGLE_ADS_CUSTOMER_ID="" \\ GOOGLE_ADS_TARGET_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT="@.iam.gserviceaccount.com" \\ GOOGLE_ADS_VIDEO_PATH="~/Downloads/video.mp4" \\ - pytest tests/system/test_resumable_upload_acceptance.py + nox -s system-3.14 """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import io import logging import os from pathlib import Path import re -import subprocess -import sys -from typing import Any, List, Sequence, Tuple -import uuid +from typing import List, Optional, Sequence, Tuple -import grpc import pytest +import requests import google.auth from google.auth import credentials as ga_credentials from google.auth import impersonated_credentials import google.auth.transport.requests from google.oauth2 import service_account -from google.api_core import exceptions from google.api_core.resumable_transfer import ( ProgressState, ResumableUploadConfig, @@ -63,13 +56,20 @@ class _ClientPauseError(Exception): """Client-side exception raised inside progress loop to simulate user pause.""" def __init__(self, message: str, upload_url: str, chunk_size: int) -> None: + """Initialize the pause exception with resumable session state. + + Args: + message: Human-readable reason for pausing the upload. + upload_url: Resumable session URI used to resume the upload. + chunk_size: Active chunk size in bytes for the upload session. + """ super().__init__(message) self.upload_url = upload_url self.chunk_size = chunk_size # ============================================================================= -# Manual Live Google Ads (YouTubeVideoUploadService) Acceptance Tests +# Live Google Ads (YouTubeVideoUploadService) Acceptance Tests # ============================================================================= @@ -78,77 +78,88 @@ def __init__(self, message: str, upload_url: str, chunk_size: int) -> None: "https://www.googleapis.com/auth/iam", "https://www.googleapis.com/auth/cloud-platform", ) +VIDEO_TITLE_PREFIX = "gapic-generator-resumable-upload-" +VIDEO_TITLE_TIMESTAMP_FMT = "%Y%m%dT%H%M%SZ" +STALE_UPLOAD_MAX_AGE = timedelta(hours=2) -YOUTUBE_VIDEO_UPLOAD_PROTO = """\ -syntax = "proto3"; - -package google.ads.googleads.v23.services; - -import "google/api/annotations.proto"; -import "google/api/client.proto"; - -enum YouTubeVideoPrivacy { - UNSPECIFIED = 0; - UNKNOWN = 1; - PUBLIC = 2; - UNLISTED = 3; -} - -message YouTubeVideoUpload { - string resource_name = 1; - int64 video_upload_id = 2; - string channel_id = 3; - string video_id = 4; - string video_title = 6; - string video_description = 7; - YouTubeVideoPrivacy video_privacy = 8; -} - -message CreateYouTubeVideoUploadRequest { - string customer_id = 1; - YouTubeVideoUpload you_tube_video_upload = 2; -} - -message CreateYouTubeVideoUploadResponse { - string resource_name = 1; -} - -service YouTubeVideoUploadService { - option (google.api.default_host) = "googleads.googleapis.com"; - option (google.api.oauth_scopes) = "https://www.googleapis.com/auth/adwords"; - - rpc CreateYouTubeVideoUpload(CreateYouTubeVideoUploadRequest) - returns (CreateYouTubeVideoUploadResponse) { - option (google.api.http) = { - post: "/v23/customers/{customer_id=*}/youTubeVideoUploads:create" - body: "*" - }; - } -} -""" +from google.ads.googleads_v23.services.services.you_tube_video_upload_service import ( + YouTubeVideoUploadServiceAsyncClient, + YouTubeVideoUploadServiceClient, +) -GOOGLEADS_SERVICE_YAML = """\ -type: google.api.Service -config_version: 3 -name: googleads.googleapis.com -title: Google Ads API -apis: -- name: google.ads.googleads.v23.services.YouTubeVideoUploadService -publishing: - library_settings: - - version: google.ads.googleads.v23.services - python_settings: - experimental_features: - rest_async_io_enabled: true -""" + +def _generate_video_title() -> str: + """Generate a test video title tagged with the suite prefix and UTC timestamp. + + Returns: + Formatted video title string containing `VIDEO_TITLE_PREFIX` and the + current UTC timestamp (`%Y%m%dT%H%M%SZ`). + """ + ts = datetime.now(timezone.utc).strftime(VIDEO_TITLE_TIMESTAMP_FMT) + return f"{VIDEO_TITLE_PREFIX}{ts}" + + +def _parse_video_title_timestamp(title: str) -> Optional[datetime]: + """Extract the UTC creation timestamp from a prefixed test video title. + + Args: + title: Video title string retrieved from YouTube metadata. + + Returns: + Timezone-aware UTC `datetime` if `title` starts with + `VIDEO_TITLE_PREFIX` and ends with a valid timestamp, otherwise `None`. + """ + if not title.startswith(VIDEO_TITLE_PREFIX): + return None + raw_ts = title[len(VIDEO_TITLE_PREFIX) :] + try: + return datetime.strptime(raw_ts, VIDEO_TITLE_TIMESTAMP_FMT).replace( + tzinfo=timezone.utc + ) + except ValueError: + return None + + +def _fetch_youtube_video_title(video_id: str) -> Optional[str]: + """Resolve the title of an uploaded YouTube video via the public oEmbed endpoint. + + Args: + video_id: 11-character YouTube video identifier (`you_tube_video_upload.video_id`). + + Returns: + Video title string if the oEmbed lookup succeeds with HTTP 200, + otherwise `None`. + """ + resp = requests.get( + "https://www.youtube.com/oembed", + params={ + "url": f"https://www.youtube.com/watch?v={video_id}", + "format": "json", + }, + timeout=10, + ) + if resp.status_code != 200: + return None + return resp.json().get("title") def _load_source_credentials() -> ga_credentials.Credentials: + """Load default source credentials scoped for IAM impersonation and Cloud Platform. + + Returns: + Google Auth credentials object loaded via Application Default Credentials. + """ creds, _ = google.auth.default(scopes=list(SOURCE_SCOPES)) return creds def _load_impersonated_credentials() -> impersonated_credentials.Credentials: + """Construct impersonated Google Ads credentials for the target service account. + + Returns: + Impersonated credentials configured for `ADWORDS_SCOPE` targeting + `GOOGLE_ADS_TARGET_SERVICE_ACCOUNT`. + """ target_principal = os.environ.get("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT", "") if not target_principal: pytest.skip("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT is not set.") @@ -162,6 +173,12 @@ def _load_impersonated_credentials() -> impersonated_credentials.Credentials: def _google_ads_metadata() -> Sequence[Tuple[str, str]]: + """Build gRPC/REST request metadata headers for Google Ads API calls. + + Returns: + Sequence of `(header_name, header_value)` pairs containing + `developer-token` and `login-customer-id`. + """ developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN", "") login_customer_id = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "") if not developer_token or not login_customer_id: @@ -175,6 +192,12 @@ def _google_ads_metadata() -> Sequence[Tuple[str, str]]: def _google_ads_customer_id() -> str: + """Read the target Google Ads customer ID from environment configuration. + + Returns: + Customer ID string read from `GOOGLE_ADS_CUSTOMER_ID` (falling back to + `GOOGLE_ADS_LOGIN_CUSTOMER_ID`). + """ customer_id = os.environ.get( "GOOGLE_ADS_CUSTOMER_ID", os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", ""), @@ -185,6 +208,12 @@ def _google_ads_customer_id() -> str: def _open_upload_file() -> Tuple[io.BufferedReader, int]: + """Open the sample video payload file in binary mode and inspect its size. + + Returns: + Tuple of `(stream, upload_size)` where `stream` is an open binary file + reader and `upload_size` is the file size in bytes. + """ raw_path = os.environ.get("GOOGLE_ADS_VIDEO_PATH", "~/Downloads/video.mp4") expanded = Path(os.path.expanduser(raw_path)).resolve() if not expanded.is_file(): @@ -193,128 +222,89 @@ def _open_upload_file() -> Tuple[io.BufferedReader, int]: return f, expanded.stat().st_size -def _extract_detail_type_names(err_or_details: Any) -> List[str]: - details = ( - getattr(err_or_details, "details", None) - if isinstance(err_or_details, Exception) - else err_or_details +@pytest.fixture(scope="session", autouse=True) +def _cleanup_stale_test_videos(): + """Query existing YouTube video uploads and remove prefixed test resources older than 2 hours. + + Queries `you_tube_video_upload` via `googleAds:search`, inspects video titles + via YouTube oEmbed to identify test resources created with `VIDEO_TITLE_PREFIX`, + and deletes any resource whose embedded timestamp exceeds `STALE_UPLOAD_MAX_AGE`. + """ + creds = _load_impersonated_credentials() + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() + developer_token = dict(metadata)["developer-token"] + login_customer_id = dict(metadata)["login-customer-id"] + + creds.refresh(google.auth.transport.requests.Request()) + headers = { + "Authorization": f"Bearer {creds.token}", + "developer-token": developer_token, + "login-customer-id": login_customer_id, + } + search_url = ( + f"https://googleads.googleapis.com/v23/customers/{customer_id}/googleAds:search" ) - if not details and isinstance(err_or_details, Exception): - import json - - try: - payload = json.loads(getattr(err_or_details, "message", "") or "") - if isinstance(payload, dict): - details = payload.get("error", {}).get("details", []) - except ValueError: - details = [] - if not details: - return [] - names: List[str] = [] - for detail in details: - if isinstance(detail, dict) and "@type" in detail: - names.append( - str(detail["@type"]).removeprefix("type.googleapis.com/") + resp = requests.post( + search_url, + headers=headers, + json={ + "query": ( + "SELECT you_tube_video_upload.resource_name, " + "you_tube_video_upload.video_id " + "FROM you_tube_video_upload" ) - elif hasattr(detail, "DESCRIPTOR"): - full_name = detail.DESCRIPTOR.full_name - if ( - full_name == "google.rpc.ResourceInfo" - and getattr(detail, "resource_type", "").startswith( - "type.googleapis.com/" - ) - ): - names.append( - detail.resource_type.removeprefix("type.googleapis.com/") - ) - else: - names.append(full_name) - elif hasattr(detail, "type_url") and detail.type_url: - names.append( - str(detail.type_url).removeprefix("type.googleapis.com/") - ) - else: - names.append(type(detail).__name__) - return names - - -@pytest.fixture(scope="module") -def generated_google_ads_module(tmp_path_factory): - """Dynamically generates the Google Ads YouTubeVideoUploadService GAPIC client.""" - from google.api import annotations_pb2 - import grpc_tools - - tmp_dir = tmp_path_factory.mktemp("googleads_gapic") - proto_rel = Path( - "google", "ads", "googleads", "v23", "services", "you_tube_video_upload_service.proto" + }, + timeout=30, ) - proto_path = tmp_dir / proto_rel - proto_path.parent.mkdir(parents=True, exist_ok=True) - proto_path.write_text(YOUTUBE_VIDEO_UPLOAD_PROTO, encoding="utf-8") - - yaml_path = tmp_dir / "googleads_v23.yaml" - yaml_path.write_text(GOOGLEADS_SERVICE_YAML, encoding="utf-8") - - googleapis_include = str(Path(annotations_pb2.__file__).resolve().parents[2]) - grpc_tools_include = str(Path(grpc_tools.__file__).resolve().parent / "_proto") - - cmd = [ - sys.executable, - "-m", - "grpc_tools.protoc", - f"-I{tmp_dir}", - f"-I{googleapis_include}", - f"-I{grpc_tools_include}", - "--experimental_allow_proto3_optional", - ( - "--python_gapic_opt=" - f"transport=grpc+rest,service-yaml={yaml_path}" - ), - f"--python_gapic_out={tmp_dir}", - str(proto_rel), - ] - env = os.environ.copy() - bin_dir = str(Path(sys.executable).resolve().parent) - env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" - repo_root = str(Path(__file__).resolve().parents[2]) - existing_pythonpath = env.get("PYTHONPATH", "") - env["PYTHONPATH"] = ( - f"{repo_root}{os.pathsep}{existing_pythonpath}" - if existing_pythonpath - else repo_root - ) - subprocess.run(cmd, check=True, env=env) - - sys.path.insert(0, str(tmp_dir)) - import importlib - - if "google" in sys.modules and hasattr(sys.modules["google"], "__path__"): - google_pkg_dir = str(tmp_dir / "google") - if google_pkg_dir not in sys.modules["google"].__path__: - sys.modules["google"].__path__.append(google_pkg_dir) - - module = importlib.import_module( - "google.ads.googleads_v23.services.you_tube_video_upload_service" - ) - return module + if resp.status_code != 200: + return + + now = datetime.now(timezone.utc) + stale_resource_names: List[str] = [] + for row in resp.json().get("results", []): + upload = row.get("youTubeVideoUpload", {}) + video_id = upload.get("videoId") + resource_name = upload.get("resourceName") + if not video_id or not resource_name: + continue + title = _fetch_youtube_video_title(video_id) + if not title: + continue + created_at = _parse_video_title_timestamp(title) + if created_at is not None and (now - created_at) > STALE_UPLOAD_MAX_AGE: + stale_resource_names.append(resource_name) + + if stale_resource_names: + client = YouTubeVideoUploadServiceClient(credentials=creds) + client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": stale_resource_names, + }, + metadata=metadata, + ) -@pytest.mark.skipif( - os.environ.get("RUN_GOOGLE_ADS_ACCEPTANCE", "").lower() != "true", - reason=( - "Manual acceptance test against live Google Ads YouTubeVideoUploadService. " - "Set RUN_GOOGLE_ADS_ACCEPTANCE=true to run." - ), -) class TestGoogleAdsLiveAcceptance: - """Manual live acceptance tests against Google Ads YouTubeVideoUploadService.""" + """Live acceptance tests against Google Ads YouTubeVideoUploadService.""" @pytest.fixture(autouse=True) def _disable_mtls(self, monkeypatch): + """Disable mTLS client certificates for test execution. + + Args: + monkeypatch: Pytest `MonkeyPatch` fixture for setting environment variables. + """ monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") @pytest.fixture(autouse=True) def _capture_diagnostic_trace(self, request): + """Capture debug log output during each test and persist diagnostic traces to `/tmp`. + + Args: + request: Pytest `FixtureRequest` object providing the current test node name. + """ log_buffer = io.StringIO() handler = logging.StreamHandler(log_buffer) handler.setLevel(logging.DEBUG) @@ -336,7 +326,7 @@ def _capture_diagnostic_trace(self, request): log_path.write_text(contents, encoding="utf-8") def test_adc_source_credentials_are_service_account(self): - """Test Case 1: ADC Source Credential Validation.""" + """Verify ADC source credentials load as a service account matching configuration.""" creds = _load_source_credentials() assert isinstance(creds, service_account.Credentials) expected_source_sa = os.environ.get("GOOGLE_ADS_SOURCE_SERVICE_ACCOUNT") @@ -344,7 +334,7 @@ def test_adc_source_credentials_are_service_account(self): assert creds.service_account_email == expected_source_sa def test_impersonated_credentials_fetch_access_token(self): - """Test Case 2: Impersonated Access Token Generation.""" + """Verify target service account impersonation mints a valid bearer access token.""" creds = _load_impersonated_credentials() creds.refresh(google.auth.transport.requests.Request()) headers = {} @@ -360,27 +350,31 @@ def test_impersonated_credentials_fetch_access_token(self): assert creds.expiry > now_utc @pytest.mark.parametrize("transport", ["grpc", "rest"]) - def test_upload_with_impersonated_credentials( - self, generated_google_ads_module, transport - ): - """Test Case 4: Complete Resumable Media Upload.""" - client_cls = generated_google_ads_module.YouTubeVideoUploadServiceClient - client = client_cls( + def test_upload_with_impersonated_credentials(self, transport: str): + """Verify complete synchronous resumable video upload and resource cleanup. + + Args: + transport: Client transport name (`"grpc"` or `"rest"`). + """ + client = YouTubeVideoUploadServiceClient( credentials=_load_impersonated_credentials(), transport=transport, ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() stream, upload_size = _open_upload_file() + response = None try: upload_session = client.create_you_tube_video_upload( request={ - "customer_id": _google_ads_customer_id(), + "customer_id": customer_id, "you_tube_video_upload": { - "video_title": "Test video", + "video_title": _generate_video_title(), "video_description": "Testing defaults", "video_privacy": "UNLISTED", }, }, - metadata=_google_ads_metadata(), + metadata=metadata, ) progress_records: List[UploadProgress] = [] for progress in upload_session.iter_upload( @@ -400,32 +394,44 @@ def test_upload_with_impersonated_credentials( assert progress_records[-1].bytes_uploaded == upload_size finally: stream.close() + if response is not None and response.resource_name: + client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [response.resource_name], + }, + metadata=metadata, + ) @pytest.mark.parametrize("transport", ["grpc", "rest"]) - def test_golden_user_style_resume_seekable( - self, generated_google_ads_module, transport - ): - """Test Case 5: Mid-Stream Interruption & Seekable Resumption.""" - client_cls = generated_google_ads_module.YouTubeVideoUploadServiceClient - client = client_cls( + def test_golden_user_style_resume_seekable(self, transport: str): + """Verify synchronous mid-stream interruption, session resumption, and cleanup. + + Args: + transport: Client transport name (`"grpc"` or `"rest"`). + """ + client = YouTubeVideoUploadServiceClient( credentials=_load_impersonated_credentials(), transport=transport, ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() stream, upload_size = _open_upload_file() chunk_size = 512 * 1024 # 512 KiB request_payload = { - "customer_id": _google_ads_customer_id(), + "customer_id": customer_id, "you_tube_video_upload": { - "video_title": "Test video", + "video_title": _generate_video_title(), "video_description": "Testing defaults", "video_privacy": "UNLISTED", }, } + response = None try: session1 = client.create_you_tube_video_upload( request=request_payload, config=ResumableUploadConfig(chunk_size=chunk_size), - metadata=_google_ads_metadata(), + metadata=metadata, ) with pytest.raises(_ClientPauseError) as exc_info: for progress in session1.iter_upload( @@ -450,7 +456,7 @@ def test_golden_user_style_resume_seekable( stream.seek(0) session2 = client.create_you_tube_video_upload( request=request_payload, - metadata=_google_ads_metadata(), + metadata=metadata, ) progress_records_2: List[UploadProgress] = [] for progress in session2.iter_resume( @@ -474,35 +480,54 @@ def test_golden_user_style_resume_seekable( assert progress_records_2[-1].bytes_uploaded == upload_size finally: stream.close() + if response is not None and response.resource_name: + client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [response.resource_name], + }, + metadata=metadata, + ) if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": - @pytest.mark.asyncio @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) async def test_async_upload_with_impersonated_credentials( - self, generated_google_ads_module, transport + self, transport: str ): - """Async Test Case 4: Complete Resumable Media Upload.""" - client_cls = ( - generated_google_ads_module.YouTubeVideoUploadServiceAsyncClient - ) - client = client_cls( + """Verify complete asynchronous resumable video upload and resource cleanup. + + Args: + transport: Async client transport name (`"grpc_asyncio"` or `"rest_asyncio"`). + """ + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in YouTubeVideoUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + client = YouTubeVideoUploadServiceAsyncClient( credentials=_load_impersonated_credentials(), transport=transport, ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() stream, upload_size = _open_upload_file() + response = None try: upload_session = await client.create_you_tube_video_upload( request={ - "customer_id": _google_ads_customer_id(), + "customer_id": customer_id, "you_tube_video_upload": { - "video_title": "Test video", + "video_title": _generate_video_title(), "video_description": "Testing defaults", "video_privacy": "UNLISTED", }, }, - metadata=_google_ads_metadata(), + metadata=metadata, ) progress_records: List[UploadProgress] = [] async for progress in upload_session.upload( @@ -522,35 +547,53 @@ async def test_async_upload_with_impersonated_credentials( assert progress_records[-1].bytes_uploaded == upload_size finally: stream.close() + if response is not None and response.resource_name: + await client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [response.resource_name], + }, + metadata=metadata, + ) @pytest.mark.asyncio @pytest.mark.parametrize("transport", ["grpc_asyncio", "rest_asyncio"]) - async def test_async_golden_user_style_resume_seekable( - self, generated_google_ads_module, transport - ): - """Async Test Case 5: Mid-Stream Interruption & Seekable Resumption.""" - client_cls = ( - generated_google_ads_module.YouTubeVideoUploadServiceAsyncClient - ) - client = client_cls( + async def test_async_golden_user_style_resume_seekable(self, transport: str): + """Verify asynchronous mid-stream interruption, session resumption, and cleanup. + + Args: + transport: Async client transport name (`"grpc_asyncio"` or `"rest_asyncio"`). + """ + if ( + transport == "rest_asyncio" + and "rest_asyncio" + not in YouTubeVideoUploadServiceClient._transport_registry + ): + pytest.skip( + "rest_asyncio transport is not registered when rest_async_io_enabled is False." + ) + client = YouTubeVideoUploadServiceAsyncClient( credentials=_load_impersonated_credentials(), transport=transport, ) + customer_id = _google_ads_customer_id() + metadata = _google_ads_metadata() stream, upload_size = _open_upload_file() chunk_size = 512 * 1024 # 512 KiB request_payload = { - "customer_id": _google_ads_customer_id(), + "customer_id": customer_id, "you_tube_video_upload": { - "video_title": "Test video", + "video_title": _generate_video_title(), "video_description": "Testing defaults", "video_privacy": "UNLISTED", }, } + response = None try: session1 = await client.create_you_tube_video_upload( request=request_payload, config=ResumableUploadConfig(chunk_size=chunk_size), - metadata=_google_ads_metadata(), + metadata=metadata, ) with pytest.raises(_ClientPauseError) as exc_info: async for progress in session1.upload( @@ -575,7 +618,7 @@ async def test_async_golden_user_style_resume_seekable( stream.seek(0) session2 = await client.create_you_tube_video_upload( request=request_payload, - metadata=_google_ads_metadata(), + metadata=metadata, ) progress_records_2: List[UploadProgress] = [] async for progress in session2.resume( @@ -599,3 +642,11 @@ async def test_async_golden_user_style_resume_seekable( assert progress_records_2[-1].bytes_uploaded == upload_size finally: stream.close() + if response is not None and response.resource_name: + await client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [response.resource_name], + }, + metadata=metadata, + ) From b19ea5add8dfc4716d0781712d0fc960a04ab7f8 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 16:14:56 +0000 Subject: [PATCH 3/7] fix build --- .kokoro/system.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/.kokoro/system.sh b/.kokoro/system.sh index 39ac1bae8233..d8a30a58e2fc 100755 --- a/.kokoro/system.sh +++ b/.kokoro/system.sh @@ -94,6 +94,7 @@ run_package_test() { NOX_FILE="noxfile.py" fi ;; + *) PROJECT_ID=$(cat "${KOKORO_GFILE_DIR}/project-id.json") GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" NOX_FILE="noxfile.py" From 209961cbcbdf1542865930d25192ea9e56b1704a Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 16:38:45 +0000 Subject: [PATCH 4/7] add dep --- packages/gapic-generator/noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gapic-generator/noxfile.py b/packages/gapic-generator/noxfile.py index 6856b5d9c974..a266d080ac93 100644 --- a/packages/gapic-generator/noxfile.py +++ b/packages/gapic-generator/noxfile.py @@ -982,7 +982,7 @@ def format(session): def google_ads_library(session): """Generate and install the Google Ads GAPIC library for live system tests.""" session.install("-e", ".") - session.install("grpcio-tools", "pyYAML") + session.install("grpcio-tools", "pyYAML", "pypandoc-binary==1.16.2") with tempfile.TemporaryDirectory() as tmp_dir: googleapis_dir = path.join(tmp_dir, "googleapis") From f019bad214a2109235aee51f834f4823d79a47b2 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 16:52:11 +0000 Subject: [PATCH 5/7] avoid skipping tests --- .../tests/system_live/test_google_ads_resumable_upload.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py index ab99936cb09a..39a88f9afcad 100644 --- a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py +++ b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py @@ -162,7 +162,7 @@ def _load_impersonated_credentials() -> impersonated_credentials.Credentials: """ target_principal = os.environ.get("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT", "") if not target_principal: - pytest.skip("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT is not set.") + pytest.fail("GOOGLE_ADS_TARGET_SERVICE_ACCOUNT is not set.") source_creds = _load_source_credentials() return impersonated_credentials.Credentials( source_credentials=source_creds, @@ -182,7 +182,7 @@ def _google_ads_metadata() -> Sequence[Tuple[str, str]]: developer_token = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN", "") login_customer_id = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "") if not developer_token or not login_customer_id: - pytest.skip( + pytest.fail( "GOOGLE_ADS_DEVELOPER_TOKEN and GOOGLE_ADS_LOGIN_CUSTOMER_ID are required." ) return ( @@ -203,7 +203,7 @@ def _google_ads_customer_id() -> str: os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID", ""), ) if not customer_id: - pytest.skip("GOOGLE_ADS_CUSTOMER_ID is required.") + pytest.fail("GOOGLE_ADS_CUSTOMER_ID is required.") return customer_id @@ -217,7 +217,7 @@ def _open_upload_file() -> Tuple[io.BufferedReader, int]: raw_path = os.environ.get("GOOGLE_ADS_VIDEO_PATH", "~/Downloads/video.mp4") expanded = Path(os.path.expanduser(raw_path)).resolve() if not expanded.is_file(): - pytest.skip(f"Test media file not found at {expanded}") + pytest.fail(f"Test media file not found at {expanded}") f = open(expanded, "rb") return f, expanded.stat().st_size From 97d16886337241ae3581ecf8be9bddaafeefc336 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 17:29:10 +0000 Subject: [PATCH 6/7] cater for videos that cannot be deleted which are still in uploaded state --- .../test_google_ads_resumable_upload.py | 116 ++++++++++++------ 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py index 39a88f9afcad..b1ae6a5f5e60 100644 --- a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py +++ b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py @@ -45,6 +45,7 @@ from google.auth import impersonated_credentials import google.auth.transport.requests from google.oauth2 import service_account +from google.api_core import exceptions as core_exceptions from google.api_core.resumable_transfer import ( ProgressState, ResumableUploadConfig, @@ -222,13 +223,72 @@ def _open_upload_file() -> Tuple[io.BufferedReader, int]: return f, expanded.stat().st_size +def _try_remove_upload( + client: YouTubeVideoUploadServiceClient, + customer_id: str, + resource_name: str, + metadata: Sequence[Tuple[str, str]], +) -> None: + """Attempt best-effort removal of a newly uploaded YouTube video resource. + + Immediately after upload completion (`ProgressState.FINALIZED`), the video + remains in `UPLOADED` state while YouTube processes the media stream, during + which `RemoveYouTubeVideoUpload` returns `INVALID_ARGUMENT` (`NOT_FOUND`). + Any upload still processing at teardown is removed by `_cleanup_stale_test_videos` + once processed and older than `STALE_UPLOAD_MAX_AGE`. + + Args: + client: Synchronous `YouTubeVideoUploadServiceClient` instance. + customer_id: Google Ads customer ID owning the upload resource. + resource_name: Resource name (`customers/{id}/youTubeVideoUploads/{id}`). + metadata: Request metadata headers (`developer-token`, `login-customer-id`). + """ + try: + client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [resource_name], + }, + metadata=metadata, + ) + except core_exceptions.GoogleAPICallError: + pass + + +async def _async_try_remove_upload( + client: YouTubeVideoUploadServiceAsyncClient, + customer_id: str, + resource_name: str, + metadata: Sequence[Tuple[str, str]], +) -> None: + """Attempt best-effort async removal of a newly uploaded YouTube video resource. + + Args: + client: Asynchronous `YouTubeVideoUploadServiceAsyncClient` instance. + customer_id: Google Ads customer ID owning the upload resource. + resource_name: Resource name (`customers/{id}/youTubeVideoUploads/{id}`). + metadata: Request metadata headers (`developer-token`, `login-customer-id`). + """ + try: + await client.remove_you_tube_video_upload( + request={ + "customer_id": customer_id, + "resource_names": [resource_name], + }, + metadata=metadata, + ) + except core_exceptions.GoogleAPICallError: + pass + + @pytest.fixture(scope="session", autouse=True) def _cleanup_stale_test_videos(): - """Query existing YouTube video uploads and remove prefixed test resources older than 2 hours. + """Query processed YouTube video uploads and remove test resources older than 2 hours. - Queries `you_tube_video_upload` via `googleAds:search`, inspects video titles - via YouTube oEmbed to identify test resources created with `VIDEO_TITLE_PREFIX`, - and deletes any resource whose embedded timestamp exceeds `STALE_UPLOAD_MAX_AGE`. + Queries `you_tube_video_upload` resources in `PROCESSED` state via + `googleAds:search`, inspects video titles via YouTube oEmbed to identify + test resources created with `VIDEO_TITLE_PREFIX`, and removes any resource + whose embedded timestamp exceeds `STALE_UPLOAD_MAX_AGE`. """ creds = _load_impersonated_credentials() customer_id = _google_ads_customer_id() @@ -252,7 +312,8 @@ def _cleanup_stale_test_videos(): "query": ( "SELECT you_tube_video_upload.resource_name, " "you_tube_video_upload.video_id " - "FROM you_tube_video_upload" + "FROM you_tube_video_upload " + "WHERE you_tube_video_upload.state = 'PROCESSED'" ) }, timeout=30, @@ -277,13 +338,8 @@ def _cleanup_stale_test_videos(): if stale_resource_names: client = YouTubeVideoUploadServiceClient(credentials=creds) - client.remove_you_tube_video_upload( - request={ - "customer_id": customer_id, - "resource_names": stale_resource_names, - }, - metadata=metadata, - ) + for resource_name in stale_resource_names: + _try_remove_upload(client, customer_id, resource_name, metadata) class TestGoogleAdsLiveAcceptance: @@ -395,12 +451,8 @@ def test_upload_with_impersonated_credentials(self, transport: str): finally: stream.close() if response is not None and response.resource_name: - client.remove_you_tube_video_upload( - request={ - "customer_id": customer_id, - "resource_names": [response.resource_name], - }, - metadata=metadata, + _try_remove_upload( + client, customer_id, response.resource_name, metadata ) @pytest.mark.parametrize("transport", ["grpc", "rest"]) @@ -481,12 +533,8 @@ def test_golden_user_style_resume_seekable(self, transport: str): finally: stream.close() if response is not None and response.resource_name: - client.remove_you_tube_video_upload( - request={ - "customer_id": customer_id, - "resource_names": [response.resource_name], - }, - metadata=metadata, + _try_remove_upload( + client, customer_id, response.resource_name, metadata ) if os.environ.get("GAPIC_PYTHON_ASYNC", "true") == "true": @@ -506,7 +554,7 @@ async def test_async_upload_with_impersonated_credentials( and "rest_asyncio" not in YouTubeVideoUploadServiceClient._transport_registry ): - pytest.skip( + pytest.fail( "rest_asyncio transport is not registered when rest_async_io_enabled is False." ) client = YouTubeVideoUploadServiceAsyncClient( @@ -548,12 +596,8 @@ async def test_async_upload_with_impersonated_credentials( finally: stream.close() if response is not None and response.resource_name: - await client.remove_you_tube_video_upload( - request={ - "customer_id": customer_id, - "resource_names": [response.resource_name], - }, - metadata=metadata, + await _async_try_remove_upload( + client, customer_id, response.resource_name, metadata ) @pytest.mark.asyncio @@ -569,7 +613,7 @@ async def test_async_golden_user_style_resume_seekable(self, transport: str): and "rest_asyncio" not in YouTubeVideoUploadServiceClient._transport_registry ): - pytest.skip( + pytest.fail( "rest_asyncio transport is not registered when rest_async_io_enabled is False." ) client = YouTubeVideoUploadServiceAsyncClient( @@ -643,10 +687,6 @@ async def test_async_golden_user_style_resume_seekable(self, transport: str): finally: stream.close() if response is not None and response.resource_name: - await client.remove_you_tube_video_upload( - request={ - "customer_id": customer_id, - "resource_names": [response.resource_name], - }, - metadata=metadata, + await _async_try_remove_upload( + client, customer_id, response.resource_name, metadata ) From 19c99a9773a2c81609f53406b57916d40ac5dee3 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Thu, 8 Oct 2026 17:57:38 +0000 Subject: [PATCH 7/7] remove logging --- .../tests/system_live/README.md | 6 ---- .../test_google_ads_resumable_upload.py | 28 ------------------- 2 files changed, 34 deletions(-) diff --git a/packages/gapic-generator/tests/system_live/README.md b/packages/gapic-generator/tests/system_live/README.md index 398b333de74a..bf36d1e8530e 100644 --- a/packages/gapic-generator/tests/system_live/README.md +++ b/packages/gapic-generator/tests/system_live/README.md @@ -39,9 +39,3 @@ From `packages/gapic-generator`, run the [`system`](../../noxfile.py) `nox` sess cd packages/gapic-generator nox -s system-3.14 ``` - -### Diagnostic Logs -During execution, debug logs for each test case are captured automatically under: -```text -/tmp/googleads_acceptance__.log -``` diff --git a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py index b1ae6a5f5e60..c97e67292cc4 100644 --- a/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py +++ b/packages/gapic-generator/tests/system_live/test_google_ads_resumable_upload.py @@ -31,7 +31,6 @@ from datetime import datetime, timedelta, timezone import io -import logging import os from pathlib import Path import re @@ -354,33 +353,6 @@ def _disable_mtls(self, monkeypatch): """ monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false") - @pytest.fixture(autouse=True) - def _capture_diagnostic_trace(self, request): - """Capture debug log output during each test and persist diagnostic traces to `/tmp`. - - Args: - request: Pytest `FixtureRequest` object providing the current test node name. - """ - log_buffer = io.StringIO() - handler = logging.StreamHandler(log_buffer) - handler.setLevel(logging.DEBUG) - handler.setFormatter( - logging.Formatter("[%(asctime)s] %(levelname)s: %(message)s") - ) - root_logger = logging.getLogger() - prev_level = root_logger.level - root_logger.setLevel(logging.DEBUG) - root_logger.addHandler(handler) - yield - root_logger.removeHandler(handler) - root_logger.setLevel(prev_level) - contents = log_buffer.getvalue() - if contents: - safe_name = re.sub(r"[^a-zA-Z0-9_-]+", "_", request.node.name) - ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - log_path = Path(f"/tmp/googleads_acceptance_{safe_name}_{ts}.log") - log_path.write_text(contents, encoding="utf-8") - def test_adc_source_credentials_are_service_account(self): """Verify ADC source credentials load as a service account matching configuration.""" creds = _load_source_credentials()