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
12 changes: 9 additions & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ __pycache__
*$py.class
*.so
.Python
.venv
.venv*
venv/
env/
ENV/
.mypy_cache
.pytest_cache
.mypy_cache/
.pytest_cache/
.ruff_cache/
.uv-cache/
.test-tmp/
.coverage
htmlcov/
test-results/
*.egg-info
dist/
build/
Expand Down
11 changes: 11 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Summary

<!-- What changed, and why? -->

## Testing

<!-- List the automated or manual checks you ran. -->

## Screenshots

<!-- For visible UI changes. Remove this section when it is not applicable. -->
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ jobs:
- name: Build package
run: uv build

docker:
name: Docker / Build and HTTP smoke test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
- name: Build image and test application response
run: python3 scripts/docker_smoke.py

browser-tests:
name: E2E / Selection / ${{ matrix.browser }}
runs-on: ubuntu-latest
Expand Down
39 changes: 6 additions & 33 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,62 +1,35 @@
minimum_pre_commit_version: "4.0.0"
default_install_hook_types: [pre-commit]

# These local hooks intentionally mirror .github/workflows/ci.yml. They run
# against the whole repository so documentation-only commits cannot bypass CI.
# Keep the commit-time feedback fast. Package, Docker, and browser checks run
# in CI and remain available as explicit local commands.
repos:
- repo: local
hooks:
- id: ruff-lint
name: CI / Ruff lint
name: Local / Ruff lint
entry: uv run --locked --extra dev ruff check .
language: system
pass_filenames: false
always_run: true

- id: ruff-format
name: CI / Ruff format
name: Local / Ruff format
entry: uv run --locked --extra dev ruff format --check .
language: system
pass_filenames: false
always_run: true

- id: mypy
name: CI / mypy
name: Local / mypy
entry: uv run --locked --extra dev mypy --no-incremental src/spanselector
language: system
pass_filenames: false
always_run: true

- id: pytest
name: CI / pytest
name: Local / pytest
entry: uv run --locked --extra dev pytest
language: system
pass_filenames: false
always_run: true

- id: build
name: CI / package build
entry: uv build
language: system
pass_filenames: false
always_run: true

- id: e2e-chromium
name: CI / Playwright Chromium
entry: >-
uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e
--browser chromium --no-cov --tracing retain-on-failure
--screenshot only-on-failure
language: system
pass_filenames: false
always_run: true

- id: e2e-firefox
name: CI / Playwright Firefox
entry: >-
uv run --locked --extra dev --extra e2e pytest tests/e2e --run-e2e
--browser firefox --no-cov --tracing retain-on-failure
--screenshot only-on-failure
language: system
pass_filenames: false
always_run: true
20 changes: 17 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,18 @@ uv run spanselector

## Before opening a pull request

Every commit runs the same lint, format, type, test, build, and browser checks as CI. To run the
complete gate without creating a commit:
Every commit runs the fast lint, format, type, and unit-test checks. To run the commit gate without
creating a commit:

```bash
uv run pre-commit run --all-files
```

The individual commands are:
Pull-request CI also builds the package, builds and smoke-tests the Docker image, and runs the
end-to-end suite in Chromium and Firefox. These heavier checks stay out of pre-commit so commits do
not require a running Docker engine or open browser processes.

The fast individual commands are:

```bash
uv run ruff format --check .
Expand All @@ -30,6 +34,16 @@ uv run mypy src/spanselector
uv run pytest
```

The package and Docker checks can still be run locally when relevant:

```bash
uv build
uv run python scripts/docker_smoke.py
```

The Docker smoke test requires a running Docker-compatible engine. It reports an actionable error
when the engine is unavailable and does not attempt any platform-specific startup command.

For changes to browser interactions, install the optional end-to-end dependencies and run the same
pointer interaction in both supported browser engines:

Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ ENV UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HOST=0.0.0.0 \
PORT=8080
PORT=8080 \
SHOW=false

WORKDIR /app

Expand All @@ -20,4 +21,7 @@ USER appuser

EXPOSE 8080

HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD ["/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/', timeout=2)"]

CMD ["/app/.venv/bin/spanselector"]
98 changes: 98 additions & 0 deletions scripts/docker_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import socket
import subprocess
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path

IMAGE_TAG = "spanselector:smoke"
PROJECT_ROOT = Path(__file__).resolve().parents[1]
STARTUP_TIMEOUT_SECONDS = 30


def _available_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])


def _docker(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["docker", *args],
check=check,
cwd=PROJECT_ROOT,
text=True,
)


def _require_docker_daemon() -> None:
try:
result = subprocess.run(
["docker", "info"],
check=False,
capture_output=True,
text=True,
)
except FileNotFoundError:
raise SystemExit("Docker is not installed or is not available on PATH.") from None

if result.returncode == 0:
return

detail = f"{result.stderr}\n{result.stdout}".strip().splitlines()
connection_errors = [line for line in detail if "error during connect" in line.lower()]
reason = connection_errors[-1] if connection_errors else "Docker did not report a server."
raise SystemExit(
"Docker daemon is unavailable. Start your Docker engine or compatible Docker API "
f"provider, wait until it is ready, and retry.\nDocker said: {reason}"
)


def _wait_for_app(url: str) -> None:
deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as response:
body = response.read().decode("utf-8", errors="replace")
if response.status == 200 and "spanselector" in body.lower():
return
except (OSError, urllib.error.URLError) as error:
last_error = error
time.sleep(0.25)
raise RuntimeError(f"Container did not serve spanselector at {url}: {last_error}")


def main() -> None:
_require_docker_daemon()
container_name = f"spanselector-smoke-{uuid.uuid4().hex[:12]}"
host_port = _available_port()
url = f"http://127.0.0.1:{host_port}/"

_docker("build", "--tag", IMAGE_TAG, ".")
try:
_docker(
"run",
"--detach",
"--name",
container_name,
"--publish",
f"127.0.0.1:{host_port}:8080",
"--env",
"SHOW=false",
IMAGE_TAG,
)
_wait_for_app(url)
print(f"Docker smoke test passed: {url}")
except Exception:
_docker("logs", container_name, check=False)
raise
finally:
_docker("rm", "--force", container_name, check=False)


if __name__ == "__main__":
main()