From 40470a664b1178e6340fe4c75a567b3e53889ff7 Mon Sep 17 00:00:00 2001 From: Suvigya Jain <42478893+suvigyajain0101@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:11:34 +0000 Subject: [PATCH 01/13] fix(speech): add streaming input limit for speech transcription example (#13301) * Enforce 25Kb limit for infinite transcription Current implementation breaks when a new stream is created, even under 5 min limit. This is due to the missing logic to handle 25KB stream size limit [1] Updated the 'generator' function to yield data as soon as API limit is reached. [1] - https://github.com/GoogleCloudPlatform/python-docs-samples/issues/12053 * nit: Update speech/microphone/transcribe_streaming_infinite_v2.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Jennifer Davis Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../transcribe_streaming_infinite_v2.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/speech/microphone/transcribe_streaming_infinite_v2.py b/speech/microphone/transcribe_streaming_infinite_v2.py index 54b48439131..4ad6527a56b 100644 --- a/speech/microphone/transcribe_streaming_infinite_v2.py +++ b/speech/microphone/transcribe_streaming_infinite_v2.py @@ -40,6 +40,8 @@ STREAMING_LIMIT = 240000 # 4 minutes SAMPLE_RATE = 16000 CHUNK_SIZE = int(SAMPLE_RATE / 10) # 100ms +# 25KB API limit for streaming requests. Exceeding this limit will result in an error. +MAX_STREAMING_CHUNK = 25 * 1024 RED = "\033[0;31m" GREEN = "\033[0;32m" @@ -213,7 +215,23 @@ def generator(self: object) -> object: except queue.Empty: break - yield b"".join(data) + # Enforce max streaming chunk size supported by the API + combined_size = sum(len(chunk) for chunk in data) + if combined_size <= MAX_STREAMING_CHUNK: + yield b"".join(data) + else: + run_chunks = [] + run_size = 0 + for chunk in data: + if len(chunk) + run_size > MAX_STREAMING_CHUNK: + yield b"".join(run_chunks) + run_chunks = [chunk] + run_size = len(chunk) + else: + run_chunks.append(chunk) + run_size += len(chunk) + if run_chunks: + yield b"".join(run_chunks) def listen_print_loop(responses: object, stream: object) -> None: From 930ad03a94484353ecd5d705e5986c76f492ed32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:15:43 +1100 Subject: [PATCH 02/13] chore(deps): bump fastmcp from 2.8.0 to 2.13.0 in /run/mcp-server (#13625) Bumps [fastmcp](https://github.com/jlowin/fastmcp) from 2.8.0 to 2.13.0. - [Release notes](https://github.com/jlowin/fastmcp/releases) - [Changelog](https://github.com/jlowin/fastmcp/blob/main/docs/changelog.mdx) - [Commits](https://github.com/jlowin/fastmcp/compare/v2.8.0...v2.13.0) --- updated-dependencies: - dependency-name: fastmcp dependency-version: 2.13.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 19d89b8c9e3cb014329e747b8fd05aef79c0d833 Mon Sep 17 00:00:00 2001 From: Katie McLaughlin Date: Tue, 11 Nov 2025 11:57:41 +1100 Subject: [PATCH 03/13] ci(pubsublite): disable tests pending deprecation (#13629) --- pubsublite/spark-connector/spark_streaming_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pubsublite/spark-connector/spark_streaming_test.py b/pubsublite/spark-connector/spark_streaming_test.py index 198cc35bd06..265abaa9ebc 100644 --- a/pubsublite/spark-connector/spark_streaming_test.py +++ b/pubsublite/spark-connector/spark_streaming_test.py @@ -176,6 +176,7 @@ def pyfile(source_file: str) -> str: return "gs://" + blob.bucket.name + "/" + blob.name +@pytest.mark.skip("PubSubLite is deprecated, and will be turned down March 18, 2026.") def test_spark_streaming_to_pubsublite( topic: Topic, dataproc_cluster: dataproc_v1.Cluster ) -> None: @@ -229,6 +230,7 @@ def test_spark_streaming_to_pubsublite( assert "Committed 1 messages for epochId" in output +@pytest.mark.skip("PubSubLite is deprecated, and will be turned down March 18, 2026.") def test_spark_streaming_from_pubsublite( subscription: Subscription, dataproc_cluster: dataproc_v1.Cluster ) -> None: From c4187f6fb283efb84b46cb234c923aa48d10091c Mon Sep 17 00:00:00 2001 From: Katie McLaughlin Date: Tue, 11 Nov 2025 11:58:36 +1100 Subject: [PATCH 04/13] fix(appengine): update all app engine samples to latest Python (#13612) * fix(appengine): update all app engine samples to latest Python Python 3.9 is EOL, new deployments will error, with the message saying to update to the latest Python runtime. b/452455170 * cgi dropped in py313, make this sample use py312 for now * cloudsql binary not installing on py313, try latest version just in case * bump sqlalchemy to avoid import typing error * mail package also uses cgi, pull back to py312 --- appengine/standard_python3/bigquery/app.yaml | 2 +- .../building-an-app/building-an-app-2/app.yaml | 2 +- .../building-an-app/building-an-app-3/app.yaml | 2 +- .../building-an-app/building-an-app-4/app.yaml | 2 +- .../bundled-services/blobstore/django/app.yaml | 2 +- .../bundled-services/blobstore/flask/app.yaml | 2 +- .../standard_python3/bundled-services/blobstore/wsgi/app.yaml | 2 +- .../bundled-services/deferred/django/app.yaml | 2 +- .../standard_python3/bundled-services/deferred/flask/app.yaml | 2 +- .../standard_python3/bundled-services/deferred/wsgi/app.yaml | 2 +- .../standard_python3/bundled-services/mail/django/app.yaml | 2 +- .../standard_python3/bundled-services/mail/flask/app.yaml | 2 +- .../standard_python3/bundled-services/mail/wsgi/app.yaml | 2 +- appengine/standard_python3/cloudsql/app.yaml | 2 +- appengine/standard_python3/cloudsql/requirements.txt | 4 ++-- appengine/standard_python3/custom-server/app.yaml | 2 +- appengine/standard_python3/django/app.yaml | 2 +- appengine/standard_python3/migration/urlfetch/app.yaml | 2 +- appengine/standard_python3/pubsub/app.yaml | 2 +- appengine/standard_python3/redis/app.yaml | 2 +- appengine/standard_python3/spanner/app.yaml | 2 +- appengine/standard_python3/warmup/app.yaml | 2 +- 22 files changed, 23 insertions(+), 23 deletions(-) diff --git a/appengine/standard_python3/bigquery/app.yaml b/appengine/standard_python3/bigquery/app.yaml index 83c91f5b872..472f1f0c034 100644 --- a/appengine/standard_python3/bigquery/app.yaml +++ b/appengine/standard_python3/bigquery/app.yaml @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 diff --git a/appengine/standard_python3/building-an-app/building-an-app-2/app.yaml b/appengine/standard_python3/building-an-app/building-an-app-2/app.yaml index a0931a8a5d9..100d540982b 100644 --- a/appengine/standard_python3/building-an-app/building-an-app-2/app.yaml +++ b/appengine/standard_python3/building-an-app/building-an-app-2/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 handlers: # This configures Google App Engine to serve the files in the app's static diff --git a/appengine/standard_python3/building-an-app/building-an-app-3/app.yaml b/appengine/standard_python3/building-an-app/building-an-app-3/app.yaml index a0931a8a5d9..100d540982b 100644 --- a/appengine/standard_python3/building-an-app/building-an-app-3/app.yaml +++ b/appengine/standard_python3/building-an-app/building-an-app-3/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 handlers: # This configures Google App Engine to serve the files in the app's static diff --git a/appengine/standard_python3/building-an-app/building-an-app-4/app.yaml b/appengine/standard_python3/building-an-app/building-an-app-4/app.yaml index a0931a8a5d9..100d540982b 100644 --- a/appengine/standard_python3/building-an-app/building-an-app-4/app.yaml +++ b/appengine/standard_python3/building-an-app/building-an-app-4/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 handlers: # This configures Google App Engine to serve the files in the app's static diff --git a/appengine/standard_python3/bundled-services/blobstore/django/app.yaml b/appengine/standard_python3/bundled-services/blobstore/django/app.yaml index 96e1c924ee3..6994339e157 100644 --- a/appengine/standard_python3/bundled-services/blobstore/django/app.yaml +++ b/appengine/standard_python3/bundled-services/blobstore/django/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true handlers: diff --git a/appengine/standard_python3/bundled-services/blobstore/flask/app.yaml b/appengine/standard_python3/bundled-services/blobstore/flask/app.yaml index 96e1c924ee3..6994339e157 100644 --- a/appengine/standard_python3/bundled-services/blobstore/flask/app.yaml +++ b/appengine/standard_python3/bundled-services/blobstore/flask/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true handlers: diff --git a/appengine/standard_python3/bundled-services/blobstore/wsgi/app.yaml b/appengine/standard_python3/bundled-services/blobstore/wsgi/app.yaml index 96e1c924ee3..6994339e157 100644 --- a/appengine/standard_python3/bundled-services/blobstore/wsgi/app.yaml +++ b/appengine/standard_python3/bundled-services/blobstore/wsgi/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true handlers: diff --git a/appengine/standard_python3/bundled-services/deferred/django/app.yaml b/appengine/standard_python3/bundled-services/deferred/django/app.yaml index 84314e1d25b..c2226a56b67 100644 --- a/appengine/standard_python3/bundled-services/deferred/django/app.yaml +++ b/appengine/standard_python3/bundled-services/deferred/django/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true env_variables: NDB_USE_CROSS_COMPATIBLE_PICKLE_PROTOCOL: "True" diff --git a/appengine/standard_python3/bundled-services/deferred/flask/app.yaml b/appengine/standard_python3/bundled-services/deferred/flask/app.yaml index 84314e1d25b..c2226a56b67 100644 --- a/appengine/standard_python3/bundled-services/deferred/flask/app.yaml +++ b/appengine/standard_python3/bundled-services/deferred/flask/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true env_variables: NDB_USE_CROSS_COMPATIBLE_PICKLE_PROTOCOL: "True" diff --git a/appengine/standard_python3/bundled-services/deferred/wsgi/app.yaml b/appengine/standard_python3/bundled-services/deferred/wsgi/app.yaml index 84314e1d25b..c2226a56b67 100644 --- a/appengine/standard_python3/bundled-services/deferred/wsgi/app.yaml +++ b/appengine/standard_python3/bundled-services/deferred/wsgi/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true env_variables: NDB_USE_CROSS_COMPATIBLE_PICKLE_PROTOCOL: "True" diff --git a/appengine/standard_python3/bundled-services/mail/django/app.yaml b/appengine/standard_python3/bundled-services/mail/django/app.yaml index ff79a69182c..902fe897910 100644 --- a/appengine/standard_python3/bundled-services/mail/django/app.yaml +++ b/appengine/standard_python3/bundled-services/mail/django/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 app_engine_apis: true inbound_services: diff --git a/appengine/standard_python3/bundled-services/mail/flask/app.yaml b/appengine/standard_python3/bundled-services/mail/flask/app.yaml index ff79a69182c..79f6d993358 100644 --- a/appengine/standard_python3/bundled-services/mail/flask/app.yaml +++ b/appengine/standard_python3/bundled-services/mail/flask/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python312 app_engine_apis: true inbound_services: diff --git a/appengine/standard_python3/bundled-services/mail/wsgi/app.yaml b/appengine/standard_python3/bundled-services/mail/wsgi/app.yaml index ff79a69182c..79f6d993358 100644 --- a/appengine/standard_python3/bundled-services/mail/wsgi/app.yaml +++ b/appengine/standard_python3/bundled-services/mail/wsgi/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python312 app_engine_apis: true inbound_services: diff --git a/appengine/standard_python3/cloudsql/app.yaml b/appengine/standard_python3/cloudsql/app.yaml index 496b60f231b..dfb14663846 100644 --- a/appengine/standard_python3/cloudsql/app.yaml +++ b/appengine/standard_python3/cloudsql/app.yaml @@ -14,7 +14,7 @@ # [START gae_python38_cloudsql_config] # [START gae_python3_cloudsql_config] -runtime: python39 +runtime: python313 env_variables: CLOUD_SQL_USERNAME: YOUR-USERNAME diff --git a/appengine/standard_python3/cloudsql/requirements.txt b/appengine/standard_python3/cloudsql/requirements.txt index 7ca534fe2e0..7fe39c1a1b2 100644 --- a/appengine/standard_python3/cloudsql/requirements.txt +++ b/appengine/standard_python3/cloudsql/requirements.txt @@ -1,6 +1,6 @@ flask==3.0.0 # psycopg2==2.8.4 # you will need either the binary or the regular - for more info see http://initd.org/psycopg/docs/install.html -psycopg2-binary==2.9.9 +psycopg2-binary==2.9.11 PyMySQL==1.1.1 -SQLAlchemy==2.0.10 \ No newline at end of file +SQLAlchemy==2.0.44 diff --git a/appengine/standard_python3/custom-server/app.yaml b/appengine/standard_python3/custom-server/app.yaml index ff2f64b2b26..b67aef4f96e 100644 --- a/appengine/standard_python3/custom-server/app.yaml +++ b/appengine/standard_python3/custom-server/app.yaml @@ -14,7 +14,7 @@ # [START gae_python38_custom_runtime] # [START gae_python3_custom_runtime] -runtime: python39 +runtime: python313 entrypoint: uwsgi --http-socket :$PORT --wsgi-file main.py --callable app --master --processes 1 --threads 2 # [END gae_python3_custom_runtime] # [END gae_python38_custom_runtime] diff --git a/appengine/standard_python3/django/app.yaml b/appengine/standard_python3/django/app.yaml index 5a7255118c8..ddf86e23823 100644 --- a/appengine/standard_python3/django/app.yaml +++ b/appengine/standard_python3/django/app.yaml @@ -15,7 +15,7 @@ # # [START gaestd_py_django_app_yaml] -runtime: python39 +runtime: python313 env_variables: # This setting is used in settings.py to configure your ALLOWED_HOSTS diff --git a/appengine/standard_python3/migration/urlfetch/app.yaml b/appengine/standard_python3/migration/urlfetch/app.yaml index dd75aa47c69..3aa9d9d2207 100644 --- a/appengine/standard_python3/migration/urlfetch/app.yaml +++ b/appengine/standard_python3/migration/urlfetch/app.yaml @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 diff --git a/appengine/standard_python3/pubsub/app.yaml b/appengine/standard_python3/pubsub/app.yaml index 9e3e948e4db..3c36b4bfb3c 100644 --- a/appengine/standard_python3/pubsub/app.yaml +++ b/appengine/standard_python3/pubsub/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 # [START gae_standard_pubsub_env] env_variables: diff --git a/appengine/standard_python3/redis/app.yaml b/appengine/standard_python3/redis/app.yaml index 2797ed154f7..138895c3737 100644 --- a/appengine/standard_python3/redis/app.yaml +++ b/appengine/standard_python3/redis/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 env_variables: REDIS_HOST: your-redis-host diff --git a/appengine/standard_python3/spanner/app.yaml b/appengine/standard_python3/spanner/app.yaml index a4e3167ec08..59a31baca33 100644 --- a/appengine/standard_python3/spanner/app.yaml +++ b/appengine/standard_python3/spanner/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 env_variables: SPANNER_INSTANCE: "YOUR-SPANNER-INSTANCE-ID" diff --git a/appengine/standard_python3/warmup/app.yaml b/appengine/standard_python3/warmup/app.yaml index fdda19a79b1..3cc59533b01 100644 --- a/appengine/standard_python3/warmup/app.yaml +++ b/appengine/standard_python3/warmup/app.yaml @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: python39 +runtime: python313 inbound_services: - warmup From 4cd0939c6ced225edac13b488ebeaa78cfa429e9 Mon Sep 17 00:00:00 2001 From: Robert Kozak <50328216+Guiners@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:41:51 +0100 Subject: [PATCH 05/13] feat(genai): Adding live samples (#13598) * Adding samples * Live samples updates * adding mocking * changing region in test_live_conversation_audio_with_audio * removing rag memory corpus * mocking live_conversation_audio_with_audio * fixing mock live_conversation_audio_with_audio --------- Co-authored-by: Guiners --- genai/live/live_audio_with_txt.py | 85 ++++++++ .../live_conversation_audio_with_audio.py | 133 ++++++++++++ genai/live/live_ground_ragengine_with_txt.py | 63 ++++++ ....py => live_structured_output_with_txt.py} | 6 +- genai/live/live_txt_with_audio.py | 72 +++++++ genai/live/requirements-test.txt | 1 + genai/live/requirements.txt | 5 +- genai/live/test_live_examples.py | 189 +++++++++++++++++- .../test_text_generation_examples.py | 5 + .../text_generation/textgen_code_with_pdf.py | 55 +++++ 10 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 genai/live/live_audio_with_txt.py create mode 100644 genai/live/live_conversation_audio_with_audio.py create mode 100644 genai/live/live_ground_ragengine_with_txt.py rename genai/live/{live_structured_ouput_with_txt.py => live_structured_output_with_txt.py} (95%) create mode 100644 genai/live/live_txt_with_audio.py create mode 100644 genai/text_generation/textgen_code_with_pdf.py diff --git a/genai/live/live_audio_with_txt.py b/genai/live/live_audio_with_txt.py new file mode 100644 index 00000000000..5d4e82cef85 --- /dev/null +++ b/genai/live/live_audio_with_txt.py @@ -0,0 +1,85 @@ +# Copyright 2025 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. + +# Test file: https://storage.googleapis.com/generativeai-downloads/data/16000.wav +# Install helpers for converting files: pip install librosa soundfile simpleaudio + +import asyncio + + +async def generate_content() -> list: + # [START googlegenaisdk_live_audio_with_txt] + from google import genai + from google.genai.types import ( + Content, LiveConnectConfig, Modality, Part, + PrebuiltVoiceConfig, SpeechConfig, VoiceConfig + ) + import numpy as np + import soundfile as sf + import simpleaudio as sa + + def play_audio(audio_array: np.ndarray, sample_rate: int = 24000) -> None: + sf.write("output.wav", audio_array, sample_rate) + wave_obj = sa.WaveObject.from_wave_file("output.wav") + play_obj = wave_obj.play() + play_obj.wait_done() + + client = genai.Client() + voice_name = "Aoede" + model = "gemini-2.0-flash-live-preview-04-09" + + config = LiveConnectConfig( + response_modalities=[Modality.AUDIO], + speech_config=SpeechConfig( + voice_config=VoiceConfig( + prebuilt_voice_config=PrebuiltVoiceConfig( + voice_name=voice_name, + ) + ), + ), + ) + + async with client.aio.live.connect( + model=model, + config=config, + ) as session: + text_input = "Hello? Gemini are you there?" + print("> ", text_input, "\n") + + await session.send_client_content( + turns=Content(role="user", parts=[Part(text=text_input)]) + ) + + audio_data = [] + async for message in session.receive(): + if ( + message.server_content.model_turn + and message.server_content.model_turn.parts + ): + for part in message.server_content.model_turn.parts: + if part.inline_data: + audio_data.append( + np.frombuffer(part.inline_data.data, dtype=np.int16) + ) + + if audio_data: + print("Received audio answer: ") + play_audio(np.concatenate(audio_data), sample_rate=24000) + + # [END googlegenaisdk_live_audio_with_txt] + return [] + + +if __name__ == "__main__": + asyncio.run(generate_content()) diff --git a/genai/live/live_conversation_audio_with_audio.py b/genai/live/live_conversation_audio_with_audio.py new file mode 100644 index 00000000000..fb39dc36615 --- /dev/null +++ b/genai/live/live_conversation_audio_with_audio.py @@ -0,0 +1,133 @@ +# Copyright 2025 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. + +# [START googlegenaisdk_live_conversation_audio_with_audio] + +import asyncio +import base64 + +from google import genai +from google.genai.types import ( + AudioTranscriptionConfig, + Blob, + HttpOptions, + LiveConnectConfig, + Modality, +) +import numpy as np + +from scipy.io import wavfile + +# The number of audio frames to send in each chunk. +CHUNK = 4200 +CHANNELS = 1 +MODEL = "gemini-live-2.5-flash-preview-native-audio-09-2025" + +# The audio sample rate expected by the model. +INPUT_RATE = 16000 +# The audio sample rate of the audio generated by the model. +OUTPUT_RATE = 24000 + +# The sample width for 16-bit audio, which is standard for this type of audio data. +SAMPLE_WIDTH = 2 + +client = genai.Client(http_options=HttpOptions(api_version="v1beta1"), location="us-central1") + + +def read_wavefile(filepath: str) -> tuple[str, str]: + # Read the .wav file using scipy.io.wavfile.read + rate, data = wavfile.read(filepath) + # Convert the NumPy array of audio samples back to raw bytes + raw_audio_bytes = data.tobytes() + # Encode the raw bytes to a base64 string. + # The result needs to be decoded from bytes to a UTF-8 string + base64_encoded_data = base64.b64encode(raw_audio_bytes).decode("ascii") + mime_type = f"audio/pcm;rate={rate}" + return base64_encoded_data, mime_type + + +def write_wavefile(filepath: str, audio_frames: list[bytes], rate: int) -> None: + """Writes a list of audio byte frames to a WAV file using scipy.""" + # Combine the list of byte frames into a single byte string + raw_audio_bytes = b"".join(audio_frames) + + # Convert the raw bytes to a NumPy array. + # The sample width is 2 bytes (16-bit), so we use np.int16 + audio_data = np.frombuffer(raw_audio_bytes, dtype=np.int16) + + # Write the NumPy array to a .wav file + wavfile.write(filepath, rate, audio_data) + print(f"Model response saved to {filepath}") + + +async def main() -> bool: + print("Starting the code") + + async with client.aio.live.connect( + model=MODEL, + config=LiveConnectConfig( + # Set Model responses to be in Audio + response_modalities=[Modality.AUDIO], + # To generate transcript for input audio + input_audio_transcription=AudioTranscriptionConfig(), + # To generate transcript for output audio + output_audio_transcription=AudioTranscriptionConfig(), + ), + ) as session: + + async def send() -> None: + # using local file as an example for live audio input + wav_file_path = "hello_gemini_are_you_there.wav" + base64_data, mime_type = read_wavefile(wav_file_path) + audio_bytes = base64.b64decode(base64_data) + await session.send_realtime_input(media=Blob(data=audio_bytes, mime_type=mime_type)) + + async def receive() -> None: + audio_frames = [] + + async for message in session.receive(): + if message.server_content.input_transcription: + print(message.server_content.model_dump(mode="json", exclude_none=True)) + if message.server_content.output_transcription: + print(message.server_content.model_dump(mode="json", exclude_none=True)) + if message.server_content.model_turn: + for part in message.server_content.model_turn.parts: + if part.inline_data.data: + audio_data = part.inline_data.data + audio_frames.append(audio_data) + + if audio_frames: + write_wavefile( + "example_model_response.wav", + audio_frames, + OUTPUT_RATE, + ) + + send_task = asyncio.create_task(send()) + receive_task = asyncio.create_task(receive()) + await asyncio.gather(send_task, receive_task) + # Example response: + # gemini-2.0-flash-live-preview-04-09 + # {'input_transcription': {'text': 'Hello.'}} + # {'output_transcription': {}} + # {'output_transcription': {'text': 'Hi'}} + # {'output_transcription': {'text': ' there. What can I do for you today?'}} + # {'output_transcription': {'finished': True}} + # Model response saved to example_model_response.wav + +# [END googlegenaisdk_live_conversation_audio_with_audio] + return True + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/genai/live/live_ground_ragengine_with_txt.py b/genai/live/live_ground_ragengine_with_txt.py new file mode 100644 index 00000000000..09b133ad7cf --- /dev/null +++ b/genai/live/live_ground_ragengine_with_txt.py @@ -0,0 +1,63 @@ +# Copyright 2025 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 asyncio + + +async def generate_content(memory_corpus: str) -> list[str]: + # [START googlegenaisdk_live_ground_ragengine_with_txt] + from google import genai + from google.genai.types import (Content, LiveConnectConfig, Modality, Part, + Retrieval, Tool, VertexRagStore, + VertexRagStoreRagResource) + + client = genai.Client() + model_id = "gemini-2.0-flash-live-preview-04-09" + rag_store = VertexRagStore( + rag_resources=[ + VertexRagStoreRagResource( + rag_corpus=memory_corpus # Use memory corpus if you want to store context. + ) + ], + # Set `store_context` to true to allow Live API sink context into your memory corpus. + store_context=True, + ) + config = LiveConnectConfig( + response_modalities=[Modality.TEXT], + tools=[Tool(retrieval=Retrieval(vertex_rag_store=rag_store))], + ) + + async with client.aio.live.connect(model=model_id, config=config) as session: + text_input = "What are newest gemini models?" + print("> ", text_input, "\n") + + await session.send_client_content( + turns=Content(role="user", parts=[Part(text=text_input)]) + ) + + response = [] + + async for message in session.receive(): + if message.text: + response.append(message.text) + + print("".join(response)) + # Example output: + # > What are newest gemini models? + # In December 2023, Google launched Gemini, their "most capable and general model". It's multimodal, meaning it understands and combines different types of information like text, code, audio, images, and video. + # [END googlegenaisdk_live_ground_ragengine_with_txt] + return response + + +if __name__ == "__main__": + asyncio.run(generate_content("test_memory_corpus")) diff --git a/genai/live/live_structured_ouput_with_txt.py b/genai/live/live_structured_output_with_txt.py similarity index 95% rename from genai/live/live_structured_ouput_with_txt.py rename to genai/live/live_structured_output_with_txt.py index 4f62366eb08..b743c87f064 100644 --- a/genai/live/live_structured_ouput_with_txt.py +++ b/genai/live/live_structured_output_with_txt.py @@ -24,7 +24,7 @@ class CalendarEvent(BaseModel): def generate_content() -> CalendarEvent: - # [START googlegenaisdk_live_structured_ouput_with_txt] + # [START googlegenaisdk_live_structured_output_with_txt] import os import google.auth.transport.requests @@ -78,8 +78,8 @@ def generate_content() -> CalendarEvent: # System message: Extract the event information. # User message: Alice and Bob are going to a science fair on Friday. # Output message: name='science fair' date='Friday' participants=['Alice', 'Bob'] - # [END googlegenaisdk_live_structured_ouput_with_txt] - return True + # [END googlegenaisdk_live_structured_output_with_txt] + return response if __name__ == "__main__": diff --git a/genai/live/live_txt_with_audio.py b/genai/live/live_txt_with_audio.py new file mode 100644 index 00000000000..30e9004d76f --- /dev/null +++ b/genai/live/live_txt_with_audio.py @@ -0,0 +1,72 @@ +# Copyright 2025 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. + + +# Test file: https://storage.googleapis.com/generativeai-downloads/data/16000.wav +# Install helpers for converting files: pip install librosa soundfile + +import asyncio + + +async def generate_content() -> list[str]: + # [START googlegenaisdk_live_txt_with_audio] + import io + + import librosa + import requests + import soundfile as sf + from google import genai + from google.genai.types import Blob, LiveConnectConfig, Modality + + client = genai.Client() + model = "gemini-2.0-flash-live-preview-04-09" + config = LiveConnectConfig(response_modalities=[Modality.TEXT]) + + async with client.aio.live.connect(model=model, config=config) as session: + audio_url = ( + "https://storage.googleapis.com/generativeai-downloads/data/16000.wav" + ) + response = requests.get(audio_url) + response.raise_for_status() + buffer = io.BytesIO(response.content) + y, sr = librosa.load(buffer, sr=16000) + sf.write(buffer, y, sr, format="RAW", subtype="PCM_16") + buffer.seek(0) + audio_bytes = buffer.read() + + # If you've pre-converted to sample.pcm using ffmpeg, use this instead: + # audio_bytes = Path("sample.pcm").read_bytes() + + print("> Answer to this audio url", audio_url, "\n") + + await session.send_realtime_input( + media=Blob(data=audio_bytes, mime_type="audio/pcm;rate=16000") + ) + + response = [] + + async for message in session.receive(): + if message.text is not None: + response.append(message.text) + + print("".join(response)) + # Example output: + # > Answer to this audio url https://storage.googleapis.com/generativeai-downloads/data/16000.wav + # Yes, I can hear you. How can I help you today? + # [END googlegenaisdk_live_txt_with_audio] + return response + + +if __name__ == "__main__": + asyncio.run(generate_content()) diff --git a/genai/live/requirements-test.txt b/genai/live/requirements-test.txt index 1b59fd9d249..7d5998c481d 100644 --- a/genai/live/requirements-test.txt +++ b/genai/live/requirements-test.txt @@ -2,3 +2,4 @@ backoff==2.2.1 google-api-core==2.25.1 pytest==8.4.1 pytest-asyncio==1.1.0 +pytest-mock==3.14.0 \ No newline at end of file diff --git a/genai/live/requirements.txt b/genai/live/requirements.txt index deb05acdd5e..ee7f068754b 100644 --- a/genai/live/requirements.txt +++ b/genai/live/requirements.txt @@ -4,4 +4,7 @@ websockets==15.0.1 numpy==1.26.4 soundfile==0.12.1 openai==1.99.1 -setuptools==80.9.0 \ No newline at end of file +setuptools==80.9.0 +pyaudio==0.2.14 +librosa==0.11.0 +simpleaudio==1.0.0 \ No newline at end of file diff --git a/genai/live/test_live_examples.py b/genai/live/test_live_examples.py index f6e3bce4fb4..ffb0f10c689 100644 --- a/genai/live/test_live_examples.py +++ b/genai/live/test_live_examples.py @@ -15,38 +15,181 @@ # # Using Google Cloud Vertex AI to test the code samples. # - +import base64 import os +import sys +import types + +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import pytest_mock +import live_audio_with_txt import live_audiogen_with_txt import live_code_exec_with_txt import live_func_call_with_txt import live_ground_googsearch_with_txt -import live_structured_ouput_with_txt +import live_ground_ragengine_with_txt +import live_structured_output_with_txt import live_transcribe_with_audio +import live_txt_with_audio import live_txtgen_with_audio import live_websocket_audiogen_with_txt import live_websocket_audiotranscript_with_txt -import live_websocket_textgen_with_audio +# import live_websocket_textgen_with_audio import live_websocket_textgen_with_txt import live_with_txt + os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "True" os.environ["GOOGLE_CLOUD_LOCATION"] = "us-central1" # The project name is included in the CICD pipeline # os.environ['GOOGLE_CLOUD_PROJECT'] = "add-your-project-name" +@pytest.fixture +def mock_live_session() -> tuple[MagicMock, MagicMock]: + async def async_gen(items: list) -> AsyncMock: + for i in items: + yield i + + mock_session = MagicMock() + mock_session.__aenter__.return_value = mock_session + mock_session.send_client_content = AsyncMock() + mock_session.send = AsyncMock() + mock_session.receive = lambda: async_gen([]) + + mock_client = MagicMock() + mock_client.aio.live.connect.return_value = mock_session + + return mock_client, mock_session + + +@pytest.fixture() +def mock_rag_components(mocker: pytest_mock.MockerFixture) -> None: + mock_client_cls = mocker.patch("google.genai.Client") + + class AsyncIterator: + def __init__(self) -> None: + self.used = False + + def __aiter__(self) -> "AsyncIterator": + return self + + async def __anext__(self) -> object: + if not self.used: + self.used = True + return mocker.MagicMock( + text="""In December 2023, Google launched Gemini, their "most capable and general model". It's multimodal, meaning it understands and combines different types of information like text, code, audio, images, and video.""" + ) + raise StopAsyncIteration + + mock_session = mocker.AsyncMock() + mock_session.__aenter__.return_value = mock_session + mock_session.receive = lambda: AsyncIterator() + mock_client_cls.return_value.aio.live.connect.return_value = mock_session + + +@pytest.fixture() +def live_conversation() -> None: + google_mod = types.ModuleType("google") + genai_mod = types.ModuleType("google.genai") + genai_types_mod = types.ModuleType("google.genai.types") + + class AudioTranscriptionConfig: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + class Blob: + def __init__(self, data: bytes, mime_type: str) -> None: + self.data = data + self.mime_type = mime_type + + class HttpOptions: + def __init__(self, api_version: str | None = None) -> None: + self.api_version = api_version + + class LiveConnectConfig: + def __init__(self, *args: object, **kwargs: object) -> None: + self.kwargs = kwargs + + class Modality: + AUDIO = "AUDIO" + + genai_types_mod.AudioTranscriptionConfig = AudioTranscriptionConfig + genai_types_mod.Blob = Blob + genai_types_mod.HttpOptions = HttpOptions + genai_types_mod.LiveConnectConfig = LiveConnectConfig + genai_types_mod.Modality = Modality + + class FakeSession: + async def __aenter__(self) -> "FakeSession": + print("MOCK: entering FakeSession") + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: types.TracebackType | None, + ) -> None: + print("MOCK: exiting FakeSession") + + async def send_realtime_input(self, media: object) -> None: + print("MOCK: send_realtime_input called (no network)") + + async def receive(self) -> object: + print("MOCK: receive started") + if False: + yield + + class FakeClient: + def __init__(self, *args: object, **kwargs: object) -> None: + self.aio = MagicMock() + self.aio.live = MagicMock() + self.aio.live.connect = MagicMock(return_value=FakeSession()) + print("MOCK: FakeClient created") + + def fake_client_constructor(*args: object, **kwargs: object) -> FakeClient: + return FakeClient() + + genai_mod.Client = fake_client_constructor + genai_mod.types = genai_types_mod + + old_modules = sys.modules.copy() + + sys.modules["google"] = google_mod + sys.modules["google.genai"] = genai_mod + sys.modules["google.genai.types"] = genai_types_mod + + import live_conversation_audio_with_audio as live + + def fake_read_wavefile(path: str) -> tuple[str, str]: + print("MOCK: read_wavefile called") + fake_bytes = b"\x00\x00" * 1000 + return base64.b64encode(fake_bytes).decode("ascii"), "audio/pcm;rate=16000" + + def fake_write_wavefile(path: str, frames: bytes, rate: int) -> None: + print(f"MOCK: write_wavefile called (no file written) rate={rate}") + + live.read_wavefile = fake_read_wavefile + live.write_wavefile = fake_write_wavefile + + yield live + + sys.modules.clear() + sys.modules.update(old_modules) + + @pytest.mark.asyncio async def test_live_with_text() -> None: assert await live_with_txt.generate_content() -@pytest.mark.asyncio -async def test_live_websocket_textgen_with_audio() -> None: - assert await live_websocket_textgen_with_audio.generate_content() +# @pytest.mark.asyncio +# async def test_live_websocket_textgen_with_audio() -> None: +# assert await live_websocket_textgen_with_audio.generate_content() @pytest.mark.asyncio @@ -95,5 +238,35 @@ async def test_live_txtgen_with_audio() -> None: @pytest.mark.asyncio -async def test_live_structured_ouput_with_txt() -> None: - assert live_structured_ouput_with_txt.generate_content() +def test_live_structured_output_with_txt() -> None: + assert live_structured_output_with_txt.generate_content() + + +@pytest.mark.asyncio +async def test_live_ground_ragengine_with_txt(mock_rag_components: None) -> None: + assert await live_ground_ragengine_with_txt.generate_content("test") + + +@pytest.mark.asyncio +async def test_live_txt_with_audio() -> None: + assert await live_txt_with_audio.generate_content() + + +@pytest.mark.asyncio +async def test_live_audio_with_txt(mock_live_session: None) -> None: + mock_client, mock_session = mock_live_session + + with patch("google.genai.Client", return_value=mock_client): + with patch("simpleaudio.WaveObject.from_wave_file") as mock_wave: + with patch("soundfile.write"): + mock_wave_obj = mock_wave.return_value + mock_wave_obj.play.return_value = MagicMock() + result = await live_audio_with_txt.generate_content() + + assert result is not None + + +@pytest.mark.asyncio +async def test_live_conversation_audio_with_audio(live_conversation: types.ModuleType) -> None: + result = await live_conversation.main() + assert result is True or result is None diff --git a/genai/text_generation/test_text_generation_examples.py b/genai/text_generation/test_text_generation_examples.py index 3381ae7ec8c..3477caef9df 100644 --- a/genai/text_generation/test_text_generation_examples.py +++ b/genai/text_generation/test_text_generation_examples.py @@ -22,6 +22,7 @@ import textgen_async_with_txt import textgen_chat_stream_with_txt import textgen_chat_with_txt +import textgen_code_with_pdf import textgen_config_with_txt import textgen_sys_instr_with_txt import textgen_transcript_with_gcs_audio @@ -137,6 +138,10 @@ def test_textgen_with_youtube_video() -> None: assert response +def test_textgen_code_with_pdf() -> None: + response = textgen_code_with_pdf.generate_content() + assert response + # Migrated to Model Optimser Folder # def test_model_optimizer_textgen_with_txt() -> None: # os.environ["GOOGLE_CLOUD_LOCATION"] = "us-central1" diff --git a/genai/text_generation/textgen_code_with_pdf.py b/genai/text_generation/textgen_code_with_pdf.py new file mode 100644 index 00000000000..da4ca76b73a --- /dev/null +++ b/genai/text_generation/textgen_code_with_pdf.py @@ -0,0 +1,55 @@ +# Copyright 2025 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. + +# !This sample works with Google Cloud Vertex AI API only. + + +def generate_content() -> str: + # [START googlegenaisdk_textgen_code_with_pdf] + from google import genai + from google.genai.types import HttpOptions, Part + + client = genai.Client(http_options=HttpOptions(api_version="v1beta1")) + model_id = "gemini-2.5-flash" + prompt = "Convert this python code to use Google Python Style Guide." + print("> ", prompt, "\n") + pdf_uri = "https://storage.googleapis.com/cloud-samples-data/generative-ai/text/inefficient_fibonacci_series_python_code.pdf" + + pdf_file = Part.from_uri( + file_uri=pdf_uri, + mime_type="application/pdf", + ) + + response = client.models.generate_content( + model=model_id, + contents=[pdf_file, prompt], + ) + + print(response.text) + # Example response: + # > Convert this python code to use Google Python Style Guide. + # + # def generate_fibonacci_sequence(num_terms: int) -> list[int]: + # """Generates the Fibonacci sequence up to a specified number of terms. + # + # This function calculates the Fibonacci sequence starting with 0 and 1. + # It handles base cases for 0, 1, and 2 terms efficiently. + # + # # ... + # [END googlegenaisdk_textgen_code_with_pdf] + return response.text + + +if __name__ == "__main__": + generate_content() From d3c7155ccc764a0bc03dfd00e42d9c4df93fd05f Mon Sep 17 00:00:00 2001 From: Katie Nguyen <21978337+katiemn@users.noreply.github.com> Date: Mon, 17 Nov 2025 10:36:25 -0800 Subject: [PATCH 06/13] feat: veo model update (#13637) --- genai/video_generation/videogen_with_first_last_frame.py | 2 +- genai/video_generation/videogen_with_img.py | 2 +- genai/video_generation/videogen_with_txt.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/genai/video_generation/videogen_with_first_last_frame.py b/genai/video_generation/videogen_with_first_last_frame.py index 21c7ff8cd98..52b5ab3a58a 100644 --- a/genai/video_generation/videogen_with_first_last_frame.py +++ b/genai/video_generation/videogen_with_first_last_frame.py @@ -25,7 +25,7 @@ def generate_videos_from_first_last_frame(output_gcs_uri: str) -> str: # output_gcs_uri = "gs://your-bucket/your-prefix" operation = client.models.generate_videos( - model="veo-3.1-generate-preview", + model="veo-3.1-generate-001", prompt="a hand reaches in and places a glass of milk next to the plate of cookies", image=Image( gcs_uri="gs://cloud-samples-data/generative-ai/image/cookies.png", diff --git a/genai/video_generation/videogen_with_img.py b/genai/video_generation/videogen_with_img.py index cee5bd0a665..ce725b1b03c 100644 --- a/genai/video_generation/videogen_with_img.py +++ b/genai/video_generation/videogen_with_img.py @@ -25,7 +25,7 @@ def generate_videos_from_image(output_gcs_uri: str) -> str: # output_gcs_uri = "gs://your-bucket/your-prefix" operation = client.models.generate_videos( - model="veo-3.1-generate-preview", + model="veo-3.1-generate-001", prompt="Extreme close-up of a cluster of vibrant wildflowers swaying gently in a sun-drenched meadow.", image=Image( gcs_uri="gs://cloud-samples-data/generative-ai/image/flowers.png", diff --git a/genai/video_generation/videogen_with_txt.py b/genai/video_generation/videogen_with_txt.py index a6d9934dc19..17ad11df4a3 100644 --- a/genai/video_generation/videogen_with_txt.py +++ b/genai/video_generation/videogen_with_txt.py @@ -25,7 +25,7 @@ def generate_videos(output_gcs_uri: str) -> str: # output_gcs_uri = "gs://your-bucket/your-prefix" operation = client.models.generate_videos( - model="veo-3.0-generate-001", + model="veo-3.1-generate-001", prompt="a cat reading a book", config=GenerateVideosConfig( aspect_ratio="16:9", From eff0040dee2bab9c4684f8425aa4d8ef28a9a244 Mon Sep 17 00:00:00 2001 From: Katie Nguyen <21978337+katiemn@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:53:28 -0800 Subject: [PATCH 07/13] feat: nano banana pro model (#13641) --- .../imggen_mmflash_edit_img_with_txt_img.py | 9 ++------- .../imggen_mmflash_txt_and_img_with_txt.py | 6 ++---- genai/image_generation/imggen_mmflash_with_txt.py | 14 ++------------ 3 files changed, 6 insertions(+), 23 deletions(-) diff --git a/genai/image_generation/imggen_mmflash_edit_img_with_txt_img.py b/genai/image_generation/imggen_mmflash_edit_img_with_txt_img.py index b0a7bb2a94c..e2d9888a027 100644 --- a/genai/image_generation/imggen_mmflash_edit_img_with_txt_img.py +++ b/genai/image_generation/imggen_mmflash_edit_img_with_txt_img.py @@ -26,7 +26,7 @@ def generate_content() -> str: image = Image.open("test_resources/example-image-eiffel-tower.png") response = client.models.generate_content( - model="gemini-2.5-flash-image", + model="gemini-3-pro-image-preview", contents=[image, "Edit this image to make it look like a cartoon."], config=GenerateContentConfig(response_modalities=[Modality.TEXT, Modality.IMAGE]), ) @@ -36,12 +36,7 @@ def generate_content() -> str: elif part.inline_data: image = Image.open(BytesIO((part.inline_data.data))) image.save("output_folder/bw-example-image.png") - # Example response: - # Here's the cartoon-style edit of the image: - # Cartoon-style edit: - # - Simplified the Eiffel Tower with bolder lines and slightly exaggerated proportions. - # - Brightened and saturated the colors of the sky, fireworks, and foliage for a more vibrant, cartoonish look. - # .... + # [END googlegenaisdk_imggen_mmflash_edit_img_with_txt_img] return "output_folder/bw-example-image.png" diff --git a/genai/image_generation/imggen_mmflash_txt_and_img_with_txt.py b/genai/image_generation/imggen_mmflash_txt_and_img_with_txt.py index 9e54d7b895e..7a9d11103a7 100644 --- a/genai/image_generation/imggen_mmflash_txt_and_img_with_txt.py +++ b/genai/image_generation/imggen_mmflash_txt_and_img_with_txt.py @@ -23,7 +23,7 @@ def generate_content() -> int: client = genai.Client() response = client.models.generate_content( - model="gemini-2.5-flash-image", + model="gemini-3-pro-image-preview", contents=( "Generate an illustrated recipe for a paella." "Create images to go alongside the text as you generate the recipe" @@ -38,9 +38,7 @@ def generate_content() -> int: image = Image.open(BytesIO((part.inline_data.data))) image.save(f"output_folder/example-image-{i+1}.png") fp.write(f"![image](example-image-{i+1}.png)") - # Example response: - # A markdown page for a Paella recipe(`paella-recipe.md`) has been generated. - # It includes detailed steps and several images illustrating the cooking process. + # [END googlegenaisdk_imggen_mmflash_txt_and_img_with_txt] return True diff --git a/genai/image_generation/imggen_mmflash_with_txt.py b/genai/image_generation/imggen_mmflash_with_txt.py index ed0b6d416bf..0ee371b7e84 100644 --- a/genai/image_generation/imggen_mmflash_with_txt.py +++ b/genai/image_generation/imggen_mmflash_with_txt.py @@ -23,16 +23,10 @@ def generate_content() -> str: client = genai.Client() response = client.models.generate_content( - model="gemini-2.5-flash-image", + model="gemini-3-pro-image-preview", contents=("Generate an image of the Eiffel tower with fireworks in the background."), config=GenerateContentConfig( response_modalities=[Modality.TEXT, Modality.IMAGE], - candidate_count=1, - safety_settings=[ - {"method": "PROBABILITY"}, - {"category": "HARM_CATEGORY_DANGEROUS_CONTENT"}, - {"threshold": "BLOCK_MEDIUM_AND_ABOVE"}, - ], ), ) for part in response.candidates[0].content.parts: @@ -41,11 +35,7 @@ def generate_content() -> str: elif part.inline_data: image = Image.open(BytesIO((part.inline_data.data))) image.save("output_folder/example-image-eiffel-tower.png") - # Example response: - # I will generate an image of the Eiffel Tower at night, with a vibrant display of - # colorful fireworks exploding in the dark sky behind it. The tower will be - # illuminated, standing tall as the focal point of the scene, with the bursts of - # light from the fireworks creating a festive atmosphere. + # [END googlegenaisdk_imggen_mmflash_with_txt] return True From 800a114ed8f76b4056d4b9cfa320a0ee55e1e3e7 Mon Sep 17 00:00:00 2001 From: kuirensu-crypto Date: Wed, 3 Dec 2025 11:04:24 -0800 Subject: [PATCH 08/13] feat: add code samples for preference tuning (#13644) * feat: add code samples for preference tuning * update region tag * update requirement and add test * update test * add new line --------- Co-authored-by: James Su --- genai/tuning/preference_tuning_job_create.py | 74 ++++++++++++++++++++ genai/tuning/requirements.txt | 2 +- genai/tuning/test_tuning_examples.py | 21 ++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 genai/tuning/preference_tuning_job_create.py diff --git a/genai/tuning/preference_tuning_job_create.py b/genai/tuning/preference_tuning_job_create.py new file mode 100644 index 00000000000..13fa05d61d0 --- /dev/null +++ b/genai/tuning/preference_tuning_job_create.py @@ -0,0 +1,74 @@ +# Copyright 2025 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. + + +def create_tuning_job() -> str: + # [START googlegenaisdk_preference_tuning_job_create] + import time + + from google import genai + from google.genai.types import HttpOptions, CreateTuningJobConfig, TuningDataset + + client = genai.Client(http_options=HttpOptions(api_version="v1")) + + training_dataset = TuningDataset( + gcs_uri="gs://mybucket/preference_tuning/data/train_data.jsonl", + ) + validation_dataset = TuningDataset( + gcs_uri="gs://mybucket/preference_tuning/data/validation_data.jsonl", + ) + + # Refer to https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini-use-continuous-tuning#google-gen-ai-sdk + # for example to continuous tune from SFT tuned model. + tuning_job = client.tunings.tune( + base_model="gemini-2.5-flash", + training_dataset=training_dataset, + config=CreateTuningJobConfig( + tuned_model_display_name="Example tuning job", + method="PREFERENCE_TUNING", + validation_dataset=validation_dataset, + ), + ) + + running_states = set([ + "JOB_STATE_PENDING", + "JOB_STATE_RUNNING", + ]) + + while tuning_job.state in running_states: + print(tuning_job.state) + tuning_job = client.tunings.get(name=tuning_job.name) + time.sleep(60) + + print(tuning_job.tuned_model.model) + print(tuning_job.tuned_model.endpoint) + print(tuning_job.experiment) + # Example response: + # projects/123456789012/locations/us-central1/models/1234567890@1 + # projects/123456789012/locations/us-central1/endpoints/123456789012345 + # projects/123456789012/locations/us-central1/metadataStores/default/contexts/tuning-experiment-2025010112345678 + + if tuning_job.tuned_model.checkpoints: + for i, checkpoint in enumerate(tuning_job.tuned_model.checkpoints): + print(f"Checkpoint {i + 1}: ", checkpoint) + # Example response: + # Checkpoint 1: checkpoint_id='1' epoch=1 step=10 endpoint='projects/123456789012/locations/us-central1/endpoints/123456789000000' + # Checkpoint 2: checkpoint_id='2' epoch=2 step=20 endpoint='projects/123456789012/locations/us-central1/endpoints/123456789012345' + + # [END googlegenaisdk_preference_tuning_job_create] + return tuning_job.name + + +if __name__ == "__main__": + create_tuning_job() diff --git a/genai/tuning/requirements.txt b/genai/tuning/requirements.txt index 1efe7b29dbc..e5fdb322ca4 100644 --- a/genai/tuning/requirements.txt +++ b/genai/tuning/requirements.txt @@ -1 +1 @@ -google-genai==1.42.0 +google-genai==1.47.0 diff --git a/genai/tuning/test_tuning_examples.py b/genai/tuning/test_tuning_examples.py index c0e6ec2864d..25b46402622 100644 --- a/genai/tuning/test_tuning_examples.py +++ b/genai/tuning/test_tuning_examples.py @@ -20,6 +20,7 @@ from google.genai import types import pytest +import preference_tuning_job_create import tuning_job_create import tuning_job_get import tuning_job_list @@ -327,3 +328,23 @@ def test_tuning_with_pretuned_model(mock_genai_client: MagicMock) -> None: mock_genai_client.assert_called_once_with(http_options=types.HttpOptions(api_version="v1beta1")) mock_genai_client.return_value.tunings.tune.assert_called_once() assert response == "test-tuning-job" + + +@patch("google.genai.Client") +def test_preference_tuning_job_create(mock_genai_client: MagicMock) -> None: + # Mock the API response + mock_tuning_job = types.TuningJob( + name="test-tuning-job", + experiment="test-experiment", + tuned_model=types.TunedModel( + model="test-model", + endpoint="test-endpoint" + ) + ) + mock_genai_client.return_value.tunings.tune.return_value = mock_tuning_job + + response = preference_tuning_job_create.create_tuning_job() + + mock_genai_client.assert_called_once_with(http_options=types.HttpOptions(api_version="v1")) + mock_genai_client.return_value.tunings.tune.assert_called_once() + assert response == "test-tuning-job" From 6cbd32817d27e936839ff523851c614acda9b278 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 18:51:35 -0800 Subject: [PATCH 09/13] chore(deps): bump urllib3 (#13666) Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.5.0 to 2.6.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.5.0...2.6.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.6.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../flex-templates/pipeline_with_dependencies/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dataflow/flex-templates/pipeline_with_dependencies/requirements.txt b/dataflow/flex-templates/pipeline_with_dependencies/requirements.txt index eeed8f6f3ce..bef166bb943 100644 --- a/dataflow/flex-templates/pipeline_with_dependencies/requirements.txt +++ b/dataflow/flex-templates/pipeline_with_dependencies/requirements.txt @@ -305,7 +305,7 @@ typing-extensions==4.10.0 # via apache-beam tzlocal==5.2 # via js2py -urllib3==2.5.0 +urllib3==2.6.0 # via requests wrapt==1.16.0 # via deprecated From e1fe831fab975627419e64373ecee7eb11f47568 Mon Sep 17 00:00:00 2001 From: tbeyer-google Date: Fri, 12 Dec 2025 04:57:12 +0100 Subject: [PATCH 10/13] =?UTF-8?q?chore(genai):=20Update=20tools=5Fgoogle?= =?UTF-8?q?=5Fsearch=5Fwith=5Ftxt.py=20to=20include=20example=20for=20excl?= =?UTF-8?q?ude=E2=80=A6=20(#13600)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update tools_google_search_with_txt.py to include example for exclude_domains * Update genai/tools/tools_google_search_with_txt.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update genai/tools/tools_google_search_with_txt.py * Update requirements * Update genai/tools/requirements.txt Co-authored-by: Katie McLaughlin --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Holt Skinner <13262395+holtskinner@users.noreply.github.com> Co-authored-by: Katie McLaughlin --- genai/tools/requirements.txt | 2 +- genai/tools/tools_google_search_with_txt.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/genai/tools/requirements.txt b/genai/tools/requirements.txt index 95d3e9bc0f0..9f6fafbe8ec 100644 --- a/genai/tools/requirements.txt +++ b/genai/tools/requirements.txt @@ -1,3 +1,3 @@ -google-genai==1.42.0 +google-genai==1.45.0 # PIl is required for tools_code_execution_with_txt_img.py pillow==11.1.0 diff --git a/genai/tools/tools_google_search_with_txt.py b/genai/tools/tools_google_search_with_txt.py index 2f650b01df9..4069071d0c3 100644 --- a/genai/tools/tools_google_search_with_txt.py +++ b/genai/tools/tools_google_search_with_txt.py @@ -31,7 +31,12 @@ def generate_content() -> str: config=GenerateContentConfig( tools=[ # Use Google Search Tool - Tool(google_search=GoogleSearch()) + Tool( + google_search=GoogleSearch( + # Optional: Domains to exclude from results + exclude_domains=["domain.com", "domain2.com"] + ) + ) ], ), ) From a1cc7a44610d526c7912a65f3449f4b5a9ec451e Mon Sep 17 00:00:00 2001 From: werman Date: Fri, 12 Dec 2025 17:05:00 -0800 Subject: [PATCH 11/13] feat: Custom Credential Supplier Documentation (#13634) * Included changes for python script for custom credential suppliers. * Made some test and format changes. * Scripts now read from a file instead of env variables. Changed readmes as well. * Added license header to pod.yaml. * fix: Update Dockerfile this updates to a more modern version of python and leverages the benefits of Docker's Layer caching. requirements.txt will rarely change so this way the depedency layer is cached and only rebuilt if you explictly change requirements.txt. * fix: clarify comments * fix: refactor main to seprate concerns simplify testing need to refactor testing based on this separation. * fix: update testing to match refactored main. * fix: update version to test * fix: use latest python * fix: last line * fix: address issues introduced in gitignore file * fix: cleanup README documentation. * fix: refine the README instructions. * fix: Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: starting region tag * fix: address whitespace linting issue * fix: address linting * Now using the storage library instead of calling the storage endpoint. * Removed unnecessary comments. * Formatting changes. * Changed default scopes. * Fixed PR Build run fixes. --------- Co-authored-by: Jennifer Davis Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .gitignore | 6 +- auth/custom-credentials/aws/Dockerfile | 15 ++ auth/custom-credentials/aws/README.md | 127 +++++++++++++++ ...ustom-credentials-aws-secrets.json.example | 8 + auth/custom-credentials/aws/noxfile_config.py | 17 ++ auth/custom-credentials/aws/pod.yaml | 40 +++++ .../aws/requirements-test.txt | 2 + auth/custom-credentials/aws/requirements.txt | 5 + auth/custom-credentials/aws/snippets.py | 153 ++++++++++++++++++ auth/custom-credentials/aws/snippets_test.py | 130 +++++++++++++++ auth/custom-credentials/okta/README.md | 83 ++++++++++ ...stom-credentials-okta-secrets.json.example | 8 + .../custom-credentials/okta/noxfile_config.py | 17 ++ .../okta/requirements-test.txt | 2 + auth/custom-credentials/okta/requirements.txt | 4 + auth/custom-credentials/okta/snippets.py | 138 ++++++++++++++++ auth/custom-credentials/okta/snippets_test.py | 134 +++++++++++++++ 17 files changed, 888 insertions(+), 1 deletion(-) create mode 100644 auth/custom-credentials/aws/Dockerfile create mode 100644 auth/custom-credentials/aws/README.md create mode 100644 auth/custom-credentials/aws/custom-credentials-aws-secrets.json.example create mode 100644 auth/custom-credentials/aws/noxfile_config.py create mode 100644 auth/custom-credentials/aws/pod.yaml create mode 100644 auth/custom-credentials/aws/requirements-test.txt create mode 100644 auth/custom-credentials/aws/requirements.txt create mode 100644 auth/custom-credentials/aws/snippets.py create mode 100644 auth/custom-credentials/aws/snippets_test.py create mode 100644 auth/custom-credentials/okta/README.md create mode 100644 auth/custom-credentials/okta/custom-credentials-okta-secrets.json.example create mode 100644 auth/custom-credentials/okta/noxfile_config.py create mode 100644 auth/custom-credentials/okta/requirements-test.txt create mode 100644 auth/custom-credentials/okta/requirements.txt create mode 100644 auth/custom-credentials/okta/snippets.py create mode 100644 auth/custom-credentials/okta/snippets_test.py diff --git a/.gitignore b/.gitignore index bcb6b89f6ff..80cf8846a58 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,8 @@ env/ .idea .env* **/venv -**/noxfile.py \ No newline at end of file +**/noxfile.py + +# Auth Local secrets file +auth/custom-credentials/okta/custom-credentials-okta-secrets.json +auth/custom-credentials/aws/custom-credentials-aws-secrets.json diff --git a/auth/custom-credentials/aws/Dockerfile b/auth/custom-credentials/aws/Dockerfile new file mode 100644 index 00000000000..d90d88aa0a8 --- /dev/null +++ b/auth/custom-credentials/aws/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +RUN useradd -m appuser + +WORKDIR /app + +COPY --chown=appuser:appuser requirements.txt . + +USER appuser +RUN pip install --no-cache-dir -r requirements.txt + +COPY --chown=appuser:appuser snippets.py . + + +CMD ["python3", "snippets.py"] diff --git a/auth/custom-credentials/aws/README.md b/auth/custom-credentials/aws/README.md new file mode 100644 index 00000000000..551c95ef691 --- /dev/null +++ b/auth/custom-credentials/aws/README.md @@ -0,0 +1,127 @@ +# Running the Custom AWS Credential Supplier Sample + +This sample demonstrates how to use a custom AWS security credential supplier to authenticate with Google Cloud using AWS as an external identity provider. It uses Boto3 (the AWS SDK for Python) to fetch credentials from sources like Amazon Elastic Kubernetes Service (EKS) with IAM Roles for Service Accounts(IRSA), Elastic Container Service (ECS), or Fargate. + +## Prerequisites + +* An AWS account. +* A Google Cloud project with the IAM API enabled. +* A GCS bucket. +* Python 3.10 or later installed. + +If you want to use AWS security credentials that cannot be retrieved using methods supported natively by the [google-auth](https://github.com/googleapis/google-auth-library-python) library, a custom `AwsSecurityCredentialsSupplier` implementation may be specified. The supplier must return valid, unexpired AWS security credentials when called by the Google Cloud Auth library. + + +## Running Locally + +For local development, you can provide credentials and configuration in a JSON file. + +### Install Dependencies + +Ensure you have Python installed, then install the required libraries: + +```bash +pip install -r requirements.txt +``` + +### Configure Credentials for Local Development + +1. Copy the example secrets file to a new file named `custom-credentials-aws-secrets.json`: + ```bash + cp custom-credentials-aws-secrets.json.example custom-credentials-aws-secrets.json + ``` +2. Open `custom-credentials-aws-secrets.json` and fill in the required values for your AWS and Google Cloud configuration. Do not check your `custom-credentials-aws-secrets.json` file into version control. + +**Note:** This file is only used for local development and is not needed when running in a containerized environment like EKS with IRSA. + + +### Run the Script + +```bash +python3 snippets.py +``` + +When run locally, the script will detect the `custom-credentials-aws-secrets.json` file and use it to configure the necessary environment variables for the Boto3 client. + +## Running in a Containerized Environment (EKS) + +This section provides a brief overview of how to run the sample in an Amazon EKS cluster. + +### EKS Cluster Setup + +First, you need an EKS cluster. You can create one using `eksctl` or the AWS Management Console. For detailed instructions, refer to the [Amazon EKS documentation](https://docs.aws.amazon.com/eks/latest/userguide/create-cluster.html). + +### Configure IAM Roles for Service Accounts (IRSA) + +IRSA enables you to associate an IAM role with a Kubernetes service account. This provides a secure way for your pods to access AWS services without hardcoding long-lived credentials. + +Run the following command to create the IAM role and bind it to a Kubernetes Service Account: + +```bash +eksctl create iamserviceaccount \ + --name your-k8s-service-account \ + --namespace default \ + --cluster your-cluster-name \ + --region your-aws-region \ + --role-name your-role-name \ + --attach-policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess \ + --approve +``` + +> **Note**: The `--attach-policy-arn` flag is used here to demonstrate attaching permissions. Update this with the specific AWS policy ARN your application requires. + +For a deep dive into how this works without using `eksctl`, refer to the [IAM Roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) documentation. + +### Configure Google Cloud to Trust the AWS Role + +To allow your AWS role to authenticate as a Google Cloud service account, you need to configure Workload Identity Federation. This process involves these key steps: + +1. **Create a Workload Identity Pool and an AWS Provider:** The pool holds the configuration, and the provider is set up to trust your AWS account. + +2. **Create or select a Google Cloud Service Account:** This service account will be impersonated by your AWS role. + +3. **Bind the AWS Role to the Google Cloud Service Account:** Create an IAM policy binding that gives your AWS role the `Workload Identity User` (`roles/iam.workloadIdentityUser`) role on the Google Cloud service account. + +For more detailed information, see the documentation on [Configuring Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds). + +**Alternative: Direct Access** + +> For supported resources, you can grant roles directly to the AWS identity, bypassing service account impersonation. To do this, grant a role (like `roles/storage.objectViewer`) to the workload identity principal (`principalSet://...`) directly on the resource's IAM policy. + +For more detailed information, see the documentation on [Configuring Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-clouds). + +### Containerize and Package the Application + +Create a `Dockerfile` for the Python application and push the image to a container registry (for example Amazon ECR) that your EKS cluster can access. + +**Note:** The provided [`Dockerfile`](Dockerfile) is an example and may need to be modified for your specific needs. + +Build and push the image: +```bash +docker build -t your-container-image:latest . +docker push your-container-image:latest +``` + +### Deploy to EKS + +Create a Kubernetes deployment manifest to deploy your application to the EKS cluster. See the [`pod.yaml`](pod.yaml) file for an example. + +**Note:** The provided [`pod.yaml`](pod.yaml) is an example and may need to be modified for your specific needs. + +Deploy the pod: + +```bash +kubectl apply -f pod.yaml +``` + +### Clean Up + +To clean up the resources, delete the EKS cluster and any other AWS and Google Cloud resources you created. + +```bash +eksctl delete cluster --name your-cluster-name +``` + +## Testing + +This sample is not continuously tested. It is provided for instructional purposes and may require modifications to work in your environment. diff --git a/auth/custom-credentials/aws/custom-credentials-aws-secrets.json.example b/auth/custom-credentials/aws/custom-credentials-aws-secrets.json.example new file mode 100644 index 00000000000..300dc70c138 --- /dev/null +++ b/auth/custom-credentials/aws/custom-credentials-aws-secrets.json.example @@ -0,0 +1,8 @@ +{ + "aws_access_key_id": "YOUR_AWS_ACCESS_KEY_ID", + "aws_secret_access_key": "YOUR_AWS_SECRET_ACCESS_KEY", + "aws_region": "YOUR_AWS_REGION", + "gcp_workload_audience": "YOUR_GCP_WORKLOAD_AUDIENCE", + "gcs_bucket_name": "YOUR_GCS_BUCKET_NAME", + "gcp_service_account_impersonation_url": "YOUR_GCP_SERVICE_ACCOUNT_IMPERSONATION_URL" +} diff --git a/auth/custom-credentials/aws/noxfile_config.py b/auth/custom-credentials/aws/noxfile_config.py new file mode 100644 index 00000000000..0ed973689f7 --- /dev/null +++ b/auth/custom-credentials/aws/noxfile_config.py @@ -0,0 +1,17 @@ +# Copyright 2025 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 +# +# http://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. + +TEST_CONFIG_OVERRIDE = { + "ignored_versions": ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"], +} diff --git a/auth/custom-credentials/aws/pod.yaml b/auth/custom-credentials/aws/pod.yaml new file mode 100644 index 00000000000..70b94bf25e2 --- /dev/null +++ b/auth/custom-credentials/aws/pod.yaml @@ -0,0 +1,40 @@ +# Copyright 2025 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 +# +# http://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. + +apiVersion: v1 +kind: Pod +metadata: + name: custom-credential-pod +spec: + # The Kubernetes Service Account that is annotated with the corresponding + # AWS IAM role ARN. See the README for instructions on setting up IAM + # Roles for Service Accounts (IRSA). + serviceAccountName: your-k8s-service-account + containers: + - name: gcp-auth-sample + # The container image pushed to the container registry + # For example, Amazon Elastic Container Registry + image: your-container-image:latest + env: + # REQUIRED: The AWS region. Boto3 requires this to be set explicitly + # in containers. + - name: AWS_REGION + value: "your-aws-region" + # REQUIRED: The full identifier of the Workload Identity Pool provider + - name: GCP_WORKLOAD_AUDIENCE + value: "your-gcp-workload-audience" + # OPTIONAL: Enable Google Cloud service account impersonation + # - name: GCP_SERVICE_ACCOUNT_IMPERSONATION_URL + # value: "your-gcp-service-account-impersonation-url" + - name: GCS_BUCKET_NAME + value: "your-gcs-bucket-name" diff --git a/auth/custom-credentials/aws/requirements-test.txt b/auth/custom-credentials/aws/requirements-test.txt new file mode 100644 index 00000000000..43b24059d3e --- /dev/null +++ b/auth/custom-credentials/aws/requirements-test.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.2.0 diff --git a/auth/custom-credentials/aws/requirements.txt b/auth/custom-credentials/aws/requirements.txt new file mode 100644 index 00000000000..2c302888ed7 --- /dev/null +++ b/auth/custom-credentials/aws/requirements.txt @@ -0,0 +1,5 @@ +boto3==1.40.53 +google-auth==2.43.0 +google-cloud-storage==2.19.0 +python-dotenv==1.1.1 +requests==2.32.3 diff --git a/auth/custom-credentials/aws/snippets.py b/auth/custom-credentials/aws/snippets.py new file mode 100644 index 00000000000..2d77a123015 --- /dev/null +++ b/auth/custom-credentials/aws/snippets.py @@ -0,0 +1,153 @@ +# Copyright 2025 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 +# +# http://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. + +# [START auth_custom_credential_supplier_aws] +import json +import os +import sys + +import boto3 +from google.auth import aws +from google.auth import exceptions +from google.cloud import storage + + +class CustomAwsSupplier(aws.AwsSecurityCredentialsSupplier): + """Custom AWS Security Credentials Supplier using Boto3.""" + + def __init__(self): + """Initializes the Boto3 session, prioritizing environment variables for region.""" + # Explicitly read the region from the environment first. + region = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") + + # If region is None, Boto3's discovery chain will be used when needed. + self.session = boto3.Session(region_name=region) + self._cached_region = None + + def get_aws_region(self, context, request) -> str: + """Returns the AWS region using Boto3's default provider chain.""" + if self._cached_region: + return self._cached_region + + self._cached_region = self.session.region_name + + if not self._cached_region: + raise exceptions.GoogleAuthError( + "Boto3 was unable to resolve an AWS region." + ) + + return self._cached_region + + def get_aws_security_credentials( + self, context, request=None + ) -> aws.AwsSecurityCredentials: + """Retrieves AWS security credentials using Boto3's default provider chain.""" + creds = self.session.get_credentials() + if not creds: + raise exceptions.GoogleAuthError( + "Unable to resolve AWS credentials from Boto3." + ) + + return aws.AwsSecurityCredentials( + access_key_id=creds.access_key, + secret_access_key=creds.secret_key, + session_token=creds.token, + ) + + +def authenticate_with_aws_credentials(bucket_name, audience, impersonation_url=None): + """Authenticates using the custom AWS supplier and gets bucket metadata. + + Returns: + dict: The bucket metadata response from the Google Cloud Storage API. + """ + + custom_supplier = CustomAwsSupplier() + + credentials = aws.Credentials( + audience=audience, + subject_token_type="urn:ietf:params:aws:token-type:aws4_request", + service_account_impersonation_url=impersonation_url, + aws_security_credentials_supplier=custom_supplier, + scopes=["https://www.googleapis.com/auth/devstorage.read_only"], + ) + + storage_client = storage.Client(credentials=credentials) + + bucket = storage_client.get_bucket(bucket_name) + + return bucket._properties + + +# [END auth_custom_credential_supplier_aws] + + +def _load_config_from_file(): + """ + If a local secrets file is present, load it into the environment. + This is a "just-in-time" configuration for local development. These + variables are only set for the current process and are not exposed to the + shell. + """ + secrets_file = "custom-credentials-aws-secrets.json" + if os.path.exists(secrets_file): + with open(secrets_file, "r") as f: + try: + secrets = json.load(f) + except json.JSONDecodeError: + print(f"Error: '{secrets_file}' is not valid JSON.", file=sys.stderr) + return + + os.environ["AWS_ACCESS_KEY_ID"] = secrets.get("aws_access_key_id", "") + os.environ["AWS_SECRET_ACCESS_KEY"] = secrets.get("aws_secret_access_key", "") + os.environ["AWS_REGION"] = secrets.get("aws_region", "") + os.environ["GCP_WORKLOAD_AUDIENCE"] = secrets.get("gcp_workload_audience", "") + os.environ["GCS_BUCKET_NAME"] = secrets.get("gcs_bucket_name", "") + os.environ["GCP_SERVICE_ACCOUNT_IMPERSONATION_URL"] = secrets.get( + "gcp_service_account_impersonation_url", "" + ) + + +def main(): + + # Reads the custom-credentials-aws-secrets.json if running locally. + _load_config_from_file() + + # Now, read the configuration from the environment. In a local run, these + # will be the values we just set. In a containerized run, they will be + # the values provided by the environment. + gcp_audience = os.getenv("GCP_WORKLOAD_AUDIENCE") + sa_impersonation_url = os.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL") + gcs_bucket_name = os.getenv("GCS_BUCKET_NAME") + + if not all([gcp_audience, gcs_bucket_name]): + print( + "Required configuration missing. Please provide it in a " + "custom-credentials-aws-secrets.json file or as environment variables: " + "GCP_WORKLOAD_AUDIENCE, GCS_BUCKET_NAME" + ) + return + + try: + print(f"Retrieving metadata for bucket: {gcs_bucket_name}...") + metadata = authenticate_with_aws_credentials( + gcs_bucket_name, gcp_audience, sa_impersonation_url + ) + print("--- SUCCESS! ---") + print(json.dumps(metadata, indent=2)) + except Exception as e: + print(f"Authentication or Request failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/auth/custom-credentials/aws/snippets_test.py b/auth/custom-credentials/aws/snippets_test.py new file mode 100644 index 00000000000..e0382cfc6f5 --- /dev/null +++ b/auth/custom-credentials/aws/snippets_test.py @@ -0,0 +1,130 @@ +# Copyright 2025 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 +# +# http://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 json +import os +from unittest import mock + +import pytest + +import snippets + +# --- Unit Tests --- + + +@mock.patch.dict(os.environ, {"AWS_REGION": "us-west-2"}) +@mock.patch("boto3.Session") +def test_init_priority_env_var(mock_boto_session): + """Test that AWS_REGION env var takes priority during init.""" + snippets.CustomAwsSupplier() + mock_boto_session.assert_called_with(region_name="us-west-2") + + +@mock.patch.dict(os.environ, {}, clear=True) +@mock.patch("boto3.Session") +def test_get_aws_region_caching(mock_boto_session): + """Test that get_aws_region caches the result from Boto3.""" + mock_session_instance = mock_boto_session.return_value + mock_session_instance.region_name = "us-east-1" + + supplier = snippets.CustomAwsSupplier() + + # First call should hit the session + region = supplier.get_aws_region(None, None) + assert region == "us-east-1" + + # Change the mock to ensure we aren't calling it again + mock_session_instance.region_name = "us-west-2" + + # Second call should return the cached value + region2 = supplier.get_aws_region(None, None) + assert region2 == "us-east-1" + + +@mock.patch("boto3.Session") +def test_get_aws_security_credentials_success(mock_boto_session): + """Test successful retrieval of AWS credentials.""" + mock_session_instance = mock_boto_session.return_value + + mock_creds = mock.MagicMock() + mock_creds.access_key = "test-key" + mock_creds.secret_key = "test-secret" + mock_creds.token = "test-token" + mock_session_instance.get_credentials.return_value = mock_creds + + supplier = snippets.CustomAwsSupplier() + creds = supplier.get_aws_security_credentials(None) + + assert creds.access_key_id == "test-key" + assert creds.secret_access_key == "test-secret" + assert creds.session_token == "test-token" + + +@mock.patch("snippets.auth_requests.AuthorizedSession") +@mock.patch("snippets.aws.Credentials") +@mock.patch("snippets.CustomAwsSupplier") +def test_authenticate_unit_success(MockSupplier, MockAwsCreds, MockSession): + """Unit test for the main flow using mocks.""" + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"name": "my-bucket"} + + mock_session_instance = MockSession.return_value + mock_session_instance.get.return_value = mock_response + + result = snippets.authenticate_with_aws_credentials( + bucket_name="my-bucket", + audience="//iam.googleapis.com/...", + impersonation_url=None, + ) + + assert result == {"name": "my-bucket"} + MockSupplier.assert_called_once() + MockAwsCreds.assert_called_once() + + +# --- System Test (Integration) --- + + +def test_authenticate_system(): + """ + System test that runs against the real API. + Skips automatically if custom-credentials-aws-secrets.json is missing or incomplete. + """ + if not os.path.exists("custom-credentials-aws-secrets.json"): + pytest.skip( + "Skipping system test: custom-credentials-aws-secrets.json not found." + ) + + with open("custom-credentials-aws-secrets.json", "r") as f: + secrets = json.load(f) + + required_keys = [ + "gcp_workload_audience", + "gcs_bucket_name", + "aws_access_key_id", + "aws_secret_access_key", + "aws_region", + ] + if not all(key in secrets and secrets[key] for key in required_keys): + pytest.skip( + "Skipping system test: custom-credentials-aws-secrets.json is missing or has empty required keys." + ) + + metadata = snippets.main() + + # Verify that the returned metadata is a dictionary with expected keys. + assert isinstance(metadata, dict) + assert "name" in metadata + assert metadata["name"] == secrets["gcs_bucket_name"] diff --git a/auth/custom-credentials/okta/README.md b/auth/custom-credentials/okta/README.md new file mode 100644 index 00000000000..96d444e85a4 --- /dev/null +++ b/auth/custom-credentials/okta/README.md @@ -0,0 +1,83 @@ +# Running the Custom Okta Credential Supplier Sample + +This sample demonstrates how to use a custom subject token supplier to authenticate with Google Cloud using Okta as an external identity provider. It uses the Client Credentials flow for machine-to-machine (M2M) authentication. + +## Prerequisites + +* An Okta developer account. +* A Google Cloud project with the IAM API enabled. +* A Google Cloud Storage bucket. Ensure that the authenticated user has access to this bucket. +* Python 3.10 or later installed. +* +## Okta Configuration + +Before running the sample, you need to configure an Okta application for Machine-to-Machine (M2M) communication. + +### Create an M2M Application in Okta + +1. Log in to your Okta developer console. +2. Navigate to **Applications** > **Applications** and click **Create App Integration**. +3. Select **API Services** as the sign-on method and click **Next**. +4. Give your application a name and click **Save**. + +### Obtain Okta Credentials + +Once the application is created, you will find the following information in the **General** tab: + +* **Okta Domain**: Your Okta developer domain (e.g., `https://dev-123456.okta.com`). +* **Client ID**: The client ID for your application. +* **Client Secret**: The client secret for your application. + +You will need these values to configure the sample. + +## Google Cloud Configuration + +You need to configure a Workload Identity Pool in Google Cloud to trust the Okta application. + +### Set up Workload Identity Federation + +1. In the Google Cloud Console, navigate to **IAM & Admin** > **Workload Identity Federation**. +2. Click **Create Pool** to create a new Workload Identity Pool. +3. Add a new **OIDC provider** to the pool. +4. Configure the provider with your Okta domain as the issuer URL. +5. Map the Okta `sub` (subject) assertion to a GCP principal. + +For detailed instructions, refer to the [Workload Identity Federation documentation](https://cloud.google.com/iam/docs/workload-identity-federation). + +## 3. Running the Script + +To run the sample on your local system, you need to install the dependencies and configure your credentials. + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Configure Credentials + +1. Copy the example secrets file to a new file named `custom-credentials-okta-secrets.json`: + ```bash + cp custom-credentials-okta-secrets.json.example custom-credentials-okta-secrets.json + ``` +2. Open `custom-credentials-okta-secrets.json` and fill in the following values: + + * `okta_domain`: Your Okta developer domain (for example `https://dev-123456.okta.com`). + * `okta_client_id`: The client ID for your application. + * `okta_client_secret`: The client secret for your application. + * `gcp_workload_audience`: The audience for the Google Cloud Workload Identity Pool. This is the full identifier of the Workload Identity Pool provider. + * `gcs_bucket_name`: The name of the Google Cloud Storage bucket to access. + * `gcp_service_account_impersonation_url`: (Optional) The URL for service account impersonation. + + +### Run the Application + +```bash +python3 snippets.py +``` + +The script authenticates with Okta to get an OIDC token, exchanges that token for a Google Cloud federated token, and uses it to list metadata for the specified Google Cloud Storage bucket. + +## Testing + +This sample is not continuously tested. It is provided for instructional purposes and may require modifications to work in your environment. diff --git a/auth/custom-credentials/okta/custom-credentials-okta-secrets.json.example b/auth/custom-credentials/okta/custom-credentials-okta-secrets.json.example new file mode 100644 index 00000000000..fa04fda7cb2 --- /dev/null +++ b/auth/custom-credentials/okta/custom-credentials-okta-secrets.json.example @@ -0,0 +1,8 @@ +{ + "okta_domain": "https://your-okta-domain.okta.com/", + "okta_client_id": "your-okta-client-id", + "okta_client_secret": "your-okta-client-secret", + "gcp_workload_audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider", + "gcs_bucket_name": "your-gcs-bucket-name", + "gcp_service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/my-service-account@my-project.iam.gserviceaccount.com:generateAccessToken" +} diff --git a/auth/custom-credentials/okta/noxfile_config.py b/auth/custom-credentials/okta/noxfile_config.py new file mode 100644 index 00000000000..0ed973689f7 --- /dev/null +++ b/auth/custom-credentials/okta/noxfile_config.py @@ -0,0 +1,17 @@ +# Copyright 2025 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 +# +# http://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. + +TEST_CONFIG_OVERRIDE = { + "ignored_versions": ["2.7", "3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"], +} diff --git a/auth/custom-credentials/okta/requirements-test.txt b/auth/custom-credentials/okta/requirements-test.txt new file mode 100644 index 00000000000..f47609d2651 --- /dev/null +++ b/auth/custom-credentials/okta/requirements-test.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==7.1.2 diff --git a/auth/custom-credentials/okta/requirements.txt b/auth/custom-credentials/okta/requirements.txt new file mode 100644 index 00000000000..d9669ebee9f --- /dev/null +++ b/auth/custom-credentials/okta/requirements.txt @@ -0,0 +1,4 @@ +requests==2.32.3 +google-cloud-storage==2.19.0 +google-auth==2.43.0 +python-dotenv==1.1.1 diff --git a/auth/custom-credentials/okta/snippets.py b/auth/custom-credentials/okta/snippets.py new file mode 100644 index 00000000000..02af2dadc93 --- /dev/null +++ b/auth/custom-credentials/okta/snippets.py @@ -0,0 +1,138 @@ +# Copyright 2025 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 +# +# http://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. + +# [START auth_custom_credential_supplier_okta] +import json +import time +import urllib.parse + +from google.auth import identity_pool +from google.cloud import storage +import requests + + +class OktaClientCredentialsSupplier: + """A custom SubjectTokenSupplier that authenticates with Okta. + + This supplier uses the Client Credentials grant flow for machine-to-machine + (M2M) authentication with Okta. + """ + + def __init__(self, domain, client_id, client_secret): + self.okta_token_url = f"{domain.rstrip('/')}/oauth2/default/v1/token" + self.client_id = client_id + self.client_secret = client_secret + self.access_token = None + self.expiry_time = 0 + + def get_subject_token(self, context, request=None) -> str: + """Fetches a new token if the current one is expired or missing.""" + if self.access_token and time.time() < self.expiry_time - 60: + return self.access_token + self._fetch_okta_access_token() + return self.access_token + + def _fetch_okta_access_token(self): + """Performs the Client Credentials grant flow with Okta.""" + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + data = { + "grant_type": "client_credentials", + "scope": "gcp.test.read", # Set scope as per Okta app config. + } + + response = requests.post( + self.okta_token_url, + headers=headers, + data=urllib.parse.urlencode(data), + auth=(self.client_id, self.client_secret), + ) + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data["access_token"] + self.expiry_time = time.time() + token_data["expires_in"] + + +def authenticate_with_okta_credentials( + bucket_name, audience, domain, client_id, client_secret, impersonation_url=None +): + """Authenticates using the custom Okta supplier and gets bucket metadata. + + Returns: + dict: The bucket metadata response from the Google Cloud Storage API. + """ + + okta_supplier = OktaClientCredentialsSupplier(domain, client_id, client_secret) + + credentials = identity_pool.Credentials( + audience=audience, + subject_token_type="urn:ietf:params:oauth:token-type:jwt", + token_url="https://sts.googleapis.com/v1/token", + subject_token_supplier=okta_supplier, + default_scopes=["https://www.googleapis.com/auth/devstorage.read_only"], + service_account_impersonation_url=impersonation_url, + ) + + storage_client = storage.Client(credentials=credentials) + + bucket = storage_client.get_bucket(bucket_name) + + return bucket._properties + + +# [END auth_custom_credential_supplier_okta] + + +def main(): + try: + with open("custom-credentials-okta-secrets.json") as f: + secrets = json.load(f) + except FileNotFoundError: + print("Could not find custom-credentials-okta-secrets.json.") + return + + gcp_audience = secrets.get("gcp_workload_audience") + gcs_bucket_name = secrets.get("gcs_bucket_name") + sa_impersonation_url = secrets.get("gcp_service_account_impersonation_url") + + okta_domain = secrets.get("okta_domain") + okta_client_id = secrets.get("okta_client_id") + okta_client_secret = secrets.get("okta_client_secret") + + if not all( + [gcp_audience, gcs_bucket_name, okta_domain, okta_client_id, okta_client_secret] + ): + print("Missing required values in secrets.json.") + return + + try: + print(f"Retrieving metadata for bucket: {gcs_bucket_name}...") + metadata = authenticate_with_okta_credentials( + bucket_name=gcs_bucket_name, + audience=gcp_audience, + domain=okta_domain, + client_id=okta_client_id, + client_secret=okta_client_secret, + impersonation_url=sa_impersonation_url, + ) + print("--- SUCCESS! ---") + print(json.dumps(metadata, indent=2)) + except Exception as e: + print(f"Authentication or Request failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/auth/custom-credentials/okta/snippets_test.py b/auth/custom-credentials/okta/snippets_test.py new file mode 100644 index 00000000000..1f05c4ad7bf --- /dev/null +++ b/auth/custom-credentials/okta/snippets_test.py @@ -0,0 +1,134 @@ +# Copyright 2025 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 +# +# http://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 json +import os +import time +from unittest import mock +import urllib.parse + +import pytest + +import snippets + +# --- Unit Tests --- + + +def test_init_url_cleaning(): + """Test that the token URL strips trailing slashes.""" + s1 = snippets.OktaClientCredentialsSupplier("https://okta.com/", "id", "sec") + assert s1.okta_token_url == "https://okta.com/oauth2/default/v1/token" + + s2 = snippets.OktaClientCredentialsSupplier("https://okta.com/", "id", "sec") + assert s2.okta_token_url == "https://okta.com/oauth2/default/v1/token" + + +@mock.patch("requests.post") +def test_get_subject_token_fetch(mock_post): + """Test fetching a new token from Okta.""" + supplier = snippets.OktaClientCredentialsSupplier("https://okta.com/", "id", "sec") + + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"access_token": "new-token", "expires_in": 3600} + mock_post.return_value = mock_response + + token = supplier.get_subject_token(None, None) + + assert token == "new-token" + mock_post.assert_called_once() + + # Verify args + _, kwargs = mock_post.call_args + assert kwargs["auth"] == ("id", "sec") + + sent_data = urllib.parse.parse_qs(kwargs["data"]) + assert sent_data["grant_type"][0] == "client_credentials" + + +@mock.patch("requests.post") +def test_get_subject_token_cached(mock_post): + """Test that cached token is returned if valid.""" + supplier = snippets.OktaClientCredentialsSupplier("https://okta.com/", "id", "sec") + supplier.access_token = "cached-token" + supplier.expiry_time = time.time() + 3600 + + token = supplier.get_subject_token(None, None) + + assert token == "cached-token" + mock_post.assert_not_called() + + +@mock.patch("snippets.auth_requests.AuthorizedSession") +@mock.patch("snippets.identity_pool.Credentials") +@mock.patch("snippets.OktaClientCredentialsSupplier") +def test_authenticate_unit_success(MockSupplier, MockCreds, MockSession): + """Unit test for the main Okta auth flow.""" + mock_response = mock.MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"name": "test-bucket"} + + mock_session_instance = MockSession.return_value + mock_session_instance.get.return_value = mock_response + + metadata = snippets.authenticate_with_okta_credentials( + bucket_name="test-bucket", + audience="test-aud", + domain="https://okta.com/", + client_id="id", + client_secret="sec", + impersonation_url=None, + ) + + assert metadata == {"name": "test-bucket"} + MockSupplier.assert_called_once() + MockCreds.assert_called_once() + + +# --- System Test --- + + +def test_authenticate_system(): + """ + System test that runs against the real API. + Skips automatically if custom-credentials-okta-secrets.json is missing or incomplete. + """ + if not os.path.exists("custom-credentials-okta-secrets.json"): + pytest.skip( + "Skipping system test: custom-credentials-okta-secrets.json not found." + ) + + with open("custom-credentials-okta-secrets.json", "r") as f: + secrets = json.load(f) + + required_keys = [ + "gcp_workload_audience", + "gcs_bucket_name", + "okta_domain", + "okta_client_id", + "okta_client_secret", + ] + if not all(key in secrets for key in required_keys): + pytest.skip( + "Skipping system test: custom-credentials-okta-secrets.json is missing required keys." + ) + + # The main() function handles the auth flow and printing. + # We mock the print function to verify the output. + with mock.patch("builtins.print") as mock_print: + snippets.main() + + # Check for the success message in the print output. + output = "\n".join([call.args[0] for call in mock_print.call_args_list]) + assert "--- SUCCESS! ---" in output From aaa3218a27e7a2a0f356f9063fff0ebae6aef49b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:04:13 -0800 Subject: [PATCH 12/13] chore(deps): bump django in /appengine/standard_python3/django (#13660) Bumps [django](https://github.com/django/django) from 5.1.8 to 5.1.15. - [Commits](https://github.com/django/django/compare/5.1.8...5.1.15) --- updated-dependencies: - dependency-name: django dependency-version: 5.1.15 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- appengine/standard_python3/django/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appengine/standard_python3/django/requirements.txt b/appengine/standard_python3/django/requirements.txt index cdd4b54cf3e..60b4408e6b4 100644 --- a/appengine/standard_python3/django/requirements.txt +++ b/appengine/standard_python3/django/requirements.txt @@ -1,4 +1,4 @@ -Django==5.1.8; python_version >= "3.10" +Django==5.1.15; python_version >= "3.10" Django==4.2.17; python_version >= "3.8" and python_version < "3.10" Django==3.2.25; python_version < "3.8" django-environ==0.10.0 From 04df0496f8732139d37493adb9334b68a640e11b Mon Sep 17 00:00:00 2001 From: Jennifer Davis Date: Mon, 15 Dec 2025 17:56:18 +0000 Subject: [PATCH 13/13] fix: enable dialog_cx dependency updates to be tested/merged (#13670) * chore(deps): bump werkzeug from 3.0.6 to 3.1.4 in /dialogflow-cx Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.0.6 to 3.1.4. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/3.0.6...3.1.4) --- updated-dependencies: - dependency-name: werkzeug dependency-version: 3.1.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * fix: validated in 3.10 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dialogflow-cx/noxfile_config.py | 2 +- dialogflow-cx/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dialogflow-cx/noxfile_config.py b/dialogflow-cx/noxfile_config.py index 462f6d428f7..cc8143940ee 100644 --- a/dialogflow-cx/noxfile_config.py +++ b/dialogflow-cx/noxfile_config.py @@ -22,7 +22,7 @@ TEST_CONFIG_OVERRIDE = { # You can opt out from the test for specific Python versions. - "ignored_versions": ["2.7", "3.7", "3.9", "3.10", "3.11", "3.12", "3.13"], + "ignored_versions": ["2.7", "3.7", "3.8", "3.9", "3.11", "3.12", "3.13"], # An envvar key for determining the project id to use. Change it # to 'BUILD_SPECIFIC_GCLOUD_PROJECT' if you want to opt in using a # build specific Cloud project. You can also use your own string diff --git a/dialogflow-cx/requirements.txt b/dialogflow-cx/requirements.txt index c162d7a7e98..fe7011b74ee 100644 --- a/dialogflow-cx/requirements.txt +++ b/dialogflow-cx/requirements.txt @@ -2,7 +2,7 @@ google-cloud-dialogflow-cx==2.0.0 Flask==3.0.3 python-dateutil==2.9.0.post0 functions-framework==3.9.2 -Werkzeug==3.0.6 +Werkzeug==3.1.4 termcolor==3.0.0; python_version >= "3.9" termcolor==2.4.0; python_version == "3.8" pyaudio==0.2.14 \ No newline at end of file