From 78d9fa7cda3945b92bb92d88b298e66a17d83e69 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 10 Feb 2026 12:12:49 +0100 Subject: [PATCH] DEVEXP-1266: Update CI --- .github/workflows/ci.yml | 16 ++ .../available_numbers/rent_any/snippet.py | 2 +- scripts/check_snippet_coverage.py | 101 ++++++++++++ tests/unit/test_check_snippet_coverage.py | 147 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 scripts/check_snippet_coverage.py create mode 100644 tests/unit/test_check_snippet_coverage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49967804..388b22b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,22 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -e . pip install -r requirements-dev.txt + - name: Compile all examples + run: | + for file in $(find examples -name "*.py"); do + echo "Compiling $file..." + python -m py_compile "$file" || exit 1 + done + echo "All examples compiled successfully." + + - name: Check snippet coverage + run: | + pip install python-dotenv + python scripts/check_snippet_coverage.py + - name: Lint and format check with Ruff run: | ruff check sinch/domains/numbers --statistics @@ -98,3 +112,5 @@ jobs: python -m behave tests/e2e/sms/features python -m behave tests/e2e/conversation/features python -m behave tests/e2e/number-lookup/features + + \ No newline at end of file diff --git a/examples/snippets/numbers/available_numbers/rent_any/snippet.py b/examples/snippets/numbers/available_numbers/rent_any/snippet.py index b2d57247..a7ce7a60 100644 --- a/examples/snippets/numbers/available_numbers/rent_any/snippet.py +++ b/examples/snippets/numbers/available_numbers/rent_any/snippet.py @@ -25,7 +25,7 @@ response = sinch_client.numbers.rent_any( region_code="US", - type_="LOCAL", + number_type="LOCAL", capabilities=["SMS", "VOICE"], sms_configuration=sms_configuration ) diff --git a/scripts/check_snippet_coverage.py b/scripts/check_snippet_coverage.py new file mode 100644 index 00000000..e07d0155 --- /dev/null +++ b/scripts/check_snippet_coverage.py @@ -0,0 +1,101 @@ +""" +Validate that snippets have valid syntax, working imports, and correct SDK method names. +""" +import argparse +import os +import sys +from pathlib import Path +from unittest.mock import patch + +for var in [ + "SINCH_PROJECT_ID", "SINCH_KEY_ID", "SINCH_KEY_SECRET", + "SINCH_SMS_REGION", "SINCH_CONVERSATION_REGION", + "SINCH_PHONE_NUMBER", "SINCH_SERVICE_PLAN_ID", +]: + os.environ.setdefault(var, "test") + + +class SnippetValidationComplete(Exception): + """Raised when snippet successfully reaches first API call.""" + + +def validate_snippet(snippet_path: Path, quiet: bool = True) -> tuple[bool, str]: + """Run snippet; success when it reaches the first API call.""" + def mock_request(self, endpoint): + raise SnippetValidationComplete() + + try: + with patch( + "sinch.core.adapters.requests_http_transport.HTTPTransportRequests.request", + mock_request, + ): + with open(snippet_path) as f: + source = f.read() + if quiet: + with open(os.devnull, "w") as devnull: + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout, sys.stderr = devnull, devnull + try: + exec(source, {"__name__": "__main__"}) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + else: + exec(source, {"__name__": "__main__"}) + return False, "Snippet ran without making API call" + except SnippetValidationComplete: + return True, "" + except ModuleNotFoundError as e: + return False, f"Broken import: {e}" + except ImportError as e: + return False, f"Broken import: {e}" + except AttributeError as e: + return False, f"Method/attribute does not exist: {e}" + except SyntaxError as e: + return False, f"Syntax error: {e}" + except Exception as e: + return False, f"{type(e).__name__}: {e}" + + +def main(): + parser = argparse.ArgumentParser( + description="Validate snippets (imports, syntax, SDK method names)" + ) + parser.add_argument("-q", "--quiet", action="store_true", help="Only print failures") + args = parser.parse_args() + + root = Path(__file__).parent.parent + os.chdir(root) + + snippets_dir = root / "examples" / "snippets" + if not snippets_dir.exists(): + print("ERROR: examples/snippets directory not found") + return 1 + + snippet_files = list(snippets_dir.rglob("snippet.py")) + if not snippet_files: + print("ERROR: No snippet.py files found") + return 1 + + failed = [] + for snippet_path in sorted(snippet_files): + rel_path = snippet_path.relative_to(root) + success, error = validate_snippet(snippet_path, quiet=args.quiet) + if success: + if not args.quiet: + print(f" OK {rel_path}") + else: + print(f" FAIL {rel_path}\n {error}") + failed.append((rel_path, error)) + + if failed: + print(f"\n{len(failed)} snippet(s) failed validation:") + for path, err in failed: + print(f" - {path}: {err}") + return 1 + + print(f"\nAll {len(snippet_files)} snippets validated successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_check_snippet_coverage.py b/tests/unit/test_check_snippet_coverage.py new file mode 100644 index 00000000..602bacc1 --- /dev/null +++ b/tests/unit/test_check_snippet_coverage.py @@ -0,0 +1,147 @@ +import importlib.util +from pathlib import Path +from textwrap import dedent +import pytest + +ROOT = Path(__file__).parent.parent.parent +_SPEC = importlib.util.spec_from_file_location( + "check_snippet_coverage", + ROOT / "scripts" / "check_snippet_coverage.py", +) +_CHECK_SNIPPET_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_CHECK_SNIPPET_MOD) +validate_snippet = _CHECK_SNIPPET_MOD.validate_snippet + + +@pytest.fixture +def temp_snippet_dir(tmp_path): + """Temporary directory for snippet files.""" + return tmp_path + + +def test_nonexistent_module_import_expects_failure_with_broken_import_message( + temp_snippet_dir, +): + """Test that importing a nonexistent module returns failure with broken import message.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from nonexistent_module_xyz import foo") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error + assert "nonexistent_module_xyz" in error + + +def test_missing_name_import_from_sinch_expects_failure(temp_snippet_dir): + """Test that importing a missing name from sinch returns failure.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from sinch import NonExistentClass") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error or "ImportError" in error + + +def test_nonexistent_sdk_method_expects_attribute_error(temp_snippet_dir): + """Test that calling a nonexistent SDK method returns failure with attribute error.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_nonexistent_method( + to=["+1"], from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "Method/attribute does not exist" in error + assert "send_nonexistent_method" in error + + +def test_invalid_syntax_expects_syntax_error(temp_snippet_dir): + """Test that invalid Python syntax returns failure with syntax error.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("def foo()\n return 42") + + success, error = validate_snippet(path) + + assert success is False + assert "Syntax error" in error + + +def test_snippet_without_api_call_expects_failure(temp_snippet_dir): + """Test that a snippet that does not make an API call returns failure.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + print("no api call") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "without making API call" in error + + +def test_invalid__args_expects_failure(temp_snippet_dir): + """Test that invalid arguments return failure (TypeError or similar).""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms( + to="not_a_list", from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "TypeError" in error or "AttributeError" in error or len(error) > 0 + + +def test_valid_snippet_expects_success(temp_snippet_dir): + """Test that a valid snippet (inline string) passes validation.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms(to=["+1"], from_="+1", body="hi") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is True, f"Snippet failed: {error}"