Skip to content
Open
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
23 changes: 23 additions & 0 deletions .github/scripts/runtime-matrix.cjs
Original file line number Diff line number Diff line change
@@ -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));
};
39 changes: 39 additions & 0 deletions .github/scripts/runtime-matrix.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
65 changes: 65 additions & 0 deletions .github/workflows/code-check.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 14 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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);
```
```
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
62 changes: 62 additions & 0 deletions scripts/test-package.py
Original file line number Diff line number Diff line change
@@ -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()
73 changes: 73 additions & 0 deletions test/smoke-package.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion test/test_sailthru_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""
Tests for sailthru_client
"""
from mock import MagicMock
from unittest.mock import MagicMock
import unittest
import sys

Expand Down
29 changes: 7 additions & 22 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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}
Loading