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
14 changes: 12 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,24 @@ an `E2E_BASE_URL`; use `make e2e-azure` only when that environment is available.

```bash
make format # Format code with ruff
make lint # Lint with ruff
make lint # Lint and type check (ruff + mypy)
make typecheck # Type check with mypy
make test # Run tests
make cov # Run tests with coverage
make perf # Check spec-generation performance budgets
make check-all # Run the full local gate
make check-all # Offline gate: lint-workflows + check + test + security
make integration # Real Functions host + Azurite smoke, using free ports
make docs # Strict documentation build, matching CI
make wheel-smoke # Build and test an installed wheel outside the checkout
make verify # Full local gate: check + test + integration + docs + wheel-smoke
```

`make verify` runs these lanes in order and stops on the first failure, even with
parallel make enabled. `make check-all` retains its offline scope; it does not
start services or build docs. Install dependencies before running offline checks.
For integration prerequisites and the one-time smoke venv setup, see the
[Development Guide](docs/development.md#local-smoke-prerequisites).

Before opening a PR, run `ruff format --check <changed-python-files>` (or
`ruff format <changed-python-files>` to fix them). `make format-check` checks
`src` and `tests` without changing files. CI checks those same trees and also
Expand Down
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ cov: ensure-hatch
@echo "Open htmlcov/index.html in your browser to view the coverage report."
@echo "coverage.xml generated for Codecov upload."

.PHONY: integration
integration:
@PYTHON="$(PYTHON)" bash scripts/local-smoke.sh integration

.PHONY: wheel-smoke
wheel-smoke: ensure-hatch
@PYTHON="$(PYTHON)" HATCH="$(abspath $(HATCH))" bash scripts/local-smoke.sh wheel-smoke

.PHONY: verify
verify:
@$(MAKE) check
@$(MAKE) test
@$(MAKE) integration
@$(MAKE) docs
@$(MAKE) wheel-smoke

.PHONY: browser-install test-browser
browser-install:
@npm install --cache .cache/npm --prefix .cache/browser --no-save --package-lock=false playwright@$(PLAYWRIGHT_VERSION)
Expand Down
39 changes: 38 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,15 @@ Use these as the **golden commands** for local validation and CI parity. Prefer
| `make typecheck` | Run mypy type checking |
| `make security` | Run Bandit security scan |
| `make test` | Run pytest |
| `make integration` | Run real Functions host + Azurite smoke on runtime-selected free ports |
| `make wheel-smoke` | Build a wheel, install into a throwaway venv outside the checkout, and generate a spec |
| `make cov` | Run tests with coverage (gate: `fail_under = 95`) |
| `make e2e-local` | Run e2e tests against local Azurite on `:7071` |
| `make e2e-azure` | Run e2e tests against Azure (`E2E_BASE_URL` required) |
| `make lint-workflows` | Lint release workflows, action pins, and the Hatch matrix |
| `make check` | Run `make lint`, which includes the type check, once |
| `make check-all` | Run lint-workflows + check + test + security |
| `make check-all` | Offline gate: lint-workflows + check + test + security |
| `make verify` | Full local gate: check + test + integration + strict docs + wheel-smoke, in order, failing fast |
| `make docs` | Build the MkDocs site with `mkdocs build --strict`, like the CI docs gate |
| `make docs-serve` | Serve the docs locally with live reload |
| `make demo` | Run the Swagger UI and example demos |
Expand Down Expand Up @@ -175,6 +178,40 @@ newer on `PATH` and `.venv/bin/hatch` (created by `make install`) are required:
only a local Functions host needs them. A Python 3.11 that only Hatch manages is not on `PATH`, so
`doctor` reports it as missing.

### Local smoke prerequisites

`make integration` requires Python 3.11+, Bash, curl, Functions Core Tools v4
(`func`), Azurite (`azurite`, or an already installed/cached `npx --no-install azurite`),
and the existing host-smoke app's venv. It fails instead of skipping when a
prerequisite is missing. Prepare that venv once from the repository root:

```bash
python3.11 -m venv tests/host_smoke/.venv
(cd tests/host_smoke && .venv/bin/python -m pip install -r requirements.txt pytest pytest-cov requests)
make integration PYTHON=python3.11
```

Use `PYTHON=<interpreter>` if your default `python3` is older than 3.11.
Integration starts its own host and Azurite with four distinct free ports and
temporary storage, overrides `AzureWebJobsStorage` without modifying settings,
then checks the custom-name route, the route taken from the generated spec,
Swagger UI, and all `host_smoke` route tests via `HOST_SMOKE_BASE_URL`.
Service process groups and temporary data are cleaned on success, failure,
Ctrl-C, or termination. Existing hosts and storage emulators are not touched.

`make wheel-smoke PYTHON=python3.11` builds only the candidate wheel and installs
it with runtime dependencies in a temporary venv outside the repository. It checks
dependencies, imports outside the checkout, and generates the same minimal spec
as CI's artifact smoke. The wheel, venv, and scratch directory are removed on exit.
Dependency installation may need package-index access unless wheels are available
locally (`PIP_NO_INDEX=1 PIP_FIND_LINKS=/path/to/wheels`). Neither smoke uses Azure.

`make verify` runs check, test, integration, strict docs, then wheel-smoke
sequentially, stopping at the first failing lane even under `make -j`.
`make check-all` remains the offline gate (lint-workflows + check + test +
security), with dependencies already installed; it does not invoke host smoke,
docs, or wheel installation.

> For the full release workflow, see [Release Process](release_process.md).

---
Expand Down
156 changes: 156 additions & 0 deletions scripts/local-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
set -euo pipefail

root=$(pwd)
python=${PYTHON:-python3}
fail() { echo "Error: $*" >&2; exit 1; }
command -v "$python" >/dev/null || fail "Python >=3.11 is required (PYTHON=$python)"
"$python" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "Python >=3.11 is required"

tmp=$(mktemp -d "${TMPDIR:-/tmp}/openapi-smoke.XXXXXXXX")
pids=()
cleanup() {
status=$?
trap - EXIT
trap '' INT TERM
for pid in "${pids[@]}"; do kill -TERM -- "-$pid" 2>/dev/null || true; done
if ((${#pids[@]})); then
sleep 1
for pid in "${pids[@]}"; do kill -KILL -- "-$pid" 2>/dev/null || true; done
for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done
fi
if ((status != 0)); then
for log in "$tmp"/*.log; do
if [[ -f "$log" ]]; then echo "--- $log"; cat "$log"; fi
done
fi
rm -rf "$tmp"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# Job control gives each service its own process group, including npm/worker descendants.
set -m
Comment thread
yeongseon marked this conversation as resolved.

# Run a blocking command as a tracked background job and wait for it with the builtin `wait`.
# Under job control a foreground command would sit in its own process group, so Bash would defer
# the INT/TERM trap until it finished; waiting on a job lets the trap fire at once and cleanup
# then terminates the command's process group together with the services.
run() {
"$@" &
local job=$!
pids+=("$job")
local status=0
wait "$job" || status=$?
# Finished: drop it so cleanup never signals a recycled PID.
local kept=() pid
for pid in "${pids[@]}"; do [[ "$pid" == "$job" ]] || kept+=("$pid"); done
pids=("${kept[@]}")
return "$status"
}

case "${1:-}" in
integration)
for tool in func curl; do command -v "$tool" >/dev/null || fail "$tool is required for integration"; done
azurite=(azurite)
if ! command -v azurite >/dev/null; then
command -v npx >/dev/null || fail "azurite or npx is required for integration"
azurite=(npx --no-install azurite)
fi
smoke="$root/tests/host_smoke/.venv/bin/python"
[[ -x "$smoke" ]] || fail "smoke venv missing: create tests/host_smoke/.venv and install tests/host_smoke/requirements.txt, pytest, pytest-cov, requests (see docs/development.md)"
"$smoke" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "smoke venv needs Python >=3.11"
"$smoke" -c 'import azure_functions_openapi, pytest, pytest_cov, requests' || fail "smoke venv dependencies are missing; see docs/development.md"
read -r host_port blob_port queue_port table_port < <("$python" - <<'PY'
from contextlib import ExitStack
import socket

with ExitStack() as stack:
sockets = [stack.enter_context(socket.socket()) for _ in range(4)]
for sock in sockets:
sock.bind(("127.0.0.1", 0))
print(*(sock.getsockname()[1] for sock in sockets))
PY
)
base="http://127.0.0.1:$host_port"
export HOST_SMOKE_BASE_URL="$base"
# Public Azurite development key, not an Azure credential. Override local.settings.json.
export AzureWebJobsStorage="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:$blob_port/devstoreaccount1;QueueEndpoint=http://127.0.0.1:$queue_port/devstoreaccount1;TableEndpoint=http://127.0.0.1:$table_port/devstoreaccount1;"
export FUNCTIONS_WORKER_RUNTIME=python FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1
"${azurite[@]}" --silent --location "$tmp/azurite" \
--blobHost 127.0.0.1 --queueHost 127.0.0.1 --tableHost 127.0.0.1 \
--blobPort "$blob_port" --queuePort "$queue_port" --tablePort "$table_port" >"$tmp/azurite.log" 2>&1 &
azurite_pid=$!
pids+=("$azurite_pid")
(cd tests/host_smoke; export PATH="$PWD/.venv/bin:$PATH"; exec func start --port "$host_port" --verbose) >"$tmp/host.log" 2>&1 &
host_pid=$!
pids+=("$host_pid")
echo "Host smoke: $base (Azurite $blob_port/$queue_port/$table_port)"
ready=false
for ((attempt=0; attempt<60; attempt++)); do
if curl --noproxy '*' --fail --silent --max-time 2 "$base/api/custom_name" --output "$tmp/custom.json"; then ready=true; break; fi
kill -0 "$host_pid" 2>/dev/null || break
kill -0 "$azurite_pid" 2>/dev/null || break
sleep 2
done
[[ "$ready" == true ]] || fail "The custom-name route did not become ready"
# The routes below are HTTP-only and never touch storage, so a dead Azurite would not fail
# them. Check it explicitly before and after the route tests.
kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite is not running; check $tmp/azurite.log"
"$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/custom.json"
curl --noproxy '*' --fail --silent --max-time 10 "$base/api/openapi.json" --output "$tmp/openapi.json"
documented_path=$("$smoke" - "$tmp/openapi.json" <<'PY'
import json
import sys

with open(sys.argv[1]) as source:
spec = json.load(source)
assert spec["openapi"]
paths = [path for path in spec["paths"] if path not in ("/api/openapi.json", "/api/docs")]
assert paths == ["/api/custom_name"], paths
print(paths[0])
PY
)
curl --noproxy '*' --fail --silent --max-time 10 "$base$documented_path" --output "$tmp/documented.json"
"$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/documented.json"
curl --noproxy '*' --fail --silent --max-time 10 "$base/api/docs" --output "$tmp/swagger.html"
grep -q 'SwaggerUIBundle' "$tmp/swagger.html"
run "$smoke" -m pytest tests/host_smoke -m host_smoke --no-cov -rs
kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite stopped while the route tests ran; check $tmp/azurite.log"
if grep -Eq "0 functions (loaded|found \(Worker\))|No job functions found|Executed 'Functions\..*' \(Failed" "$tmp/host.log"; then
fail "The Functions host reported indexing or invocation errors"
fi
;;
wheel-smoke)
: "${HATCH:?HATCH must name the Hatch executable}"
run "$HATCH" build -t wheel "$tmp/dist"
"$python" -m venv "$tmp/venv"
run "$tmp/venv/bin/python" -m pip install --disable-pip-version-check "$tmp"/dist/*.whl
run "$tmp/venv/bin/python" -m pip check
(cd "$tmp"; CHECKOUT="$root" "$tmp/venv/bin/python" -I - <<'PY'
import os
from pathlib import Path

import azure.functions as func
import azure_functions_openapi
from azure_functions_openapi import generate_openapi_spec, openapi, scan_endpoint_metadata

module_path = Path(azure_functions_openapi.__file__).resolve()
print(f"imported module: {module_path}")
assert not module_path.is_relative_to(Path(os.environ["CHECKOUT"]).resolve())
app = func.FunctionApp()

@openapi(summary="Artifact smoke", responses={200: {"description": "OK"}})
@app.route(route="artifact-smoke", methods=["GET"])
def artifact_smoke(req: func.HttpRequest) -> func.HttpResponse:
return func.HttpResponse("ok")

scan_endpoint_metadata(app)
spec = generate_openapi_spec()
assert spec["paths"]["/api/artifact-smoke"]["get"]
PY
)
;;
*) fail "usage: local-smoke.sh integration|wheel-smoke" ;;
esac
134 changes: 134 additions & 0 deletions tests/test_make_smoke_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Local smoke targets remain discoverable, ordered and fail-fast (#811)."""

from __future__ import annotations

import os
from pathlib import Path
import re
import shutil
import subprocess

import pytest

ROOT = Path(__file__).resolve().parents[1]
MAKE = shutil.which("make") or "make"
LANES = ("check", "test", "integration", "docs", "wheel-smoke")
pytestmark = pytest.mark.skipif(shutil.which("make") is None, reason="requires make")


def test_verify_recipe_lists_lanes_in_order() -> None:
# Read the recipe instead of running `make -n verify`: make executes any recipe line that
# contains $(MAKE) even under -n, and `docs` would then really run `pip install -e` in CI.
# Given
makefile = (ROOT / "Makefile").read_text(encoding="utf-8")
# When
match = re.search(r"^verify:[^\n]*\n((?:\t[^\n]*\n)+)", makefile, flags=re.MULTILINE)
# Then
assert match is not None, "verify target not found"
recipe = [line.strip() for line in match.group(1).splitlines()]
assert recipe == [f"@$(MAKE) {lane}" for lane in LANES]


def test_smoke_targets_are_phony_and_visible_in_help() -> None:
# Given
targets = {"integration", "wheel-smoke", "verify"}
makefile = (ROOT / "Makefile").read_text(encoding="utf-8")
# When
phony = {
target
for line in makefile.splitlines()
if line.startswith(".PHONY:")
for target in line.partition(":")[2].split()
}
result = subprocess.run(
[MAKE, "--no-print-directory", "help"],
cwd=ROOT,
capture_output=True,
text=True,
check=True,
)
# Then
assert targets <= phony
assert all(f"make {target}" in result.stdout for target in targets)


@pytest.mark.parametrize("failed_lane", LANES)
def test_verify_stops_at_the_first_failed_lane(tmp_path: Path, failed_lane: str) -> None:
# Given: real make recursion, replacing only the expensive lane recipes.
source = (ROOT / "Makefile").read_text(encoding="utf-8")
source = re.sub(r"^(check|test|wheel-smoke):.*$", r"\1:", source, flags=re.MULTILINE)
(tmp_path / "Makefile").write_text(source, encoding="utf-8")
with (tmp_path / "Makefile").open("a", encoding="utf-8") as makefile:
makefile.write("\nensure-hatch: ; @:\nbootstrap: ; @:\n")
makefile.write(
"".join(f"{lane}: ; @echo {lane}; exit {int(lane == failed_lane)}\n" for lane in LANES)
)
# When
result = subprocess.run(
[MAKE, "--no-print-directory", "verify", "MAKE=make"],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env={key: value for key, value in os.environ.items() if not key.startswith("MAKE")},
)
# Then
assert result.returncode != 0
assert result.stdout.splitlines() == list(LANES[: LANES.index(failed_lane) + 1]), result.stderr


SMOKE_SCRIPT = ROOT / "scripts" / "local-smoke.sh"


def test_smoke_script_checks_azurite_before_and_after_the_route_tests() -> None:
# The routes are HTTP-only, so a dead Azurite would not fail them. The script must check its
# liveness itself, both before running them and after (#811 review).
# Given
lines = SMOKE_SCRIPT.read_text(encoding="utf-8").splitlines()
checks = [
i for i, line in enumerate(lines) if 'kill -0 "$azurite_pid"' in line and "fail" in line
]
pytest_run = next(i for i, line in enumerate(lines) if "-m pytest tests/host_smoke" in line)
# Then
assert any(i < pytest_run for i in checks), "no Azurite liveness check before the route tests"
assert any(i > pytest_run for i in checks), "no Azurite liveness check after the route tests"


@pytest.mark.skipif(os.name != "posix", reason="process groups")
def test_term_during_a_blocking_command_cleans_up_promptly(tmp_path: Path) -> None:
# Under job control a foreground command sits in its own process group, so Bash would defer
# the TERM trap until it finished. A hung Hatch must not block cancellation or leak its
# scratch directory.
# Given: a `hatch` that hangs, and a private TMPDIR so only this run's scratch dir is seen.
import signal
import sys
import time

hatch = tmp_path / "hatch"
hatch.write_text("#!/bin/sh\nexec sleep 300\n", encoding="utf-8")
hatch.chmod(0o755)
scratch = tmp_path / "tmp"
scratch.mkdir()
env = {**os.environ, "PYTHON": sys.executable, "HATCH": str(hatch), "TMPDIR": str(scratch)}
# When
proc = subprocess.Popen( # noqa: S603
["bash", str(SMOKE_SCRIPT), "wheel-smoke"], # noqa: S607
cwd=ROOT,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
try:
deadline = time.monotonic() + 20
while time.monotonic() < deadline and not any(scratch.glob("openapi-smoke.*")):
time.sleep(0.1)
time.sleep(0.5) # let the blocking command start
proc.send_signal(signal.SIGTERM)
# Then
assert proc.wait(timeout=10) == 143
assert not list(scratch.glob("openapi-smoke.*")), "scratch directory was not removed"
finally:
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
Loading