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 new file mode 100644 index 0000000..b869fc1 --- /dev/null +++ b/.github/workflows/code-check.yml @@ -0,0 +1,65 @@ +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: + 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: + needs: discover + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + strategy: + fail-fast: false + matrix: + python: ${{ fromJSON(needs.discover.outputs.versions) }} + + 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. + run: python -m tox -e py diff --git a/README.md b/README.md index bf47af1..5a76746 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,27 @@ 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 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 (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. ### API Rate Limiting @@ -47,4 +56,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}