From d0fcc4532c98e10cb11aa8d3ec427ec8f383c0cd Mon Sep 17 00:00:00 2001 From: Wanlin Du Date: Tue, 19 Aug 2025 23:38:10 +0000 Subject: [PATCH 1/4] WIP: add python test-server wrapper. --- .gitignore | 5 + sdks/python/sample/README.md | 9 + sdks/python/sample/__init__.py | 0 sdks/python/sample/conftest.py | 15 ++ .../test-data/config/test-server-config.yml | 7 + ...d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json | 48 +++++ sdks/python/sample/test_sample.py | 59 ++++++ sdks/python/src/__init__.py | 0 sdks/python/src/test_server_wrapper.py | 193 ++++++++++++++++++ 9 files changed, 336 insertions(+) create mode 100644 sdks/python/sample/README.md create mode 100644 sdks/python/sample/__init__.py create mode 100644 sdks/python/sample/conftest.py create mode 100644 sdks/python/sample/test-data/config/test-server-config.yml create mode 100644 sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json create mode 100644 sdks/python/sample/test_sample.py create mode 100644 sdks/python/src/__init__.py create mode 100644 sdks/python/src/test_server_wrapper.py diff --git a/.gitignore b/.gitignore index 1e2ba30..f26814a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ sdks/typescript/sample/dist/ # Python SDK specific sdks/python/bin/ +sdks/python/src/__pycache__ + +# Python SDK Sample specific +sdks/python/sample/__pycache__ +sdks/python/sample/.pytest_cache diff --git a/sdks/python/sample/README.md b/sdks/python/sample/README.md new file mode 100644 index 0000000..0812c54 --- /dev/null +++ b/sdks/python/sample/README.md @@ -0,0 +1,9 @@ +To run the sample, nevigate to sdks/python/samples + +```sh +pytest -sv + +# or + +pytest -sv --record +``` \ No newline at end of file diff --git a/sdks/python/sample/__init__.py b/sdks/python/sample/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdks/python/sample/conftest.py b/sdks/python/sample/conftest.py new file mode 100644 index 0000000..32402f0 --- /dev/null +++ b/sdks/python/sample/conftest.py @@ -0,0 +1,15 @@ +import pytest + +def pytest_addoption(parser): + """Adds the --record command-line option to pytest.""" + parser.addoption( + "--record", action="store_true", default=False, help="Run test-server in record mode." + ) + +@pytest.fixture(scope="session") +def test_server_mode(request): + """ + Returns 'record' or 'replay' based on the --record command-line flag. + This fixture can be used by any test. + """ + return "record" if request.config.getoption("--record") else "replay" diff --git a/sdks/python/sample/test-data/config/test-server-config.yml b/sdks/python/sample/test-data/config/test-server-config.yml new file mode 100644 index 0000000..ebe81e6 --- /dev/null +++ b/sdks/python/sample/test-data/config/test-server-config.yml @@ -0,0 +1,7 @@ +endpoints: + - target_host: github.com + target_type: https + target_port: 443 + source_type: http + source_port: 17080 + health: /healthz diff --git a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json new file mode 100644 index 0000000..ebe42bd --- /dev/null +++ b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json @@ -0,0 +1,48 @@ +{ + "recordID": "396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c", + "interactions": [ + { + "request": { + "method": "GET", + "url": "/", + "request": "GET / HTTP/1.1", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Connection": "keep-alive", + "User-Agent": "python-requests/2.32.5" + }, + "bodySegments": [ + null + ], + "previousRequest": "b4d6e60a9b97e7b98c63df9308728c5c88c0b40c398046772c63447b94608b4d", + "serverAddress": "github.com", + "port": 443, + "protocol": "https" + }, + "shaSum": "396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c", + "response": { + "statusCode": 200, + "headers": { + "Accept-Ranges": "bytes", + "Cache-Control": "max-age=0, private, must-revalidate", + "Content-Encoding": "gzip", + "Content-Language": "en-US", + "Content-Security-Policy": "default-src 'none'; base-uri 'self'; child-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/; connect-src 'self' uploads.github.com www.githubstatus.com collector.github.com raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com *.rel.tunnels.api.visualstudio.com wss://*.rel.tunnels.api.visualstudio.com objects-origin.githubusercontent.com copilot-proxy.githubusercontent.com proxy.individual.githubcopilot.com proxy.business.githubcopilot.com proxy.enterprise.githubcopilot.com *.actions.githubusercontent.com wss://*.actions.githubusercontent.com productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ productionresultssa4.blob.core.windows.net/ productionresultssa5.blob.core.windows.net/ productionresultssa6.blob.core.windows.net/ productionresultssa7.blob.core.windows.net/ productionresultssa8.blob.core.windows.net/ productionresultssa9.blob.core.windows.net/ productionresultssa10.blob.core.windows.net/ productionresultssa11.blob.core.windows.net/ productionresultssa12.blob.core.windows.net/ productionresultssa13.blob.core.windows.net/ productionresultssa14.blob.core.windows.net/ productionresultssa15.blob.core.windows.net/ productionresultssa16.blob.core.windows.net/ productionresultssa17.blob.core.windows.net/ productionresultssa18.blob.core.windows.net/ productionresultssa19.blob.core.windows.net/ github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com insights.github.com wss://alive.github.com wss://alive-staging.github.com api.githubcopilot.com api.individual.githubcopilot.com api.business.githubcopilot.com api.enterprise.githubcopilot.com github.githubassets.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com copilot-workspace.githubnext.com objects-origin.githubusercontent.com; frame-ancestors 'none'; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com www.youtube-nocookie.com; img-src 'self' data: blob: github.githubassets.com media.githubusercontent.com camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com private-avatars.githubusercontent.com github-cloud.s3.amazonaws.com objects.githubusercontent.com release-assets.githubusercontent.com secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ private-user-images.githubusercontent.com opengraph.githubassets.com copilotprodattachments.blob.core.windows.net/github-production-copilot-attachments/ github-production-user-asset-6210df.s3.amazonaws.com customer-stories-feed.github.com spotlights-feed.github.com objects-origin.githubusercontent.com *.githubusercontent.com images.ctfassets.net/8aevphvgewt8/; manifest-src 'self'; media-src github.com user-images.githubusercontent.com/ secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com github-production-user-asset-6210df.s3.amazonaws.com gist.github.com github.githubassets.com assets.ctfassets.net/8aevphvgewt8/ videos.ctfassets.net/8aevphvgewt8/; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com; upgrade-insecure-requests; worker-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/", + "Content-Type": "text/html; charset=utf-8", + "Date": "Tue, 26 Aug 2025 00:29:18 GMT", + "Etag": "W/\"a6274f1d7a1310f3d815b2569beada50\"", + "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", + "Server": "github.com", + "Set-Cookie": "_gh_sess=OueuPF145FfXoPYHlY4GoGsQSkmrMudd71b968pl9P87hezengH%2BW5TcUQeNNijPDNmaWQAdy02a2XN%2FdMu2IQ1222H9m18%2FjrqQ0yUZBBS%2FSDnPb0ygNhIz9%2FPMinIgjzwLvPRN%2Fl1d37seXWwwBe5Bdc2hJHnTuiO5h4ckhSTvwvSpCTRO1CO%2FzoXnS4%2BK15mN0TZet%2Fis2IVhO9Xc7Gw%2BEw%2FMo%2F4TzINjoKRQZK2Pfw%2BxZsv7Ifja0289v3k5yxAsDep7K%2F3%2Bvb3g%2FODPZw%3D%3D--Yz2rsL3uKPttmly6--%2FBBRXRTvoW5%2FzN5WoEeBvA%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.495154076.1756168162; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 00:29:22 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 00:29:22 GMT; HttpOnly; Secure; SameSite=Lax", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "Vary": "X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, Accept-Language,Accept-Encoding, Accept, X-Requested-With", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "deny", + "X-Github-Request-Id": "C994:27323E:4AA865F:4BA9C30:68ACFFE2", + "X-Xss-Protection": "0" + } + } + } + ] +} \ No newline at end of file diff --git a/sdks/python/sample/test_sample.py b/sdks/python/sample/test_sample.py new file mode 100644 index 0000000..e12a587 --- /dev/null +++ b/sdks/python/sample/test_sample.py @@ -0,0 +1,59 @@ +import pytest +import requests +import json +from pathlib import Path + + +from src.test_server_wrapper import TestServer + +SAMPLE_PACKAGE_ROOT = Path(__file__).resolve().parent +CONFIG_FILE_PATH = SAMPLE_PACKAGE_ROOT / "test-data" / "config" / "test-server-config.yml" +RECORDINGS_DIR = SAMPLE_PACKAGE_ROOT / "test-data" / "recordings" + + +class TestSampleWithServer: + """A test suite that requires the test-server to be running.""" + + @pytest.fixture(scope="class", autouse=True) + def managed_server(self, test_server_mode): + """ + A fixture that starts the test-server before any tests in this class run, + and stops it after they have all finished. + + It uses the 'test_server_mode' fixture from conftest.py to determine + whether to run in 'record' or 'replay' mode. + """ + print(f"\n[PyTest] Using test-server mode: '{test_server_mode}'") + + # The TestServer context manager handles start and stop automatically + with TestServer( + config_path=str(CONFIG_FILE_PATH), + recording_dir=str(RECORDINGS_DIR), + mode=test_server_mode + ) as server: + print(f"[PyTest] Test-server started with PID: {server.process.pid}") + # The 'yield' passes control to the tests. The server will be + # stopped automatically when the tests in the class are done. + yield server + + def test_should_receive_200_from_proxied_github(self): + """Tests that a request to the proxy returns a successful response.""" + print("[PyTest] Making request to test-server proxy for www.github.com...") + + # Use the 'requests' library for a simpler HTTP call + response = requests.get("http://localhost:17080/", timeout=10) + + # Pytest uses simple 'assert' statements for checks + assert response.status_code == 200 + assert "github" in json.dumps(dict(response.headers)) + + print("[PyTest] Received 200 OK, content check passed.") + + +class TestAnotherSampleWithoutServer: + """A basic test suite that runs independently of the test-server.""" + + def test_should_run_basic_check(self): + """A simple, independent test case.""" + print("\n[PyTest] Running a test that does not manage the test-server.") + assert True diff --git a/sdks/python/src/__init__.py b/sdks/python/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sdks/python/src/test_server_wrapper.py b/sdks/python/src/test_server_wrapper.py new file mode 100644 index 0000000..102f6ab --- /dev/null +++ b/sdks/python/src/test_server_wrapper.py @@ -0,0 +1,193 @@ +# 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 subprocess +import sys +import threading +import time +import yaml +from pathlib import Path +from typing import Optional +import requests + +PROJECT_NAME = "test-server" + +class TestServer: + """A context manager for running a Go test-server binary.""" + + def __init__(self, config_path: str, recording_dir: str, mode: str = "replay"): + self.config_path = Path(config_path).resolve() + self.recording_dir = Path(recording_dir).resolve() + self.mode = mode + self.process: Optional[subprocess.Popen] = None + self._stdout_thread: Optional[threading.Thread] = None + self._stderr_thread: Optional[threading.Thread] = None + + def _get_binary_path(self) -> Path: + """ + Finds the platform-specific binary. If not found, attempts to run the + installer script. + """ + binary_name = f"{PROJECT_NAME}.exe" if sys.platform == "win32" else PROJECT_NAME + binary_path = Path(__file__).parent.parent / "bin" / binary_name + + # If the binary doesn't exist, try to install it + if not binary_path.exists(): + print(f"test-server binary not found at {binary_path}.") + print("Attempting to run the installer...") + + # Assumes install.py is in the parent directory of the wrapper script + install_script_path = Path(__file__).parent.parent / "install.py" + + if not install_script_path.exists(): + raise FileNotFoundError( + f"Installer script not found at {install_script_path}." + ) + + try: + # Use sys.executable to ensure we run with the same python interpreter + subprocess.run( + [sys.executable, str(install_script_path)], + check=True, # This will raise an exception if the script fails + capture_output=True, # Hides installer output unless there's an error + text=True + ) + print("Installer script finished successfully.") + except subprocess.CalledProcessError as e: + # The installer script returned a non-zero exit code (an error) + print("Installer script failed!", file=sys.stderr) + print(f"STDOUT:\n{e.stdout}", file=sys.stderr) + print(f"STDERR:\n{e.stderr}", file=sys.stderr) + raise RuntimeError("Failed to install the test-server binary.") from e + + if not binary_path.exists(): + raise FileNotFoundError( + f"test-server binary not found at {binary_path} even after " + "running the installer." + ) + + return binary_path + + def _read_stream(self, stream, stream_name: str): + """Reads and prints lines from a subprocess stream.""" + while self.process and self.process.poll() is None: + line = stream.readline() + if not line: + break + print(f"[test-server {stream_name}] {line.strip()}") + + def _health_check(self, url: str, retries: int = 5, delay_sec: float = 0.1): + """Performs a health check with exponential backoff.""" + for i in range(retries): + try: + response = requests.get(url, timeout=1) + response.raise_for_status() + print(f"Health check for {url} passed.") + return + except requests.exceptions.RequestException as e: + print(f"Health check attempt {i + 1} failed for {url}. Error: {e}") + backoff_delay = delay_sec * (2 ** i) + time.sleep(backoff_delay) + + raise TimeoutError(f"Health check failed for {url} after {retries} retries.") + + def start(self): + """Starts the test-server process and waits for it to be healthy.""" + binary_path = self._get_binary_path() + args = [ + str(binary_path), + self.mode, + "--config", + str(self.config_path), + "--recording-dir", + str(self.recording_dir), + ] + + print(f"Starting test-server in '{self.mode}' mode...") + print(f" Command: {' '.join(args)}") + + self.process = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + ) + + self._stdout_thread = threading.Thread( + target=self._read_stream, args=(self.process.stdout, "STDOUT"), daemon=True + ) + self._stderr_thread = threading.Thread( + target=self._read_stream, args=(self.process.stderr, "STDERR"), daemon=True + ) + self._stdout_thread.start() + self._stderr_thread.start() + + # Parse the config file to find the health check URL(s) + print(f"Reading config file for health check endpoints: {self.config_path}") + try: + with open(self.config_path, 'r') as f: + config = yaml.safe_load(f) + + if 'endpoints' in config and config['endpoints']: + for endpoint in config['endpoints']: + # Check if the endpoint has a health check path defined + if 'health' in endpoint and 'source_port' in endpoint: + port = endpoint['source_port'] + path = endpoint['health'] + + # Construct the full URL and run the check + health_url = f"http://localhost:{port}{path}" + print(f"Performing health check on: {health_url}") + self._health_check(health_url) + except FileNotFoundError: + print(f"Config file not found at {self.config_path}. Skipping health check.") + self.stop() + raise FileNotFoundError + except Exception as e: + print(f"Error parsing config file or running health check: {e}") + self.stop() + raise e + + def stop(self): + """Stops the test-server process gracefully.""" + if not self.process or self.process.poll() is not None: + print("Server not running or already stopped.") + return + + print(f"Stopping test-server process (PID: {self.process.pid})...") + self.process.terminate() # Sends SIGTERM (graceful shutdown) + try: + self.process.wait(timeout=5) # Wait up to 5 seconds + print("Server terminated gracefully.") + except subprocess.TimeoutExpired: + print("Server did not respond to SIGTERM. Sending SIGKILL...") + self.process.kill() # Sends SIGKILL (forceful shutdown) + self.process.wait() + print("Server killed.") + + # Clean up the reader threads + if self._stdout_thread: + self._stdout_thread.join() + if self._stderr_thread: + self._stderr_thread.join() + + def __enter__(self): + """Called when entering the 'with' block.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Called when exiting the 'with' block. Ensures cleanup.""" + self.stop() From 6d5ece2613abba136fb9538c7a3cea62403a4af8 Mon Sep 17 00:00:00 2001 From: Wanlin Du Date: Tue, 26 Aug 2025 00:52:38 +0000 Subject: [PATCH 2/4] feat: python wrapper packaging --- .gitignore | 5 +- sdks/python/MANIFEST.in | 6 +++ sdks/python/README.md | 17 +++++++ sdks/python/install.py | 50 ++++++++++++------- sdks/python/pyproject.toml | 36 +++++++++++++ sdks/python/requirements.txt | 2 + sdks/python/sample/README.md | 10 +++- ...d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json | 8 +-- sdks/python/sample/test_sample.py | 2 +- .../src/{ => test_server_sdk}/__init__.py | 0 .../test_server_wrapper.py | 36 ++----------- 11 files changed, 111 insertions(+), 61 deletions(-) create mode 100644 sdks/python/MANIFEST.in create mode 100644 sdks/python/README.md create mode 100644 sdks/python/pyproject.toml create mode 100644 sdks/python/requirements.txt rename sdks/python/src/{ => test_server_sdk}/__init__.py (100%) rename sdks/python/src/{ => test_server_sdk}/test_server_wrapper.py (79%) diff --git a/.gitignore b/.gitignore index f26814a..a914f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,9 @@ sdks/typescript/sample/node_modules/ sdks/typescript/sample/dist/ # Python SDK specific -sdks/python/bin/ -sdks/python/src/__pycache__ +sdks/python/src/test_server_sdk/__pycache__ +sdks/python/__pycache__ +sdks/python/test_server_sdk.egg-info/ # Python SDK Sample specific sdks/python/sample/__pycache__ diff --git a/sdks/python/MANIFEST.in b/sdks/python/MANIFEST.in new file mode 100644 index 0000000..d83c02d --- /dev/null +++ b/sdks/python/MANIFEST.in @@ -0,0 +1,6 @@ +include LICENSE +include README.md +include install.py +include checksums.json + +prune sample diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 0000000..a702a65 --- /dev/null +++ b/sdks/python/README.md @@ -0,0 +1,17 @@ +# Create virtual enviroment +``` +python3 -m venv ~/env +source ~/env/bin/activate +``` + +# Install all the dependencies +``` +pip3 install -r requirements.txt +``` + +# Build python wheel + +``` +rm -rf build/ dist/ *.egg-info/ src/test_server_sdk/bin/ && find . -depth -name "__pycache__" -type d -exec rm -rf {} \; +python3 -m build +``` diff --git a/sdks/python/install.py b/sdks/python/install.py index cb88ad8..d58205c 100644 --- a/sdks/python/install.py +++ b/sdks/python/install.py @@ -22,13 +22,14 @@ import json from pathlib import Path import requests +from setuptools.command.bdist_wheel import bdist_wheel + # --- Configuration --- TEST_SERVER_VERSION = "v0.2.7" GITHUB_OWNER = "google" GITHUB_REPO = "test-server" PROJECT_NAME = "test-server" -BIN_DIR = Path(__file__).parent / "bin" CHECKSUMS_PATH = Path(__file__).parent / "checksums.json" try: @@ -98,26 +99,24 @@ def download_and_verify(download_url, archive_path, version, archive_name): print("Checksum verified successfully.") except Exception as e: - # Clean up partial download on failure if archive_path.exists(): archive_path.unlink() print(f"Failed during download or verification: {e}") raise -def extract_archive(archive_path, archive_extension): - """Extracts the binary from the downloaded archive.""" - print(f"Extracting binary from {archive_path} to {BIN_DIR}...") +def extract_archive(archive_path, archive_extension, destination_dir): + """Extracts the binary from the downloaded archive into the destination.""" + print(f"Extracting binary from {archive_path} to {destination_dir}...") try: if archive_extension == ".zip": with zipfile.ZipFile(archive_path, "r") as zip_ref: - zip_ref.extractall(BIN_DIR) + zip_ref.extractall(destination_dir) elif archive_extension == ".tar.gz": with tarfile.open(archive_path, "r:gz") as tar_ref: - tar_ref.extractall(BIN_DIR) + tar_ref.extractall(destination_dir) print("Extraction complete.") finally: - # Clean up the archive file if archive_path.exists(): archive_path.unlink() print(f"Cleaned up {archive_path}.") @@ -131,29 +130,42 @@ def ensure_binary_is_executable(binary_path, go_os): print(f"Set executable permission for {binary_path}") -def main(): - """Main function to orchestrate the installation.""" +def install_binary(bin_dir: Path): + """Main function to orchestrate the installation to a specific directory.""" go_os, go_arch, archive_extension, binary_name = get_platform_details() - binary_path = BIN_DIR / binary_name + binary_path = bin_dir / binary_name if binary_path.exists(): print(f"{PROJECT_NAME} binary already exists at {binary_path}. Removing it for a fresh install.") - binary_path.unlink() # This deletes the file + binary_path.unlink() + + bin_dir.mkdir(parents=True, exist_ok=True) - BIN_DIR.mkdir(parents=True, exist_ok=True) - version = TEST_SERVER_VERSION archive_name = f"{PROJECT_NAME}_{go_os}_{go_arch}{archive_extension}" download_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{version}/{archive_name}" - archive_path = BIN_DIR / archive_name + archive_path = bin_dir / archive_name try: download_and_verify(download_url, archive_path, version, archive_name) - extract_archive(archive_path, archive_extension) + extract_archive(archive_path, archive_extension, bin_dir) ensure_binary_is_executable(binary_path, go_os) print(f"{PROJECT_NAME} binary is ready at {binary_path}") except Exception as e: - sys.exit(1) # Exit with an error code + print(f"An error occurred during binary installation: {e}") + sys.exit(1) + + +# --- The Setuptools Hook --- +class CustomBuild(bdist_wheel): + """Custom build command to download the binary into the correct build location.""" + def run(self): + print("--- Executing CustomBuild hook to download binary! ---") + + build_py = self.get_finalized_command('build_py') + build_dir = Path(build_py.build_lib) + bin_path = build_dir / 'test_server_sdk' / 'bin' + + install_binary(bin_path) -if __name__ == "__main__": - main() + super().run() diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 0000000..d171ea2 --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel", "twine>=6.1.0", "packaging>=24.2", "pkginfo>=1.12.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "test-server-sdk" +version = "0.1.0" # Required +authors = [ + { name = "Google LLC", email = "googleapis-packages@google.com" }, +] +description = "A python wrapper for test-server." # Required +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.9" +classifiers = [ + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Internet", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "requests", + "PyYAML" +] + +[project.urls] +Homepage = "https://github.com/google/test-server/sdks/python" +Issues = "https://github.com/google/test-server/issues" + +[tool.setuptools] +cmdclass = { bdist_wheel = "install.CustomBuild" } + +[tool.setuptools.packages.find] +where = ["src", "."] +exclude = ["sample*"] diff --git a/sdks/python/requirements.txt b/sdks/python/requirements.txt new file mode 100644 index 0000000..33f05f7 --- /dev/null +++ b/sdks/python/requirements.txt @@ -0,0 +1,2 @@ +requests +PyYAML diff --git a/sdks/python/sample/README.md b/sdks/python/sample/README.md index 0812c54..78e3790 100644 --- a/sdks/python/sample/README.md +++ b/sdks/python/sample/README.md @@ -1,9 +1,15 @@ To run the sample, nevigate to sdks/python/samples ```sh -pytest -sv +# ensure a force update +pip install --force-reinstall ../dist/test_server_sdk-0.1.0-py3-none-any.whl + +# check what is in the package +pip show -f test_server_sdk -# or +# run test with replay mode +pytest -sv +# run test with record mode pytest -sv --record ``` \ No newline at end of file diff --git a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json index ebe42bd..08c0ee0 100644 --- a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json +++ b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json @@ -30,16 +30,16 @@ "Content-Language": "en-US", "Content-Security-Policy": "default-src 'none'; base-uri 'self'; child-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/; connect-src 'self' uploads.github.com www.githubstatus.com collector.github.com raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com *.rel.tunnels.api.visualstudio.com wss://*.rel.tunnels.api.visualstudio.com objects-origin.githubusercontent.com copilot-proxy.githubusercontent.com proxy.individual.githubcopilot.com proxy.business.githubcopilot.com proxy.enterprise.githubcopilot.com *.actions.githubusercontent.com wss://*.actions.githubusercontent.com productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ productionresultssa4.blob.core.windows.net/ productionresultssa5.blob.core.windows.net/ productionresultssa6.blob.core.windows.net/ productionresultssa7.blob.core.windows.net/ productionresultssa8.blob.core.windows.net/ productionresultssa9.blob.core.windows.net/ productionresultssa10.blob.core.windows.net/ productionresultssa11.blob.core.windows.net/ productionresultssa12.blob.core.windows.net/ productionresultssa13.blob.core.windows.net/ productionresultssa14.blob.core.windows.net/ productionresultssa15.blob.core.windows.net/ productionresultssa16.blob.core.windows.net/ productionresultssa17.blob.core.windows.net/ productionresultssa18.blob.core.windows.net/ productionresultssa19.blob.core.windows.net/ github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com insights.github.com wss://alive.github.com wss://alive-staging.github.com api.githubcopilot.com api.individual.githubcopilot.com api.business.githubcopilot.com api.enterprise.githubcopilot.com github.githubassets.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com copilot-workspace.githubnext.com objects-origin.githubusercontent.com; frame-ancestors 'none'; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com www.youtube-nocookie.com; img-src 'self' data: blob: github.githubassets.com media.githubusercontent.com camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com private-avatars.githubusercontent.com github-cloud.s3.amazonaws.com objects.githubusercontent.com release-assets.githubusercontent.com secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ private-user-images.githubusercontent.com opengraph.githubassets.com copilotprodattachments.blob.core.windows.net/github-production-copilot-attachments/ github-production-user-asset-6210df.s3.amazonaws.com customer-stories-feed.github.com spotlights-feed.github.com objects-origin.githubusercontent.com *.githubusercontent.com images.ctfassets.net/8aevphvgewt8/; manifest-src 'self'; media-src github.com user-images.githubusercontent.com/ secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com github-production-user-asset-6210df.s3.amazonaws.com gist.github.com github.githubassets.com assets.ctfassets.net/8aevphvgewt8/ videos.ctfassets.net/8aevphvgewt8/; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com; upgrade-insecure-requests; worker-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/", "Content-Type": "text/html; charset=utf-8", - "Date": "Tue, 26 Aug 2025 00:29:18 GMT", - "Etag": "W/\"a6274f1d7a1310f3d815b2569beada50\"", + "Date": "Tue, 26 Aug 2025 22:40:41 GMT", + "Etag": "W/\"351327fa470a7605c74f926b4af70ad5\"", "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", "Server": "github.com", - "Set-Cookie": "_gh_sess=OueuPF145FfXoPYHlY4GoGsQSkmrMudd71b968pl9P87hezengH%2BW5TcUQeNNijPDNmaWQAdy02a2XN%2FdMu2IQ1222H9m18%2FjrqQ0yUZBBS%2FSDnPb0ygNhIz9%2FPMinIgjzwLvPRN%2Fl1d37seXWwwBe5Bdc2hJHnTuiO5h4ckhSTvwvSpCTRO1CO%2FzoXnS4%2BK15mN0TZet%2Fis2IVhO9Xc7Gw%2BEw%2FMo%2F4TzINjoKRQZK2Pfw%2BxZsv7Ifja0289v3k5yxAsDep7K%2F3%2Bvb3g%2FODPZw%3D%3D--Yz2rsL3uKPttmly6--%2FBBRXRTvoW5%2FzN5WoEeBvA%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.495154076.1756168162; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 00:29:22 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 00:29:22 GMT; HttpOnly; Secure; SameSite=Lax", + "Set-Cookie": "_gh_sess=rFTA7zig94deLAOkLs4%2FcRSwDG4FLxTiv3BO%2BVgZjHZgdUmJ7ZXo1fe%2BlI58D9omDoQqq%2B65V1lmBuDrfQQ3O%2FFiypdEP7r5CkXyfAD9mn7CBVDWx72D1qoX50rZAD6DLAxcAtzbLWaLrnOgs6VFYrP%2F5mR%2BvCDjDUgEK6AkFrV1UmjeTMv39sseCruBS%2F3ITAKxCEdmrD1%2FQqy2rDCoeFljebuPNqRJAIbmglzJiNubu4NamC%2FzPlT%2FgjHOzurh2az5eOZL%2BAZbrj7cPZF2cA%3D%3D--nQ5GLMCHd6zf8Jk8--4h1UFmQGgRymVbTkozwT3g%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.1515273910.1756248052; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 22:40:52 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 22:40:52 GMT; HttpOnly; Secure; SameSite=Lax", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Vary": "X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, Accept-Language,Accept-Encoding, Accept, X-Requested-With", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "deny", - "X-Github-Request-Id": "C994:27323E:4AA865F:4BA9C30:68ACFFE2", + "X-Github-Request-Id": "C9A5:27323E:978C2A9:9A1C750:68AE37F4", "X-Xss-Protection": "0" } } diff --git a/sdks/python/sample/test_sample.py b/sdks/python/sample/test_sample.py index e12a587..8cb9987 100644 --- a/sdks/python/sample/test_sample.py +++ b/sdks/python/sample/test_sample.py @@ -4,7 +4,7 @@ from pathlib import Path -from src.test_server_wrapper import TestServer +from test_server_sdk.test_server_wrapper import TestServer SAMPLE_PACKAGE_ROOT = Path(__file__).resolve().parent CONFIG_FILE_PATH = SAMPLE_PACKAGE_ROOT / "test-data" / "config" / "test-server-config.yml" diff --git a/sdks/python/src/__init__.py b/sdks/python/src/test_server_sdk/__init__.py similarity index 100% rename from sdks/python/src/__init__.py rename to sdks/python/src/test_server_sdk/__init__.py diff --git a/sdks/python/src/test_server_wrapper.py b/sdks/python/src/test_server_sdk/test_server_wrapper.py similarity index 79% rename from sdks/python/src/test_server_wrapper.py rename to sdks/python/src/test_server_sdk/test_server_wrapper.py index 102f6ab..15beb56 100644 --- a/sdks/python/src/test_server_wrapper.py +++ b/sdks/python/src/test_server_sdk/test_server_wrapper.py @@ -40,43 +40,13 @@ def _get_binary_path(self) -> Path: installer script. """ binary_name = f"{PROJECT_NAME}.exe" if sys.platform == "win32" else PROJECT_NAME - binary_path = Path(__file__).parent.parent / "bin" / binary_name + binary_path = Path(__file__).parent / "bin" / binary_name # If the binary doesn't exist, try to install it - if not binary_path.exists(): - print(f"test-server binary not found at {binary_path}.") - print("Attempting to run the installer...") - - # Assumes install.py is in the parent directory of the wrapper script - install_script_path = Path(__file__).parent.parent / "install.py" - - if not install_script_path.exists(): - raise FileNotFoundError( - f"Installer script not found at {install_script_path}." - ) - - try: - # Use sys.executable to ensure we run with the same python interpreter - subprocess.run( - [sys.executable, str(install_script_path)], - check=True, # This will raise an exception if the script fails - capture_output=True, # Hides installer output unless there's an error - text=True - ) - print("Installer script finished successfully.") - except subprocess.CalledProcessError as e: - # The installer script returned a non-zero exit code (an error) - print("Installer script failed!", file=sys.stderr) - print(f"STDOUT:\n{e.stdout}", file=sys.stderr) - print(f"STDERR:\n{e.stderr}", file=sys.stderr) - raise RuntimeError("Failed to install the test-server binary.") from e - if not binary_path.exists(): raise FileNotFoundError( - f"test-server binary not found at {binary_path} even after " - "running the installer." - ) - + f"test-server binary not found at {binary_path}." + ) return binary_path def _read_stream(self, stream, stream_name: str): From 29104b96078d160ab53518816eaf655d968dfa06 Mon Sep 17 00:00:00 2001 From: Wanlin Du Date: Thu, 28 Aug 2025 15:25:02 +0000 Subject: [PATCH 3/4] address comment --- sdks/python/pyproject.toml | 4 +-- sdks/python/sample/conftest.py | 33 +++++++++++++++++++ ...d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json | 8 ++--- sdks/python/sample/test_sample.py | 30 +---------------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index d171ea2..38cf382 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "test-server-sdk" -version = "0.1.0" # Required +version = "0.1.0" authors = [ { name = "Google LLC", email = "googleapis-packages@google.com" }, ] -description = "A python wrapper for test-server." # Required +description = "A python wrapper for test-server." readme = "README.md" license = "Apache-2.0" requires-python = ">=3.9" diff --git a/sdks/python/sample/conftest.py b/sdks/python/sample/conftest.py index 32402f0..5a348b0 100644 --- a/sdks/python/sample/conftest.py +++ b/sdks/python/sample/conftest.py @@ -1,4 +1,17 @@ import pytest +import os +from pathlib import Path +from test_server_sdk.test_server_wrapper import TestServer + + +# 2. Get paths from environment variables or use defaults +config_path_from_env = os.getenv("TEST_CONFIG_PATH") +recordings_dir_from_env = os.getenv("TEST_RECORDINGS_DIR") + +# 3. Set the final paths, converting the string from the env var to a Path object +SAMPLE_PACKAGE_ROOT = Path(__file__).resolve().parent +CONFIG_FILE_PATH = Path(config_path_from_env) if config_path_from_env else SAMPLE_PACKAGE_ROOT / "test-data" / "config" / "test-server-config.yml" +RECORDINGS_DIR = Path(recordings_dir_from_env) if recordings_dir_from_env else SAMPLE_PACKAGE_ROOT / "test-data" / "recordings" def pytest_addoption(parser): """Adds the --record command-line option to pytest.""" @@ -13,3 +26,23 @@ def test_server_mode(request): This fixture can be used by any test. """ return "record" if request.config.getoption("--record") else "replay" + +@pytest.fixture(scope="class") +def managed_server(test_server_mode): + """ + A fixture that starts the test-server before any tests in a class run, + and stops it after they have all finished. + """ + print(f"\n[PyTest] Using test-server mode: '{test_server_mode}'") + + # The TestServer context manager handles start and stop automatically + with TestServer( + config_path=str(CONFIG_FILE_PATH), + recording_dir=str(RECORDINGS_DIR), + mode=test_server_mode + ) as server: + print(f"[PyTest] Test-server started with PID: {server.process.pid}") + # The 'yield' passes control to the tests. + yield server + # Code after yield runs after the last test in the class finishes + print(f"\n[PyTest] Test-server with PID: {server.process.pid} stopped.") diff --git a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json index 08c0ee0..e419af5 100644 --- a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json +++ b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json @@ -30,16 +30,16 @@ "Content-Language": "en-US", "Content-Security-Policy": "default-src 'none'; base-uri 'self'; child-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/; connect-src 'self' uploads.github.com www.githubstatus.com collector.github.com raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com *.rel.tunnels.api.visualstudio.com wss://*.rel.tunnels.api.visualstudio.com objects-origin.githubusercontent.com copilot-proxy.githubusercontent.com proxy.individual.githubcopilot.com proxy.business.githubcopilot.com proxy.enterprise.githubcopilot.com *.actions.githubusercontent.com wss://*.actions.githubusercontent.com productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ productionresultssa4.blob.core.windows.net/ productionresultssa5.blob.core.windows.net/ productionresultssa6.blob.core.windows.net/ productionresultssa7.blob.core.windows.net/ productionresultssa8.blob.core.windows.net/ productionresultssa9.blob.core.windows.net/ productionresultssa10.blob.core.windows.net/ productionresultssa11.blob.core.windows.net/ productionresultssa12.blob.core.windows.net/ productionresultssa13.blob.core.windows.net/ productionresultssa14.blob.core.windows.net/ productionresultssa15.blob.core.windows.net/ productionresultssa16.blob.core.windows.net/ productionresultssa17.blob.core.windows.net/ productionresultssa18.blob.core.windows.net/ productionresultssa19.blob.core.windows.net/ github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com insights.github.com wss://alive.github.com wss://alive-staging.github.com api.githubcopilot.com api.individual.githubcopilot.com api.business.githubcopilot.com api.enterprise.githubcopilot.com github.githubassets.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com copilot-workspace.githubnext.com objects-origin.githubusercontent.com; frame-ancestors 'none'; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com www.youtube-nocookie.com; img-src 'self' data: blob: github.githubassets.com media.githubusercontent.com camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com private-avatars.githubusercontent.com github-cloud.s3.amazonaws.com objects.githubusercontent.com release-assets.githubusercontent.com secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ private-user-images.githubusercontent.com opengraph.githubassets.com copilotprodattachments.blob.core.windows.net/github-production-copilot-attachments/ github-production-user-asset-6210df.s3.amazonaws.com customer-stories-feed.github.com spotlights-feed.github.com objects-origin.githubusercontent.com *.githubusercontent.com images.ctfassets.net/8aevphvgewt8/; manifest-src 'self'; media-src github.com user-images.githubusercontent.com/ secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com github-production-user-asset-6210df.s3.amazonaws.com gist.github.com github.githubassets.com assets.ctfassets.net/8aevphvgewt8/ videos.ctfassets.net/8aevphvgewt8/; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com; upgrade-insecure-requests; worker-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/", "Content-Type": "text/html; charset=utf-8", - "Date": "Tue, 26 Aug 2025 22:40:41 GMT", - "Etag": "W/\"351327fa470a7605c74f926b4af70ad5\"", + "Date": "Thu, 28 Aug 2025 15:24:40 GMT", + "Etag": "W/\"3505858f7df315c1809c68cd0663ee09\"", "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", "Server": "github.com", - "Set-Cookie": "_gh_sess=rFTA7zig94deLAOkLs4%2FcRSwDG4FLxTiv3BO%2BVgZjHZgdUmJ7ZXo1fe%2BlI58D9omDoQqq%2B65V1lmBuDrfQQ3O%2FFiypdEP7r5CkXyfAD9mn7CBVDWx72D1qoX50rZAD6DLAxcAtzbLWaLrnOgs6VFYrP%2F5mR%2BvCDjDUgEK6AkFrV1UmjeTMv39sseCruBS%2F3ITAKxCEdmrD1%2FQqy2rDCoeFljebuPNqRJAIbmglzJiNubu4NamC%2FzPlT%2FgjHOzurh2az5eOZL%2BAZbrj7cPZF2cA%3D%3D--nQ5GLMCHd6zf8Jk8--4h1UFmQGgRymVbTkozwT3g%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.1515273910.1756248052; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 22:40:52 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Wed, 26 Aug 2026 22:40:52 GMT; HttpOnly; Secure; SameSite=Lax", + "Set-Cookie": "_gh_sess=gWyHk%2F902cFd7WxIlvGJMTRuF3jvS3LbxF85QxNnLadSC1t6oR%2BDnDdorRUde5WYb8wGbH3GmMbKKo2%2FKreYBuLhtLCpAoGirlnWF9b9gbKgDGN9P9UP1zLxd%2FrjK5LwRJwFju2779fI%2BigEXuY0u5JBQTFS1w%2BclExPL7CEdzCECTrQ8rIKzpRjgf45LseLROZDpa75%2F2uSiyksPVE4E43NuQmUSf68IhXSbNULS1iQBzapEfmWR%2FkVXfValt20bhCN823Dl74NZHJTghgzxA%3D%3D--O7CXRa1ouohcB2aG--M1G%2Byau3vBwhRvCq28de%2BA%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.2093569977.1756394685; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 15:24:45 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 15:24:45 GMT; HttpOnly; Secure; SameSite=Lax", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Vary": "X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, Accept-Language,Accept-Encoding, Accept, X-Requested-With", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "deny", - "X-Github-Request-Id": "C9A5:27323E:978C2A9:9A1C750:68AE37F4", + "X-Github-Request-Id": "C858:3A38F1:7F0129:81AC1F:68B074BD", "X-Xss-Protection": "0" } } diff --git a/sdks/python/sample/test_sample.py b/sdks/python/sample/test_sample.py index 8cb9987..f13a7ce 100644 --- a/sdks/python/sample/test_sample.py +++ b/sdks/python/sample/test_sample.py @@ -4,38 +4,10 @@ from pathlib import Path -from test_server_sdk.test_server_wrapper import TestServer - -SAMPLE_PACKAGE_ROOT = Path(__file__).resolve().parent -CONFIG_FILE_PATH = SAMPLE_PACKAGE_ROOT / "test-data" / "config" / "test-server-config.yml" -RECORDINGS_DIR = SAMPLE_PACKAGE_ROOT / "test-data" / "recordings" - - +@pytest.mark.usefixtures("managed_server") class TestSampleWithServer: """A test suite that requires the test-server to be running.""" - @pytest.fixture(scope="class", autouse=True) - def managed_server(self, test_server_mode): - """ - A fixture that starts the test-server before any tests in this class run, - and stops it after they have all finished. - - It uses the 'test_server_mode' fixture from conftest.py to determine - whether to run in 'record' or 'replay' mode. - """ - print(f"\n[PyTest] Using test-server mode: '{test_server_mode}'") - - # The TestServer context manager handles start and stop automatically - with TestServer( - config_path=str(CONFIG_FILE_PATH), - recording_dir=str(RECORDINGS_DIR), - mode=test_server_mode - ) as server: - print(f"[PyTest] Test-server started with PID: {server.process.pid}") - # The 'yield' passes control to the tests. The server will be - # stopped automatically when the tests in the class are done. - yield server - def test_should_receive_200_from_proxied_github(self): """Tests that a request to the proxy returns a successful response.""" print("[PyTest] Making request to test-server proxy for www.github.com...") From 00ae38ea085c25bc4854ead045613109f2d0f8ce Mon Sep 17 00:00:00 2001 From: Wanlin Du Date: Thu, 28 Aug 2025 16:33:45 +0000 Subject: [PATCH 4/4] address comment --- sdks/python/README.md | 74 ++++++++++++++++++- ...d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json | 8 +- .../test_server_sdk/test_server_wrapper.py | 4 +- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/sdks/python/README.md b/sdks/python/README.md index a702a65..402a2ab 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -1,17 +1,83 @@ -# Create virtual enviroment +# Build the Python test server sdk + +## Create virtual enviroment ``` python3 -m venv ~/env source ~/env/bin/activate ``` -# Install all the dependencies +## Install all the dependencies ``` pip3 install -r requirements.txt ``` -# Build python wheel +## Build python wheel -``` +```sh +# Ensure a clean build rm -rf build/ dist/ *.egg-info/ src/test_server_sdk/bin/ && find . -depth -name "__pycache__" -type d -exec rm -rf {} \; +# Build the python wheel python3 -m build ``` + +# User the Python test server sdk + +## Installation of the Python Test Server sdk + +```sh +# Install from the dist, use --force-reinstall to alwasy install fresh +pip install --force-reinstall dist/test_server_sdk-0.1.0-py3-none-any.whl + +# Check on the files +pip show -f test_server_sdk +``` +You should see something very similar to this output, note tehat the `test_server_sdk/bin/` folder exist and contains the golang test-server: +``` +Name: test-server-sdk +Version: 0.1.0 +Summary: A python wrapper for test-server. +Home-page: https://github.com/google/test-server/sdks/python +Author: +Author-email: Google LLC +License-Expression: Apache-2.0 +Location: /usr/local/google/home/wanlindu/env/lib/python3.13/site-packages +Requires: PyYAML, requests +Required-by: +Files: + src/test_server_sdk/__init__.py + src/test_server_sdk/__pycache__/__init__.cpython-313.pyc + src/test_server_sdk/__pycache__/test_server_wrapper.cpython-313.pyc + src/test_server_sdk/test_server_wrapper.py + test_server_sdk-0.1.0.dist-info/INSTALLER + test_server_sdk-0.1.0.dist-info/METADATA + test_server_sdk-0.1.0.dist-info/RECORD + test_server_sdk-0.1.0.dist-info/REQUESTED + test_server_sdk-0.1.0.dist-info/WHEEL + test_server_sdk-0.1.0.dist-info/direct_url.json + test_server_sdk-0.1.0.dist-info/licenses/LICENSE + test_server_sdk-0.1.0.dist-info/top_level.txt + test_server_sdk/__init__.py + test_server_sdk/__pycache__/__init__.cpython-313.pyc + test_server_sdk/__pycache__/test_server_wrapper.cpython-313.pyc + test_server_sdk/bin/CHANGELOG.md + test_server_sdk/bin/LICENSE + test_server_sdk/bin/README.md + test_server_sdk/bin/test-server + test_server_sdk/test_server_wrapper.py + ``` + +## Python Configuring the Python Test Server SDK + +The Python `TestServer` is a convenient wrapper around the core Go test-server executable. You can configure it using parameters that directly correspond to the Go server's command-line flags. + +You have the flexibility to provide these settings by passing them directly to the `TestServer` class, using environment variables, or creating custom `pytest` fixtures. + +### Configuration Options + +| Go Flag / ENV | Initialization Parameter | Description | Default Value | Sample Implementation (refer to the `python/sample/conftest.py` file) | +| :--- | :--- | :--- | :--- | :--- | +| `record` / `replay` | **`mode`** | Sets the server to either `'record'` or `'replay'`. | `'replay'` | Set via the `--record` pytest flag. | +| `--config` | **`config_path`** | The file path to the server's configuration file. | -- | Set via environment variable. | +| `--recording-dir` | **`recording_dir`** | The directory for saving or retrieving recordings. | -- | Set via environment variable. | +| -- | **`teardown_timeout`**| An optional grace period (in seconds) to wait before forcefully shutting down the server. | `5` | Left out to use default value | + diff --git a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json index e419af5..544cff9 100644 --- a/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json +++ b/sdks/python/sample/test-data/recordings/396bab503f4b90ad88858bb4467d59b9e827dcd82e33f41c230e1b8fb43bdc8c.json @@ -30,16 +30,16 @@ "Content-Language": "en-US", "Content-Security-Policy": "default-src 'none'; base-uri 'self'; child-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/; connect-src 'self' uploads.github.com www.githubstatus.com collector.github.com raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com *.rel.tunnels.api.visualstudio.com wss://*.rel.tunnels.api.visualstudio.com objects-origin.githubusercontent.com copilot-proxy.githubusercontent.com proxy.individual.githubcopilot.com proxy.business.githubcopilot.com proxy.enterprise.githubcopilot.com *.actions.githubusercontent.com wss://*.actions.githubusercontent.com productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ productionresultssa4.blob.core.windows.net/ productionresultssa5.blob.core.windows.net/ productionresultssa6.blob.core.windows.net/ productionresultssa7.blob.core.windows.net/ productionresultssa8.blob.core.windows.net/ productionresultssa9.blob.core.windows.net/ productionresultssa10.blob.core.windows.net/ productionresultssa11.blob.core.windows.net/ productionresultssa12.blob.core.windows.net/ productionresultssa13.blob.core.windows.net/ productionresultssa14.blob.core.windows.net/ productionresultssa15.blob.core.windows.net/ productionresultssa16.blob.core.windows.net/ productionresultssa17.blob.core.windows.net/ productionresultssa18.blob.core.windows.net/ productionresultssa19.blob.core.windows.net/ github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com insights.github.com wss://alive.github.com wss://alive-staging.github.com api.githubcopilot.com api.individual.githubcopilot.com api.business.githubcopilot.com api.enterprise.githubcopilot.com github.githubassets.com; font-src github.githubassets.com; form-action 'self' github.com gist.github.com copilot-workspace.githubnext.com objects-origin.githubusercontent.com; frame-ancestors 'none'; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com www.youtube-nocookie.com; img-src 'self' data: blob: github.githubassets.com media.githubusercontent.com camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com private-avatars.githubusercontent.com github-cloud.s3.amazonaws.com objects.githubusercontent.com release-assets.githubusercontent.com secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ private-user-images.githubusercontent.com opengraph.githubassets.com copilotprodattachments.blob.core.windows.net/github-production-copilot-attachments/ github-production-user-asset-6210df.s3.amazonaws.com customer-stories-feed.github.com spotlights-feed.github.com objects-origin.githubusercontent.com *.githubusercontent.com images.ctfassets.net/8aevphvgewt8/; manifest-src 'self'; media-src github.com user-images.githubusercontent.com/ secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com github-production-user-asset-6210df.s3.amazonaws.com gist.github.com github.githubassets.com assets.ctfassets.net/8aevphvgewt8/ videos.ctfassets.net/8aevphvgewt8/; script-src github.githubassets.com; style-src 'unsafe-inline' github.githubassets.com; upgrade-insecure-requests; worker-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/", "Content-Type": "text/html; charset=utf-8", - "Date": "Thu, 28 Aug 2025 15:24:40 GMT", - "Etag": "W/\"3505858f7df315c1809c68cd0663ee09\"", + "Date": "Thu, 28 Aug 2025 16:27:20 GMT", + "Etag": "W/\"386abcfaf4ff44a2cbfe6a90d3b1d6bf\"", "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", "Server": "github.com", - "Set-Cookie": "_gh_sess=gWyHk%2F902cFd7WxIlvGJMTRuF3jvS3LbxF85QxNnLadSC1t6oR%2BDnDdorRUde5WYb8wGbH3GmMbKKo2%2FKreYBuLhtLCpAoGirlnWF9b9gbKgDGN9P9UP1zLxd%2FrjK5LwRJwFju2779fI%2BigEXuY0u5JBQTFS1w%2BclExPL7CEdzCECTrQ8rIKzpRjgf45LseLROZDpa75%2F2uSiyksPVE4E43NuQmUSf68IhXSbNULS1iQBzapEfmWR%2FkVXfValt20bhCN823Dl74NZHJTghgzxA%3D%3D--O7CXRa1ouohcB2aG--M1G%2Byau3vBwhRvCq28de%2BA%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.2093569977.1756394685; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 15:24:45 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 15:24:45 GMT; HttpOnly; Secure; SameSite=Lax", + "Set-Cookie": "_gh_sess=LU54Wf8u3%2B7e1lbnM9LXqhwCyguiOMnGz%2FNGGANwWRg6UkobMXf3bqzDnwD64hIz5SAYSEM3L7IW%2FnzMF6AocWUE6uTxfE93SbuD8kvB1sbsBJlG3DKsg9eZicTdzOZihy3aCv1Y7syr0ZSlp3eKncPgpTAYlwoQGkTDoL1ly84KK2%2F9uGLbKTT2Q1dzWO7KAuP8znAQ98y%2FifLijPjexup%2BWvn95A4SCywsc3XcDgHYP%2BFh6na7uM1pO5toR9li4h2DpBlF5vne2m%2B0jDC0Mg%3D%3D--mDFUX5IeIv53eGZl--dmN4h2k8jrFI5%2Be%2FFUNaYw%3D%3D; Path=/; HttpOnly; Secure; SameSite=Lax, _octo=GH1.1.778961946.1756398441; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 16:27:21 GMT; Secure; SameSite=Lax, logged_in=no; Path=/; Domain=github.com; Expires=Fri, 28 Aug 2026 16:27:21 GMT; HttpOnly; Secure; SameSite=Lax", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "Vary": "X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, Accept-Language,Accept-Encoding, Accept, X-Requested-With", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "deny", - "X-Github-Request-Id": "C858:3A38F1:7F0129:81AC1F:68B074BD", + "X-Github-Request-Id": "C832:288B0E:2DB350:2E5DA4:68B08369", "X-Xss-Protection": "0" } } diff --git a/sdks/python/src/test_server_sdk/test_server_wrapper.py b/sdks/python/src/test_server_sdk/test_server_wrapper.py index 15beb56..c7368e3 100644 --- a/sdks/python/src/test_server_sdk/test_server_wrapper.py +++ b/sdks/python/src/test_server_sdk/test_server_wrapper.py @@ -13,6 +13,7 @@ # limitations under the License. import subprocess +import os import sys import threading import time @@ -30,6 +31,7 @@ def __init__(self, config_path: str, recording_dir: str, mode: str = "replay"): self.config_path = Path(config_path).resolve() self.recording_dir = Path(recording_dir).resolve() self.mode = mode + self.teardown_timeout: Optional[int] = 5 self.process: Optional[subprocess.Popen] = None self._stdout_thread: Optional[threading.Thread] = None self._stderr_thread: Optional[threading.Thread] = None @@ -139,7 +141,7 @@ def stop(self): print(f"Stopping test-server process (PID: {self.process.pid})...") self.process.terminate() # Sends SIGTERM (graceful shutdown) try: - self.process.wait(timeout=5) # Wait up to 5 seconds + self.process.wait(timeout=self.teardown_timeout) print("Server terminated gracefully.") except subprocess.TimeoutExpired: print("Server did not respond to SIGTERM. Sending SIGKILL...")