diff --git a/google/genai/interactions.py b/google/genai/interactions.py index ca7afa204..55db21dd6 100644 --- a/google/genai/interactions.py +++ b/google/genai/interactions.py @@ -20,12 +20,57 @@ from typing_extensions import Literal, Required, TypedDict -# Import triggers before interactions so that interactions.Interaction (the -# resource class) overrides triggers.Interaction (the TypeAliasType representing -# nested interactions inside triggers) in the exported namespace, resolving -# the name collision. -from ._gaos.types.triggers import * # noqa: F401,F403 -from ._gaos.types.triggers import __all__ as _triggers_all +# Trigger create-params define nested `Interaction` / `InteractionParam` +# TypeAliasTypes that share names with the interactions resource class. +# Star-importing those aliases into this module makes mypy keep the first +# binding (the TypeAliasType), while runtime last-binding-wins to the resource +# class — see https://github.com/googleapis/python-genai/issues/2732. +# Exclude the colliding names here; they remain available from +# `google.genai._gaos.types.triggers`. +from ._gaos.types.triggers import __all__ as _triggers_all_raw + +_TRIGGERS_NAMES_EXCLUDED_FROM_INTERACTIONS = frozenset({ + 'Interaction', + 'InteractionParam', +}) + +_triggers_all = [ + name + for name in _triggers_all_raw + if name not in _TRIGGERS_NAMES_EXCLUDED_FROM_INTERACTIONS +] + +# Explicit imports so type checkers never bind the colliding TypeAliasTypes. +# Keep this list aligned with triggers.__all__ minus the excluded names; the +# assertion below fails if Speakeasy adds a new trigger export. +from ._gaos.types.triggers import ( # noqa: F401 + ListTriggerExecutionsResponse, + ListTriggerExecutionsResponseTypedDict, + ListTriggersResponse, + ListTriggersResponseTypedDict, + Trigger, + TriggerCreateParams, + TriggerCreateParamsParam, + TriggerExecution, + TriggerExecutionStatus, + TriggerExecutionTypedDict, + TriggerStatus, + TriggerTypedDict, + TriggerUpdate, + TriggerUpdateParam, + TriggerUpdateStatus, +) + +_missing_trigger_exports = [ + name for name in _triggers_all if name not in globals() +] +if _missing_trigger_exports: + raise ImportError( + 'google.genai.interactions is missing trigger exports after excluding ' + f'colliding Interaction aliases: {_missing_trigger_exports}. Update the ' + 'explicit triggers import list in google/genai/interactions.py.' + ) + from ._gaos.types.environments import * # noqa: F401,F403 from ._gaos.types.environments import __all__ as _environments_all from ._gaos.types.interactions import * # noqa: F401,F403 @@ -137,6 +182,12 @@ class InteractionGetParamsStreaming(InteractionGetParamsBase): "WebhookRotateSigningSecretParams", "WebhookUpdateParams", ] -# Ensure _interactions_all is appended last so interactions.Interaction wins -# when doing wildcard imports from this module. -__all__ = __all__ + list(_triggers_all) + list(_resources_all) + list(_environments_all) + list(_interactions_all) +# Append interactions exports last so `Interaction` is the resource class for +# wildcard imports. Trigger Interaction aliases are intentionally omitted above. +__all__ = ( + __all__ + + list(_triggers_all) + + list(_resources_all) + + list(_environments_all) + + list(_interactions_all) +) diff --git a/google/genai/tests/interactions/test_interaction_type_export.py b/google/genai/tests/interactions/test_interaction_type_export.py new file mode 100644 index 000000000..42e26a592 --- /dev/null +++ b/google/genai/tests/interactions/test_interaction_type_export.py @@ -0,0 +1,119 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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. +# + +"""Tests for Interaction export typing/runtime consistency (#2732).""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from ...interactions import Interaction +from ..._gaos.types.interactions.interaction import ( + Interaction as InteractionResource, +) +from ..._gaos.types.triggers.triggercreateparams import ( + Interaction as TriggerInteractionAlias, +) + + +def test_interactions_module_exports_resource_class_at_runtime(): + assert Interaction is InteractionResource + assert Interaction is not TriggerInteractionAlias + assert isinstance(Interaction, type) + + +def test_interactions_module_supports_isinstance_with_resource(): + class _Dummy: + id = 'x' + + # Resource class is a real type usable with isinstance. + assert not isinstance(_Dummy(), Interaction) + + +def test_trigger_interaction_alias_still_importable_from_triggers(): + from ..._gaos.types.triggers import Interaction as TriggersInteraction + + assert TriggersInteraction is TriggerInteractionAlias + + +def test_interactions_module_does_not_expose_trigger_interaction_param(): + import google.genai.interactions as interactions_mod + + assert getattr(interactions_mod, 'InteractionParam', None) is None + assert interactions_mod.Interaction.__name__ == 'Interaction' + assert interactions_mod.Interaction.__module__.endswith( + 'types.interactions.interaction' + ) + + +@pytest.mark.skipif( + subprocess.run( + [sys.executable, '-m', 'mypy', '--version'], + capture_output=True, + check=False, + ).returncode + != 0, + reason='mypy is not installed', +) +def test_mypy_resolves_interaction_to_resource_class(tmp_path: Path): + """Regression for #2732: mypy must not treat Interaction as TypeAliasType.""" + sample = tmp_path / 'check_interaction_export.py' + sample.write_text( + textwrap.dedent( + '''\ + from google.genai.interactions import Interaction + + + def check(x: object) -> None: + if isinstance(x, Interaction): + print(x.id) + ''' + ), + encoding='utf-8', + ) + + repo_root = Path(__file__).resolve().parents[4] + env = os.environ.copy() + existing = env.get('PYTHONPATH', '') + env['PYTHONPATH'] = ( + str(repo_root) + (os.pathsep + existing if existing else '') + ) + + result = subprocess.run( + [ + sys.executable, + '-m', + 'mypy', + '--strict', + '--follow-imports=silent', + str(sample), + ], + capture_output=True, + text=True, + cwd=str(repo_root), + env=env, + ) + combined = (result.stdout or '') + (result.stderr or '') + assert 'TypeAliasType' not in combined, combined + assert result.returncode == 0, ( + 'mypy failed to treat google.genai.interactions.Interaction as the ' + f'resource class.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}' + ) diff --git a/pyproject.toml b/pyproject.toml index b07c2d44a..31d2951df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,13 +87,12 @@ module = [ # for imports. This will only affect the _gaos module. ignore_errors = true -# This particularly for interactions.py. +# This particularly for interactions.py (star-imports from generated packages). [[tool.mypy.overrides]] module = "google.genai.interactions" disable_error_code = [ "misc", "assignment", - # Intentionally overriding imported alias, runtime safe. "no-redef", ]