Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,10 @@ sdks/typescript/sample/node_modules/
sdks/typescript/sample/dist/

# Python SDK specific
sdks/python/bin/
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__
sdks/python/sample/.pytest_cache
6 changes: 6 additions & 0 deletions sdks/python/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
include LICENSE
include README.md
include install.py
include checksums.json

prune sample
83 changes: 83 additions & 0 deletions sdks/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Build the Python test server sdk

## Create virtual enviroment
```
python3 -m venv ~/env
source ~/env/bin/activate
```

## Install all the dependencies
```
pip3 install -r requirements.txt
```

## 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 <googleapis-packages@google.com>
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 |

50 changes: 31 additions & 19 deletions sdks/python/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}.")
Expand All @@ -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()
36 changes: 36 additions & 0 deletions sdks/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
authors = [
{ name = "Google LLC", email = "googleapis-packages@google.com" },
]
description = "A python wrapper for test-server."
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*"]
2 changes: 2 additions & 0 deletions sdks/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
requests
PyYAML
15 changes: 15 additions & 0 deletions sdks/python/sample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
To run the sample, nevigate to sdks/python/samples

```sh
# 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

# run test with replay mode
pytest -sv

# run test with record mode
pytest -sv --record
```
Empty file added sdks/python/sample/__init__.py
Empty file.
48 changes: 48 additions & 0 deletions sdks/python/sample/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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."""
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"

@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.")
7 changes: 7 additions & 0 deletions sdks/python/sample/test-data/config/test-server-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
endpoints:
- target_host: github.com
target_type: https
target_port: 443
source_type: http
source_port: 17080
health: /healthz
Original file line number Diff line number Diff line change
@@ -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": "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=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": "C832:288B0E:2DB350:2E5DA4:68B08369",
"X-Xss-Protection": "0"
}
}
}
]
}
Loading
Loading