Skip to content

Commit d26939a

Browse files
authored
Add the python sdk wrapper for test-server (#34)
* WIP: add python test-server wrapper. * feat: python wrapper packaging * address comment * address comment
1 parent 963208c commit d26939a

14 files changed

Lines changed: 479 additions & 20 deletions

.gitignore

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,10 @@ sdks/typescript/sample/node_modules/
3232
sdks/typescript/sample/dist/
3333

3434
# Python SDK specific
35-
sdks/python/bin/
35+
sdks/python/src/test_server_sdk/__pycache__
36+
sdks/python/__pycache__
37+
sdks/python/test_server_sdk.egg-info/
38+
39+
# Python SDK Sample specific
40+
sdks/python/sample/__pycache__
41+
sdks/python/sample/.pytest_cache

sdks/python/MANIFEST.in

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
include LICENSE
2+
include README.md
3+
include install.py
4+
include checksums.json
5+
6+
prune sample

sdks/python/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Build the Python test server sdk
2+
3+
## Create virtual enviroment
4+
```
5+
python3 -m venv ~/env
6+
source ~/env/bin/activate
7+
```
8+
9+
## Install all the dependencies
10+
```
11+
pip3 install -r requirements.txt
12+
```
13+
14+
## Build python wheel
15+
16+
```sh
17+
# Ensure a clean build
18+
rm -rf build/ dist/ *.egg-info/ src/test_server_sdk/bin/ && find . -depth -name "__pycache__" -type d -exec rm -rf {} \;
19+
# Build the python wheel
20+
python3 -m build
21+
```
22+
23+
# User the Python test server sdk
24+
25+
## Installation of the Python Test Server sdk
26+
27+
```sh
28+
# Install from the dist, use --force-reinstall to alwasy install fresh
29+
pip install --force-reinstall dist/test_server_sdk-0.1.0-py3-none-any.whl
30+
31+
# Check on the files
32+
pip show -f test_server_sdk
33+
```
34+
You should see something very similar to this output, note tehat the `test_server_sdk/bin/` folder exist and contains the golang test-server:
35+
```
36+
Name: test-server-sdk
37+
Version: 0.1.0
38+
Summary: A python wrapper for test-server.
39+
Home-page: https://github.com/google/test-server/sdks/python
40+
Author:
41+
Author-email: Google LLC <googleapis-packages@google.com>
42+
License-Expression: Apache-2.0
43+
Location: /usr/local/google/home/wanlindu/env/lib/python3.13/site-packages
44+
Requires: PyYAML, requests
45+
Required-by:
46+
Files:
47+
src/test_server_sdk/__init__.py
48+
src/test_server_sdk/__pycache__/__init__.cpython-313.pyc
49+
src/test_server_sdk/__pycache__/test_server_wrapper.cpython-313.pyc
50+
src/test_server_sdk/test_server_wrapper.py
51+
test_server_sdk-0.1.0.dist-info/INSTALLER
52+
test_server_sdk-0.1.0.dist-info/METADATA
53+
test_server_sdk-0.1.0.dist-info/RECORD
54+
test_server_sdk-0.1.0.dist-info/REQUESTED
55+
test_server_sdk-0.1.0.dist-info/WHEEL
56+
test_server_sdk-0.1.0.dist-info/direct_url.json
57+
test_server_sdk-0.1.0.dist-info/licenses/LICENSE
58+
test_server_sdk-0.1.0.dist-info/top_level.txt
59+
test_server_sdk/__init__.py
60+
test_server_sdk/__pycache__/__init__.cpython-313.pyc
61+
test_server_sdk/__pycache__/test_server_wrapper.cpython-313.pyc
62+
test_server_sdk/bin/CHANGELOG.md
63+
test_server_sdk/bin/LICENSE
64+
test_server_sdk/bin/README.md
65+
test_server_sdk/bin/test-server
66+
test_server_sdk/test_server_wrapper.py
67+
```
68+
69+
## Python Configuring the Python Test Server SDK
70+
71+
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.
72+
73+
You have the flexibility to provide these settings by passing them directly to the `TestServer` class, using environment variables, or creating custom `pytest` fixtures.
74+
75+
### Configuration Options
76+
77+
| Go Flag / ENV | Initialization Parameter | Description | Default Value | Sample Implementation (refer to the `python/sample/conftest.py` file) |
78+
| :--- | :--- | :--- | :--- | :--- |
79+
| `record` / `replay` | **`mode`** | Sets the server to either `'record'` or `'replay'`. | `'replay'` | Set via the `--record` pytest flag. |
80+
| `--config` | **`config_path`** | The file path to the server's configuration file. | -- | Set via environment variable. |
81+
| `--recording-dir` | **`recording_dir`** | The directory for saving or retrieving recordings. | -- | Set via environment variable. |
82+
| -- | **`teardown_timeout`**| An optional grace period (in seconds) to wait before forcefully shutting down the server. | `5` | Left out to use default value |
83+

sdks/python/install.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@
2222
import json
2323
from pathlib import Path
2424
import requests
25+
from setuptools.command.bdist_wheel import bdist_wheel
26+
2527

2628
# --- Configuration ---
2729
TEST_SERVER_VERSION = "v0.2.7"
2830
GITHUB_OWNER = "google"
2931
GITHUB_REPO = "test-server"
3032
PROJECT_NAME = "test-server"
31-
BIN_DIR = Path(__file__).parent / "bin"
3233

3334
CHECKSUMS_PATH = Path(__file__).parent / "checksums.json"
3435
try:
@@ -98,26 +99,24 @@ def download_and_verify(download_url, archive_path, version, archive_name):
9899
print("Checksum verified successfully.")
99100

100101
except Exception as e:
101-
# Clean up partial download on failure
102102
if archive_path.exists():
103103
archive_path.unlink()
104104
print(f"Failed during download or verification: {e}")
105105
raise
106106

107107

108-
def extract_archive(archive_path, archive_extension):
109-
"""Extracts the binary from the downloaded archive."""
110-
print(f"Extracting binary from {archive_path} to {BIN_DIR}...")
108+
def extract_archive(archive_path, archive_extension, destination_dir):
109+
"""Extracts the binary from the downloaded archive into the destination."""
110+
print(f"Extracting binary from {archive_path} to {destination_dir}...")
111111
try:
112112
if archive_extension == ".zip":
113113
with zipfile.ZipFile(archive_path, "r") as zip_ref:
114-
zip_ref.extractall(BIN_DIR)
114+
zip_ref.extractall(destination_dir)
115115
elif archive_extension == ".tar.gz":
116116
with tarfile.open(archive_path, "r:gz") as tar_ref:
117-
tar_ref.extractall(BIN_DIR)
117+
tar_ref.extractall(destination_dir)
118118
print("Extraction complete.")
119119
finally:
120-
# Clean up the archive file
121120
if archive_path.exists():
122121
archive_path.unlink()
123122
print(f"Cleaned up {archive_path}.")
@@ -131,29 +130,42 @@ def ensure_binary_is_executable(binary_path, go_os):
131130
print(f"Set executable permission for {binary_path}")
132131

133132

134-
def main():
135-
"""Main function to orchestrate the installation."""
133+
def install_binary(bin_dir: Path):
134+
"""Main function to orchestrate the installation to a specific directory."""
136135
go_os, go_arch, archive_extension, binary_name = get_platform_details()
137-
binary_path = BIN_DIR / binary_name
136+
binary_path = bin_dir / binary_name
138137

139138
if binary_path.exists():
140139
print(f"{PROJECT_NAME} binary already exists at {binary_path}. Removing it for a fresh install.")
141-
binary_path.unlink() # This deletes the file
140+
binary_path.unlink()
141+
142+
bin_dir.mkdir(parents=True, exist_ok=True)
142143

143-
BIN_DIR.mkdir(parents=True, exist_ok=True)
144-
145144
version = TEST_SERVER_VERSION
146145
archive_name = f"{PROJECT_NAME}_{go_os}_{go_arch}{archive_extension}"
147146
download_url = f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}/releases/download/{version}/{archive_name}"
148-
archive_path = BIN_DIR / archive_name
147+
archive_path = bin_dir / archive_name
149148

150149
try:
151150
download_and_verify(download_url, archive_path, version, archive_name)
152-
extract_archive(archive_path, archive_extension)
151+
extract_archive(archive_path, archive_extension, bin_dir)
153152
ensure_binary_is_executable(binary_path, go_os)
154153
print(f"{PROJECT_NAME} binary is ready at {binary_path}")
155154
except Exception as e:
156-
sys.exit(1) # Exit with an error code
155+
print(f"An error occurred during binary installation: {e}")
156+
sys.exit(1)
157+
158+
159+
# --- The Setuptools Hook ---
160+
class CustomBuild(bdist_wheel):
161+
"""Custom build command to download the binary into the correct build location."""
162+
def run(self):
163+
print("--- Executing CustomBuild hook to download binary! ---")
164+
165+
build_py = self.get_finalized_command('build_py')
166+
build_dir = Path(build_py.build_lib)
167+
bin_path = build_dir / 'test_server_sdk' / 'bin'
168+
169+
install_binary(bin_path)
157170

158-
if __name__ == "__main__":
159-
main()
171+
super().run()

sdks/python/pyproject.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0", "wheel", "twine>=6.1.0", "packaging>=24.2", "pkginfo>=1.12.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "test-server-sdk"
7+
version = "0.1.0"
8+
authors = [
9+
{ name = "Google LLC", email = "googleapis-packages@google.com" },
10+
]
11+
description = "A python wrapper for test-server."
12+
readme = "README.md"
13+
license = "Apache-2.0"
14+
requires-python = ">=3.9"
15+
classifiers = [
16+
"Intended Audience :: Developers",
17+
"Operating System :: OS Independent",
18+
"Programming Language :: Python",
19+
"Topic :: Internet",
20+
"Topic :: Software Development :: Libraries :: Python Modules",
21+
]
22+
dependencies = [
23+
"requests",
24+
"PyYAML"
25+
]
26+
27+
[project.urls]
28+
Homepage = "https://github.com/google/test-server/sdks/python"
29+
Issues = "https://github.com/google/test-server/issues"
30+
31+
[tool.setuptools]
32+
cmdclass = { bdist_wheel = "install.CustomBuild" }
33+
34+
[tool.setuptools.packages.find]
35+
where = ["src", "."]
36+
exclude = ["sample*"]

sdks/python/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
requests
2+
PyYAML

sdks/python/sample/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
To run the sample, nevigate to sdks/python/samples
2+
3+
```sh
4+
# ensure a force update
5+
pip install --force-reinstall ../dist/test_server_sdk-0.1.0-py3-none-any.whl
6+
7+
# check what is in the package
8+
pip show -f test_server_sdk
9+
10+
# run test with replay mode
11+
pytest -sv
12+
13+
# run test with record mode
14+
pytest -sv --record
15+
```

sdks/python/sample/__init__.py

Whitespace-only changes.

sdks/python/sample/conftest.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import pytest
2+
import os
3+
from pathlib import Path
4+
from test_server_sdk.test_server_wrapper import TestServer
5+
6+
7+
# 2. Get paths from environment variables or use defaults
8+
config_path_from_env = os.getenv("TEST_CONFIG_PATH")
9+
recordings_dir_from_env = os.getenv("TEST_RECORDINGS_DIR")
10+
11+
# 3. Set the final paths, converting the string from the env var to a Path object
12+
SAMPLE_PACKAGE_ROOT = Path(__file__).resolve().parent
13+
CONFIG_FILE_PATH = Path(config_path_from_env) if config_path_from_env else SAMPLE_PACKAGE_ROOT / "test-data" / "config" / "test-server-config.yml"
14+
RECORDINGS_DIR = Path(recordings_dir_from_env) if recordings_dir_from_env else SAMPLE_PACKAGE_ROOT / "test-data" / "recordings"
15+
16+
def pytest_addoption(parser):
17+
"""Adds the --record command-line option to pytest."""
18+
parser.addoption(
19+
"--record", action="store_true", default=False, help="Run test-server in record mode."
20+
)
21+
22+
@pytest.fixture(scope="session")
23+
def test_server_mode(request):
24+
"""
25+
Returns 'record' or 'replay' based on the --record command-line flag.
26+
This fixture can be used by any test.
27+
"""
28+
return "record" if request.config.getoption("--record") else "replay"
29+
30+
@pytest.fixture(scope="class")
31+
def managed_server(test_server_mode):
32+
"""
33+
A fixture that starts the test-server before any tests in a class run,
34+
and stops it after they have all finished.
35+
"""
36+
print(f"\n[PyTest] Using test-server mode: '{test_server_mode}'")
37+
38+
# The TestServer context manager handles start and stop automatically
39+
with TestServer(
40+
config_path=str(CONFIG_FILE_PATH),
41+
recording_dir=str(RECORDINGS_DIR),
42+
mode=test_server_mode
43+
) as server:
44+
print(f"[PyTest] Test-server started with PID: {server.process.pid}")
45+
# The 'yield' passes control to the tests.
46+
yield server
47+
# Code after yield runs after the last test in the class finishes
48+
print(f"\n[PyTest] Test-server with PID: {server.process.pid} stopped.")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
endpoints:
2+
- target_host: github.com
3+
target_type: https
4+
target_port: 443
5+
source_type: http
6+
source_port: 17080
7+
health: /healthz

0 commit comments

Comments
 (0)