From 70f4fb222bc63b5d77488737e2977d99b82eb9d1 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 19 Oct 2026 10:05:33 -0700 Subject: [PATCH 1/2] [INTST-1757] INTST-1757 Add scheduled CI and package smoke checks for Python client --- .github/workflows/code-check.yml | 48 +++++++++++++++++++++ README.md | 20 ++++++--- pyproject.toml | 3 ++ scripts/test-package.py | 62 +++++++++++++++++++++++++++ test/smoke-package.py | 73 ++++++++++++++++++++++++++++++++ test/test_sailthru_client.py | 2 +- tox.ini | 29 +++---------- 7 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/code-check.yml create mode 100644 pyproject.toml create mode 100644 scripts/test-package.py create mode 100644 test/smoke-package.py diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml new file mode 100644 index 0000000..ded4252 --- /dev/null +++ b/.github/workflows/code-check.yml @@ -0,0 +1,48 @@ +name: Code Quality Check + +on: + push: + branches: [master] + pull_request: + schedule: + # Check runtime compatibility every Monday at 04:47 UTC. + - cron: '47 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Python ${{ matrix.python == '3.x' && 'current' || matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + # 3.x follows new stable Python 3 releases without changing this file. + python: ['3.11', '3.12', '3.13', '3.14', '3.x'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + check-latest: true + allow-prereleases: false + cache: pip + cache-dependency-path: | + pyproject.toml + setup.py + tox.ini + + - name: Install test tooling + run: python -m pip install 'tox>=4.11,<5' + + - name: Test and smoke-test the packaged client + # Generic py uses the interpreter selected by setup-python, including 3.x. + run: python -m tox -e py diff --git a/README.md b/README.md index bf47af1..f470c28 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,28 @@ Python binding for [Sailthru API](http://getstarted.sailthru.com/api) based on [ It will make requests in `JSON` format. -Supports Python 2.6, 2.7, 3.3+ +CI tests Python 3.11, 3.12, 3.13, 3.14, and the latest stable Python 3 release. +These are tested compatibility targets; other runtimes are outside CI coverage. -### Installation (Tested with Python 2.7.x) +### Installation Installing with [pip](http://www.pip-installer.org/): pip install sailthru-client ### Running tests -Install tox and then type: - tox +```bash +python -m pip install 'tox>=4.11,<5' +python -m tox -e py +``` + +This runs unit tests and installs the built wheel and source distribution into +separate temporary consumers for local requests with dummy credentials. It does +not publish. To retain the validated artifacts, use `python -m tox -e py -- --outdir dist`. + +CI runs on PRs, pushes to `master`, weekly, and manual runs. The `3.x` matrix +entry follows the latest stable Python 3 release. ### API Rate Limiting @@ -47,4 +57,4 @@ if rate_limit_info is not None: seconds_till_reset = reset_timestamp - time.time() # sleep or perform other business logic before next user api call time.sleep(seconds_till_reset); -``` \ No newline at end of file +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..642ab3c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/scripts/test-package.py b/scripts/test-package.py new file mode 100644 index 0000000..7a878b6 --- /dev/null +++ b/scripts/test-package.py @@ -0,0 +1,62 @@ +"""Build and smoke-test both Python distributions without publishing them.""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import venv + + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--outdir", type=Path, + help="Keep the validated distributions in this directory for manual publication.", + ) + options = parser.parse_args() + destination = options.outdir.resolve() if options.outdir else None + + with tempfile.TemporaryDirectory(prefix="sailthru-client-smoke-") as temporary: + directory = Path(temporary) + artifacts = directory / "dist" + # Build's default flow builds the wheel from the sdist, checking its contents too. + subprocess.run( + [sys.executable, "-m", "build", "--outdir", str(artifacts)], + cwd=ROOT, check=True, timeout=180, + ) + wheels = list(artifacts.glob("*.whl")) + sources = list(artifacts.glob("*.tar.gz")) + if len(wheels) != 1 or len(sources) != 1: + raise RuntimeError("Expected one wheel and one source distribution") + + for index, artifact in enumerate(wheels + sources): + environment = directory / f"install-{index}" + venv.EnvBuilder(with_pip=True).create(environment) + python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + subprocess.run( + [str(python), "-I", "-m", "pip", "install", + "--disable-pip-version-check", str(artifact)], + cwd=directory, check=True, timeout=180, + ) + # -I prevents checkout paths, user site packages, and PYTHONPATH from masking bugs. + subprocess.run( + [str(python), "-I", str(ROOT / "test" / "smoke-package.py")], + cwd=directory, check=True, timeout=30, + ) + print(f"Package smoke check passed: {artifact.name}", flush=True) + + if destination: + destination.mkdir(parents=True, exist_ok=True) + for artifact in wheels + sources: + shutil.copy2(artifact, destination / artifact.name) + print(f"Validated distributions saved to {destination}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/test/smoke-package.py b/test/smoke-package.py new file mode 100644 index 0000000..56ed2a4 --- /dev/null +++ b/test/smoke-package.py @@ -0,0 +1,73 @@ +"""Exercise the installed library using a loopback server and fake credentials.""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +from pathlib import Path +import re +import sys +from threading import Thread +from urllib.parse import parse_qs + +import sailthru +from sailthru import SailthruClient + + +def check(condition, message): + if not condition: + raise AssertionError(message) + + +def main(): + installed_path = Path(sailthru.__file__).resolve() + check(installed_path.is_relative_to(Path(sys.prefix).resolve()), + f"Client loaded outside the isolated installation: {installed_path}") + failures = [] + user = {"id": "smoke@example.com"} + response = {"ok": True} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + try: + check(self.path == "/user", "Expected the /user endpoint") + body = self.rfile.read(int(self.headers["Content-Length"])) + params = parse_qs(body.decode("utf-8")) + check(params["api_key"] == ["smoke-key"], "API key was not preserved") + check(params["format"] == ["json"], "Expected the JSON response format") + check(json.loads(params["json"][0]) == user, "User payload was not preserved") + check(re.fullmatch(r"[a-f0-9]{32}", params["sig"][0]), + "Missing or malformed signature") + + encoded = json.dumps(response).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + except Exception as error: + failures.append(error) + self.send_error(500, "Smoke request validation failed") + + def log_message(self, format, *args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client = SailthruClient("smoke-key", "smoke-secret", + api_url=f"http://127.0.0.1:{server.server_port}", request_timeout=5) + result = client.api_post("user", user) + if failures: + raise AssertionError("Local server rejected the client request") from failures[0] + check(result.get_status_code() == 200, "Expected a successful HTTP response") + check(result.is_ok(), "Client reported an unsuccessful response") + check(result.get_body() == response, "Client did not parse the expected response") + print("Packaged client imported and completed a local API request.") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/test/test_sailthru_client.py b/test/test_sailthru_client.py index 164ed88..c3f2e1b 100644 --- a/test/test_sailthru_client.py +++ b/test/test_sailthru_client.py @@ -2,7 +2,7 @@ """ Tests for sailthru_client """ -from mock import MagicMock +from unittest.mock import MagicMock import unittest import sys diff --git a/tox.ini b/tox.ini index b070a35..2b308e1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,11 @@ [tox] -envlist = - py26, py27, py34 +min_version = 4.11 +env_list = py311, py312, py313, py314, py315 [testenv] -commands = nosetests -v +package = wheel deps = - nose==1.3.4 - mock==1.0.1 - -rrequirements.txt - -[testenv:py26] -basepython = python2.6 -deps = - simplejson==3.6.5 - {[testenv]deps} - -[testenv:py27] -basepython = python2.7 -deps = - {[testenv]deps} - -[testenv:py34] -basepython = python3.4 -deps = - {[testenv]deps} + build >= 1.2 +commands = + python -m unittest discover -s test -v + python scripts/test-package.py {posargs} From 9d9bbb9ebb39783a7df4feaf18e208a3f277a835 Mon Sep 17 00:00:00 2001 From: kishore Date: Mon, 26 Oct 2026 07:51:31 -0700 Subject: [PATCH 2/2] [INTST-1757] INTST-1757 Discover complete stable runtime matrices for CI --- .github/scripts/runtime-matrix.cjs | 23 +++++++++++++++ .github/scripts/runtime-matrix.test.cjs | 39 +++++++++++++++++++++++++ .github/workflows/code-check.yml | 25 +++++++++++++--- README.md | 5 ++-- 4 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/runtime-matrix.cjs create mode 100644 .github/scripts/runtime-matrix.test.cjs diff --git a/.github/scripts/runtime-matrix.cjs b/.github/scripts/runtime-matrix.cjs new file mode 100644 index 0000000..08570a1 --- /dev/null +++ b/.github/scripts/runtime-matrix.cjs @@ -0,0 +1,23 @@ +// Discover every stable release line from the existing CI minimum. +const metadataUrl = 'https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json'; +const compare = (a, b) => a.localeCompare(b, 'en', {numeric: true}); + +module.exports = async function discover({core, fetch = globalThis.fetch}) { + const response = await fetch(metadataUrl, { + headers: {'User-Agent': 'sailthru-ci-runtime-matrix'}, + signal: AbortSignal.timeout(30000) + }); + if (!response.ok) throw new Error(`Runtime metadata request failed: ${response.status}`); + const releases = await response.json(); + const versions = releases + .filter(release => release.stable === true && /^\d+\.\d+\.\d+$/.test(release.version)) + .map(release => release.version.split('.').slice(0, 2).join('.')) + .filter(version => compare(version, '3.11') >= 0); + + const matrix = [...new Set(versions)].sort(compare); + if (!matrix.includes('3.11') || matrix.length > 256) { + throw new Error('Runtime matrix is incomplete or exceeds the GitHub Actions limit'); + } + core.info(`Testing python release lines: ${matrix.join(', ')}`); + core.setOutput('versions', JSON.stringify(matrix)); +}; diff --git a/.github/scripts/runtime-matrix.test.cjs b/.github/scripts/runtime-matrix.test.cjs new file mode 100644 index 0000000..3bc8beb --- /dev/null +++ b/.github/scripts/runtime-matrix.test.cjs @@ -0,0 +1,39 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const discover = require('./runtime-matrix.cjs'); + +async function resolve(releases) { + let output; + await discover({ + core: {info() {}, setOutput(name, value) { + assert.equal(name, 'versions'); + output = JSON.parse(value); + }}, + fetch: async () => ({ok: true, json: async () => releases}) + }); + return output; +} + +test('includes intermediate and future stable releases, excludes prereleases and duplicates', async () => { + const releases = [ + ...Array.from({length: 8}, (_, index) => ({version: `3.${index + 10}.0`, stable: true})), + {version: '3.15.1', stable: true}, {version: '3.18.0rc1', stable: false}, + {version: '3.19.0', stable: false}, {version: '4.0.0', stable: true} + ]; + assert.deepEqual(await resolve(releases), ['3.11', '3.12', '3.13', '3.14', '3.15', '3.16', '3.17', '4.0']); +}); + +test('fails rather than quietly running an empty or incomplete matrix', async () => { + await assert.rejects(resolve([])); + await assert.rejects(resolve([{version: '4.0.0', stable: true}])); + await assert.rejects(resolve(null)); +}); + +test('metadata HTTP, JSON and network failures fail discovery without emitting a matrix', async () => { + const core = {info() {}, setOutput() {assert.fail('Must not emit a fallback matrix');}}; + await assert.rejects(discover({core, fetch: async () => ({ok: false, status: 503})}), /503/); + await assert.rejects(discover({core, fetch: async () => ({ + ok: true, json: async () => {throw new SyntaxError('Invalid JSON');} + })}), /Invalid JSON/); + await assert.rejects(discover({core, fetch: async () => {throw new Error('Network failure');}}), /Network failure/); +}); diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml index ded4252..b869fc1 100644 --- a/.github/workflows/code-check.yml +++ b/.github/workflows/code-check.yml @@ -13,16 +13,33 @@ permissions: contents: read jobs: + discover: + name: Discover runtimes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + versions: ${{ steps.versions.outputs.versions }} + steps: + - uses: actions/checkout@v4 + - name: Test discovery and list stable runtimes + id: versions + uses: actions/github-script@v7 + with: + script: | + require('node:child_process').execFileSync(process.execPath, + ['--test', '.github/scripts/runtime-matrix.test.cjs'], {stdio: 'inherit'}); + await require('./.github/scripts/runtime-matrix.cjs')({core}); + build: - name: Python ${{ matrix.python == '3.x' && 'current' || matrix.python }} + needs: discover + name: Python ${{ matrix.python }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: - # 3.x follows new stable Python 3 releases without changing this file. - python: ['3.11', '3.12', '3.13', '3.14', '3.x'] + python: ${{ fromJSON(needs.discover.outputs.versions) }} steps: - name: Checkout @@ -44,5 +61,5 @@ jobs: run: python -m pip install 'tox>=4.11,<5' - name: Test and smoke-test the packaged client - # Generic py uses the interpreter selected by setup-python, including 3.x. + # Generic py uses the interpreter selected by setup-python. run: python -m tox -e py diff --git a/README.md b/README.md index f470c28..5a76746 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Python binding for [Sailthru API](http://getstarted.sailthru.com/api) based on [ It will make requests in `JSON` format. -CI tests Python 3.11, 3.12, 3.13, 3.14, and the latest stable Python 3 release. +CI discovers every stable Python release line from 3.11 through the latest release. These are tested compatibility targets; other runtimes are outside CI coverage. ### Installation @@ -31,8 +31,7 @@ This runs unit tests and installs the built wheel and source distribution into separate temporary consumers for local requests with dummy credentials. It does not publish. To retain the validated artifacts, use `python -m tox -e py -- --outdir dist`. -CI runs on PRs, pushes to `master`, weekly, and manual runs. The `3.x` matrix -entry follows the latest stable Python 3 release. +CI runs on PRs, pushes to `master`, weekly, and manual runs. ### API Rate Limiting