diff --git a/README.md b/README.md index a2fdb9d07..5556b4073 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,51 @@ pip install google-genai uv pip install google-genai ``` +## Migrate from google-generativeai + +A codemod tool automates migration from the legacy `google-generativeai` SDK to `google-genai`. Install with the migrate extra and run on your codebase: + +```sh +pip install google-genai[migrate] +``` + +```sh +# Preview changes (unified diff) +genai-migrate ./my_app/ --diff + +# Apply changes in-place +genai-migrate ./my_app/ --in-place +``` + +**Before (legacy):** +```python +import google.generativeai as genai +genai.configure(api_key="YOUR_API_KEY") +model = genai.GenerativeModel("gemini-1.5-flash") +response = model.generate_content("Hello") +``` + +**After (migrated):** +```python +from google import genai +client = genai.Client(api_key="YOUR_API_KEY") +model = genai.GenerativeModel("gemini-1.5-flash") +response = client.models.generate_content(model="gemini-1.5-flash", contents="Hello") +``` + +**Supported transforms:** + +| Legacy pattern | New pattern | +|---|---| +| `import google.generativeai as genai` | `from google import genai` | +| `genai.configure(api_key=...)` | `client = genai.Client(api_key=...)` | +| `genai.GenerativeModel(...)` | `client.models.generate_content(model=..., ...)` | +| `model.generate_content(...)` | `client.models.generate_content(model=..., contents=...)` | +| `model.start_chat(...)` | `client.chats.create(model=..., config=...)` | +| `model.count_tokens(...)` | `client.models.count_tokens(model=..., contents=...)` | + +**Known limitations:** `generation_config`/`safety_settings` folding into `config=` is not yet automated; `history=` in `start_chat` is stubbed. + ## Imports ```python diff --git a/google/genai/_migrate/__init__.py b/google/genai/_migrate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google/genai/_migrate/cli.py b/google/genai/_migrate/cli.py new file mode 100644 index 000000000..7b5247f21 --- /dev/null +++ b/google/genai/_migrate/cli.py @@ -0,0 +1,99 @@ +"""CLI for google-generativeai -> google-genai migration.""" + +import argparse +import difflib +import sys +from pathlib import Path + +from google.genai._migrate.codemod import transform_source + + +def collect_python_files(path: Path, include_tests: bool) -> list[Path]: + """Collect .py files from path, optionally including test_*.py files.""" + files: list[Path] = [] + if path.is_file() and path.suffix == ".py": + files.append(path) + elif path.is_dir(): + for py_file in path.rglob("*.py"): + if include_tests or not py_file.name.startswith("test_"): + files.append(py_file) + return sorted(files) + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="genai-migrate", + description="Migrate google-generativeai code to google-genai.", + ) + parser.add_argument( + "path", + type=Path, + help="File or directory to migrate", + ) + parser.add_argument( + "--diff", + action="store_true", + help="Print unified diff to stdout, do not write files", + ) + parser.add_argument( + "--in-place", + action="store_true", + help="Overwrite files in place", + ) + parser.add_argument( + "--include-tests", + action="store_true", + help="Also process test_*.py files (default: off)", + ) + + args = parser.parse_args() + + if not args.diff and not args.in_place: + parser.error("Must specify either --diff or --in-place") + + target_path = args.path + if not target_path.exists(): + parser.error(f"Path does not exist: {target_path}") + + files = collect_python_files(target_path, args.include_tests) + if not files: + print("No Python files found to process.") + return 0 + + total_files = len(files) + changed_files = 0 + + for file_path in files: + try: + source = file_path.read_text(encoding="utf-8") + except Exception as e: + print(f"Error reading {file_path}: {e}", file=sys.stderr) + continue + + new_source = transform_source(source) + + if new_source == source: + continue + + changed_files += 1 + + if args.diff: + diff = difflib.unified_diff( + source.splitlines(keepends=True), + new_source.splitlines(keepends=True), + fromfile=str(file_path), + tofile=str(file_path), + ) + sys.stdout.writelines(diff) + elif args.in_place: + try: + file_path.write_text(new_source, encoding="utf-8") + except Exception as e: + print(f"Error writing {file_path}: {e}", file=sys.stderr) + + print(f"Processed {total_files} files, {changed_files} changed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/google/genai/_migrate/codemod.py b/google/genai/_migrate/codemod.py new file mode 100644 index 000000000..f1f593a55 --- /dev/null +++ b/google/genai/_migrate/codemod.py @@ -0,0 +1,51 @@ +# google/genai/_migrate/codemod.py +import libcst as cst +# from typing import Dict, Any + +from google.genai._migrate.visitors import (CountTokensVisitor, + GenerateContentVisitor, + GenerativeModelVisitor, + ImportAndConfigureVisitor, + StartChatVisitor) + + +def transform_source(src: str) -> str: + """ + Parses Python source code with libcst and applies the migration visitors. + + The visitors are applied sequentially to ensure state (like the symbol_table + mapping variable names to model strings) is passed down correctly. + + Args: + src: The raw Python source code string to be migrated. + + Returns: + The migrated Python source code string, with formatting preserved. + + Raises: + ValueError: If the source code contains invalid Python syntax. + """ + if not src.strip(): + return src + + try: + module = cst.parse_module(src) + except cst.ParserSyntaxError as e: + raise ValueError(f"Failed to parse Python source: {e}") from e + + # Shared state container: maps legacy model variable names to their string names. + # e.g., {"model": "'gemini-1.5-flash'"} + symbol_table: dict[str, str] = {} + + # Apply visitors sequentially. + # Order matters: + # 1. Imports/configure must run first to establish the `client` variable. + # 2. GenerativeModelVisitor must run before the others to populate the symbol_table. + # 3. The remaining visitors use the symbol_table to rewrite method calls. + module = module.visit(ImportAndConfigureVisitor()) + module = module.visit(GenerativeModelVisitor(symbol_table)) + module = module.visit(GenerateContentVisitor(symbol_table)) + module = module.visit(StartChatVisitor(symbol_table)) + module = module.visit(CountTokensVisitor(symbol_table)) + + return module.code diff --git a/google/genai/_migrate/mappings.py b/google/genai/_migrate/mappings.py new file mode 100644 index 000000000..e489ac06b --- /dev/null +++ b/google/genai/_migrate/mappings.py @@ -0,0 +1,47 @@ +"""Legacy -> new transformation mappings for google-generativeai -> google-genai migration. + +Each dict represents one legacy -> new transformation with keys: +- name: short identifier for the transformation +- legacy_pattern: legacy code snippet (string snippet, not regex) +- new_pattern: new code snippet (string snippet, not regex) +- notes: additional notes about the transformation +""" + +MAPPINGS: list[dict[str, str]] = [ + { + "name": "import", + "legacy_pattern": "import google.generativeai as genai", + "new_pattern": "from google import genai", + "notes": "Change import style from module import to from google import genai", + }, + { + "name": "configure", + "legacy_pattern": "genai.configure(api_key=...)", + "new_pattern": "client = genai.Client(api_key=...)", + "notes": "Replace configure() with Client instantiation", + }, + { + "name": "model", + "legacy_pattern": "genai.GenerativeModel('gemini-X')", + "new_pattern": "client.models.generate_content(model='gemini-X', ...)", + "notes": "Replace GenerativeModel instantiation with client.models.generate_content", + }, + { + "name": "generate_content", + "legacy_pattern": "model.generate_content(text)", + "new_pattern": "client.models.generate_content(model=..., contents=text)", + "notes": "Replace model.generate_content with client.models.generate_content", + }, + { + "name": "start_chat", + "legacy_pattern": "model.start_chat(history=...)", + "new_pattern": "client.chats.create(model=..., config=...)", + "notes": "Replace model.start_chat with client.chats.create", + }, + { + "name": "count_tokens", + "legacy_pattern": "model.count_tokens(text)", + "new_pattern": "client.models.count_tokens(model=..., contents=text)", + "notes": "Replace model.count_tokens with client.models.count_tokens", + }, +] diff --git a/google/genai/_migrate/report.py b/google/genai/_migrate/report.py new file mode 100644 index 000000000..e69de29bb diff --git a/google/genai/_migrate/visitors.py b/google/genai/_migrate/visitors.py new file mode 100644 index 000000000..2579cc273 --- /dev/null +++ b/google/genai/_migrate/visitors.py @@ -0,0 +1,365 @@ +"""CSTVisitor classes for google-generativeai -> google-genai migration. + +This module contains all the CSTTransformer classes that perform the actual +source-to-source transformations. Each transformer targets a specific pattern +and uses libcst matchers to find and replace legacy code with new google-genai +equivalents. +""" + +import libcst as cst +from libcst import matchers as m + + +class ImportAndConfigureVisitor(cst.CSTTransformer): # type: ignore[misc] + """Transform legacy imports and genai.configure() calls. + + (a) Replaces: import google.generativeai as genai + with: from google import genai + + (b) Replaces: genai.configure(api_key=...) (top-level expr) + with: client = genai.Client(api_key=...) + """ + + def __init__(self) -> None: + super().__init__() + self.client_var = "client" + + def leave_Import( + self, original_node: cst.Import, updated_node: cst.Import + ) -> cst.BaseSmallStatement: + # Match: import google.generativeai as genai + if m.matches( + original_node, + m.Import( + names=[ + m.ImportAlias( + name=m.Attribute( + value=m.Name("google"), + attr=m.Name("generativeai"), + ), + asname=m.AsName(name=m.Name("genai")), + ) + ] + ), + ): + # Replace with: from google import genai + return cst.ImportFrom( + module=cst.Name("google"), + names=[cst.ImportAlias(name=cst.Name("genai"))], + ) + return updated_node + + def leave_Expr( + self, original_node: cst.Expr, updated_node: cst.Expr + ) -> cst.BaseSmallStatement: + # Match: genai.configure(...) as a top-level expression statement + if m.matches( + original_node, + m.Expr( + value=m.Call( + func=m.Attribute( + value=m.Name("genai"), + attr=m.Name("configure"), + ) + ) + ), + ): + # Extract the Call node + call = original_node.value + if isinstance(call, cst.Call): + # Create: client = genai.Client() + assign = cst.Assign( + targets=[cst.AssignTarget(target=cst.Name(self.client_var))], + value=cst.Call( + func=cst.Attribute( + value=cst.Name("genai"), + attr=cst.Name("Client"), + ), + args=call.args, + ), + ) + return assign + return updated_node + + +class GenerativeModelVisitor(cst.CSTTransformer): # type: ignore[misc] + """Detect genai.GenerativeModel(...) assignments and record model names. + + Records a mapping: variable_name -> model_name_string + Does NOT rewrite the assignment (left for a later cleanup pass). + """ + + def __init__(self, symbol_table: dict[str, str]) -> None: + super().__init__() + self.symbol_table = symbol_table + + def leave_Assign(self, original_node: cst.Assign, updated_node: cst.Assign) -> cst.BaseSmallStatement: + # Match: var = genai.GenerativeModel('model-name') + if m.matches( + original_node, + m.Assign( + targets=[m.AssignTarget(target=m.Name())], + value=m.Call( + func=m.Attribute( + value=m.Name("genai"), + attr=m.Name("GenerativeModel"), + ) + ), + ), + ): + # Extract variable name + target = original_node.targets[0].target + if isinstance(target, cst.Name): + var_name = target.value + else: + return updated_node + + # Extract model name from arguments + model_name = "gemini-1.5-flash" # default + call = original_node.value + if isinstance(call, cst.Call): + # Check positional args first + for arg in call.args: + if arg.keyword is None: + # Positional argument + if isinstance(arg.value, cst.SimpleString): + evaluated = arg.value.evaluated_value + if isinstance(evaluated, bytes): + model_name = evaluated.decode("utf-8") + else: + model_name = str(evaluated) + break + else: + # No positional args, check keyword args + for arg in call.args: + if arg.keyword and arg.keyword.value == "model_name": + if isinstance(arg.value, cst.SimpleString): + evaluated = arg.value.evaluated_value + if isinstance(evaluated, bytes): + model_name = evaluated.decode("utf-8") + else: + model_name = str(evaluated) + break + + self.symbol_table[var_name] = model_name + return cst.FlattenSentinel([]) # type: ignore[return-value] + + return updated_node + + +class GenerateContentVisitor(cst.CSTTransformer): # type: ignore[misc] + """Rewrite model.generate_content(...) calls to client.models.generate_content(...). + + Uses the symbol_table populated by GenerativeModelVisitor to know the model name. + """ + + def __init__(self, symbol_table: dict[str, str]) -> None: + super().__init__() + self.symbol_table = symbol_table + + def leave_Call( + self, original_node: cst.Call, updated_node: cst.Call + ) -> cst.BaseExpression: + # Match: var.generate_content(...) + if m.matches( + original_node, + m.Call( + func=m.Attribute( + value=m.Name(), + attr=m.Name("generate_content"), + ) + ), + ): + func = original_node.func + if isinstance(func, cst.Attribute) and isinstance(func.value, cst.Name): + var_name = func.value.value + if var_name in self.symbol_table: + model_name = self.symbol_table[var_name] + + # Check for stream=True kwarg + stream = False + for arg in original_node.args: + if arg.keyword and arg.keyword.value == "stream": + if ( + isinstance(arg.value, cst.Name) + and arg.value.value == "True" + ): + stream = True + elif isinstance(arg.value, cst.SimpleString): + stream = True + break + + method_name = ( + "generate_content_stream" if stream else "generate_content" + ) + + # Build new args: model=... + contents=... + other kwargs + new_args: list[cst.Arg] = [ + cst.Arg( + keyword=cst.Name("model"), + value=cst.SimpleString(f"'{model_name}'"), + ) + ] + + # Map first positional arg (prompt) to contents= + # Pass through other args + first_pos = True + for arg in original_node.args: + if arg.keyword is None and first_pos: + # First positional -> contents + new_args.append( + cst.Arg(keyword=cst.Name("contents"), value=arg.value) + ) + first_pos = False + elif arg.keyword and arg.keyword.value == "stream" and stream: + # Skip stream=True kwarg + continue + else: + new_args.append(arg) + + return cst.Call( + func=cst.Attribute( + value=cst.Attribute( + value=cst.Name("client"), + attr=cst.Name("models"), + ), + attr=cst.Name(method_name), + ), + args=new_args, + ) + + return updated_node + + +class StartChatVisitor(cst.CSTTransformer): # type: ignore[misc] + """Rewrite model.start_chat(...) calls to client.chats.create(...). + + Uses the symbol_table populated by GenerativeModelVisitor to know the model name. + """ + + def __init__(self, symbol_table: dict[str, str]) -> None: + super().__init__() + self.symbol_table = symbol_table + + def leave_Call( + self, original_node: cst.Call, updated_node: cst.Call + ) -> cst.BaseExpression: + # Match: var.start_chat(...) + if m.matches( + original_node, + m.Call( + func=m.Attribute( + value=m.Name(), + attr=m.Name("start_chat"), + ) + ), + ): + func = original_node.func + if isinstance(func, cst.Attribute) and isinstance(func.value, cst.Name): + var_name = func.value.value + if var_name in self.symbol_table: + model_name = self.symbol_table[var_name] + + # Check for history= kwarg + history_arg = None + for arg in original_node.args: + if arg.keyword and arg.keyword.value == "history": + history_arg = arg + break + + # Build new args: model=... + config=... + new_args: list[cst.Arg] = [ + cst.Arg( + keyword=cst.Name("model"), + value=cst.SimpleString(f'"{model_name}"'), + ) + ] + + if history_arg is not None: + # TODO: Full history migration is out of scope for 24h. + # For now, pass empty GenerateContentConfig as placeholder. + new_args.append( + cst.Arg( + keyword=cst.Name("config"), + value=cst.parse_expression( + "types.GenerateContentConfig()" + ), + ) + ) + + return cst.Call( + func=cst.Attribute( + value=cst.Attribute( + value=cst.Name("client"), + attr=cst.Name("chats"), + ), + attr=cst.Name("create"), + ), + args=new_args, + ) + + return updated_node + + +class CountTokensVisitor(cst.CSTTransformer): # type: ignore[misc] + """Rewrite model.count_tokens(...) calls to client.models.count_tokens(...). + + Uses the symbol_table populated by GenerativeModelVisitor to know the model name. + """ + + def __init__(self, symbol_table: dict[str, str]) -> None: + super().__init__() + self.symbol_table = symbol_table + + def leave_Call( + self, original_node: cst.Call, updated_node: cst.Call + ) -> cst.BaseExpression: + # Match: var.count_tokens(...) + if m.matches( + original_node, + m.Call( + func=m.Attribute( + value=m.Name(), + attr=m.Name("count_tokens"), + ) + ), + ): + func = original_node.func + if isinstance(func, cst.Attribute) and isinstance(func.value, cst.Name): + var_name = func.value.value + if var_name in self.symbol_table: + model_name = self.symbol_table[var_name] + + # Build new args: model=... + contents=... + other kwargs + new_args: list[cst.Arg] = [ + cst.Arg( + keyword=cst.Name("model"), + value=cst.SimpleString(f"'{model_name}'"), + ) + ] + + # Map first positional arg to contents= + # Pass through other args + first_pos = True + for arg in original_node.args: + if arg.keyword is None and first_pos: + # First positional -> contents + new_args.append( + cst.Arg(keyword=cst.Name("contents"), value=arg.value) + ) + first_pos = False + else: + new_args.append(arg) + + return cst.Call( + func=cst.Attribute( + value=cst.Attribute( + value=cst.Name("client"), + attr=cst.Name("models"), + ), + attr=cst.Name("count_tokens"), + ), + args=new_args, + ) + + return updated_node diff --git a/pyproject.toml b/pyproject.toml index 1022cb0e0..9681d9014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ local-tokenizer = [ "transformers", ] pyopenssl = ["pyopenssl"] +migrate = ["libcst>=1.4.0"] + +[project.scripts] +genai-migrate = "google.genai._migrate.cli:main" [project.urls] Homepage = "https://github.com/googleapis/python-genai" @@ -61,6 +65,7 @@ Homepage = "https://github.com/googleapis/python-genai" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" + [tool.mypy] exclude = ["tests/", "_test_api_client\\.py", "_gaos/"] explicit_package_bases = true diff --git a/tests/migrate/conftest.py b/tests/migrate/conftest.py new file mode 100644 index 000000000..9ba72aa07 --- /dev/null +++ b/tests/migrate/conftest.py @@ -0,0 +1 @@ +collect_ignore = ["fixtures"] diff --git a/tests/migrate/fixtures/expected/test_basic.py b/tests/migrate/fixtures/expected/test_basic.py new file mode 100644 index 000000000..1e065014b --- /dev/null +++ b/tests/migrate/fixtures/expected/test_basic.py @@ -0,0 +1,4 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") +response = client.models.generate_content(model = 'gemini-1.5-flash', contents = "Hello") diff --git a/tests/migrate/fixtures/expected/test_chat.py b/tests/migrate/fixtures/expected/test_chat.py new file mode 100644 index 000000000..375cd39bf --- /dev/null +++ b/tests/migrate/fixtures/expected/test_chat.py @@ -0,0 +1,4 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") +chat = client.chats.create(model = "gemini-1.5-flash", config = types.GenerateContentConfig()) diff --git a/tests/migrate/fixtures/expected/test_count_tokens.py b/tests/migrate/fixtures/expected/test_count_tokens.py new file mode 100644 index 000000000..02d6001ca --- /dev/null +++ b/tests/migrate/fixtures/expected/test_count_tokens.py @@ -0,0 +1,4 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") +tokens = client.models.count_tokens(model = 'gemini-1.5-flash', contents = "Hello") diff --git a/tests/migrate/fixtures/expected/test_kwargs.py b/tests/migrate/fixtures/expected/test_kwargs.py new file mode 100644 index 000000000..127a5c5a3 --- /dev/null +++ b/tests/migrate/fixtures/expected/test_kwargs.py @@ -0,0 +1,8 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") +response = client.models.generate_content(model = 'gemini-1.5-flash', contents = "Hello", generation_config={"temperature": 0.5}, + safety_settings=[ + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"} + ], +) diff --git a/tests/migrate/fixtures/expected/test_multiple_models.py b/tests/migrate/fixtures/expected/test_multiple_models.py new file mode 100644 index 000000000..b00d14ac5 --- /dev/null +++ b/tests/migrate/fixtures/expected/test_multiple_models.py @@ -0,0 +1,6 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") + +response1 = client.models.generate_content(model = 'gemini-1.5-flash', contents = "Hello") +response2 = client.models.generate_content(model = 'gemini-1.5-pro', contents = "World") diff --git a/tests/migrate/fixtures/expected/test_stream.py b/tests/migrate/fixtures/expected/test_stream.py new file mode 100644 index 000000000..369dcb5a4 --- /dev/null +++ b/tests/migrate/fixtures/expected/test_stream.py @@ -0,0 +1,4 @@ +from google import genai + +client = genai.Client(api_key="YOUR_API_KEY") +response = client.models.generate_content_stream(model = 'gemini-1.5-flash', contents = "Hello") diff --git a/tests/migrate/fixtures/legacy/test_basic.py b/tests/migrate/fixtures/legacy/test_basic.py new file mode 100644 index 000000000..56d316368 --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_basic.py @@ -0,0 +1,6 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model = genai.GenerativeModel("gemini-1.5-flash") +response = model.generate_content("Hello") diff --git a/tests/migrate/fixtures/legacy/test_chat.py b/tests/migrate/fixtures/legacy/test_chat.py new file mode 100644 index 000000000..b108413bb --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_chat.py @@ -0,0 +1,6 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model = genai.GenerativeModel("gemini-1.5-flash") +chat = model.start_chat(history=[{"role": "user", "parts": "Hello"}]) diff --git a/tests/migrate/fixtures/legacy/test_count_tokens.py b/tests/migrate/fixtures/legacy/test_count_tokens.py new file mode 100644 index 000000000..0af5a9dfa --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_count_tokens.py @@ -0,0 +1,6 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model = genai.GenerativeModel("gemini-1.5-flash") +tokens = model.count_tokens("Hello") diff --git a/tests/migrate/fixtures/legacy/test_kwargs.py b/tests/migrate/fixtures/legacy/test_kwargs.py new file mode 100644 index 000000000..16dfe3952 --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_kwargs.py @@ -0,0 +1,12 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model = genai.GenerativeModel("gemini-1.5-flash") +response = model.generate_content( + "Hello", + generation_config={"temperature": 0.5}, + safety_settings=[ + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"} + ], +) diff --git a/tests/migrate/fixtures/legacy/test_multiple_models.py b/tests/migrate/fixtures/legacy/test_multiple_models.py new file mode 100644 index 000000000..2c6f54ddf --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_multiple_models.py @@ -0,0 +1,9 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model1 = genai.GenerativeModel("gemini-1.5-flash") +model2 = genai.GenerativeModel("gemini-1.5-pro") + +response1 = model1.generate_content("Hello") +response2 = model2.generate_content("World") diff --git a/tests/migrate/fixtures/legacy/test_stream.py b/tests/migrate/fixtures/legacy/test_stream.py new file mode 100644 index 000000000..69dac0815 --- /dev/null +++ b/tests/migrate/fixtures/legacy/test_stream.py @@ -0,0 +1,6 @@ +import google.generativeai as genai + +genai.configure(api_key="YOUR_API_KEY") + +model = genai.GenerativeModel("gemini-1.5-flash") +response = model.generate_content("Hello", stream=True) diff --git a/tests/migrate/test_codemod.py b/tests/migrate/test_codemod.py new file mode 100644 index 000000000..78a455584 --- /dev/null +++ b/tests/migrate/test_codemod.py @@ -0,0 +1,55 @@ +# tests/migrate/test_codemod.py +import os +import pathlib + +import pytest + +from google.genai._migrate.codemod import transform_source + +FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures" +LEGACY_DIR = FIXTURES_DIR / "legacy" +EXPECTED_DIR = FIXTURES_DIR / "expected" + + +def get_legacy_files(): + return sorted(LEGACY_DIR.glob("*.py")) + + +@pytest.fixture +def update_expected(): + """Return True if UPDATE_EXPECTED=1 environment variable is set.""" + return os.environ.get("UPDATE_EXPECTED") == "1" + + +@pytest.mark.parametrize("legacy_file", get_legacy_files(), ids=lambda f: f.stem) +def test_migration(legacy_file, update_expected): + """Test that migrating legacy code produces expected output.""" + legacy_src = legacy_file.read_text() + expected_file = EXPECTED_DIR / legacy_file.name + expected_src = expected_file.read_text() + + actual_src = transform_source(legacy_src) + + if update_expected: + expected_file.write_text(actual_src) + pytest.skip(f"Updated expected file: {expected_file}") + else: + assert actual_src == expected_src, ( + f"Migration output does not match expected for {legacy_file.name}\n" + f"--- Actual ---\n{actual_src}\n" + f"--- Expected ---\n{expected_src}" + ) + + +@pytest.mark.parametrize( + "expected_file", sorted(EXPECTED_DIR.glob("*.py")), ids=lambda f: f.stem +) +def test_idempotent(expected_file): + """Test that running transform_source on already-migrated code is a no-op.""" + src = expected_file.read_text() + actual_src = transform_source(src) + assert actual_src == src, ( + f"Codemod is not idempotent for {expected_file.name}\n" + f"--- First pass ---\n{actual_src}\n" + f"--- Original ---\n{src}" + )