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
44 changes: 44 additions & 0 deletions .github/workflows/ci-cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI/CD Pipeline

on:
push:
branches:
- '**'
tags:
- 'v*.*.*'
pull_request:
branches:
- '**'
workflow_dispatch:

jobs:
cache:
uses: milsman2/python-app-template/.github/workflows/cache-uv-build.yaml@main

lint:
uses: milsman2/python-app-template/.github/workflows/ruff.yaml@main
needs: cache

test:
needs: [lint, cache]
uses: milsman2/python-app-template/.github/workflows/pytest.yaml@main

docker-build-and-image-scan:
if: github.event_name == 'push'
needs: test
uses: milsman2/python-app-template/.github/workflows/docker-build-and-scan.yaml@main
with:
DOCKER_PATH_CONTEXT: .
DOCKER_BUILD_DOCKERFILE: ./Dockerfile
DOCKER_TAGS: ${{ github.repository }}:${{ github.sha }}
DOCKER_LOAD_BOOL: false
DOCKER_PUSH_BOOL: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
secrets: inherit

release:
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [test, docker-build-and-image-scan]
uses: milsman2/python-app-template/.github/workflows/release.yaml@main
permissions:
contents: write
secrets: inherit
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_NO_DEV=1
ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked

FROM python:3.13-slim-bookworm AS runtime
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 999 nonroot \
&& useradd --system --gid 999 --uid 999 --create-home nonroot
COPY --from=builder --chown=nonroot:nonroot /app/ /app
ENV PATH="/app/.venv/bin:$PATH"
USER nonroot
WORKDIR /app/src
CMD ["python", "-m", "sample_python_app.main"]
74 changes: 73 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,74 @@
# python-response-time
Python-based response time module

A Pythonic, DevOps-friendly HTTP benchmarking tool leveraging Pydantic v2 for robust configuration, Loguru for structured logging, and a modern, testable workflow.

## Features

- **Configurable HTTP benchmarking** via environment variables or `.env` file (Pydantic v2 + pydantic-settings)
- **Structured logging** with Loguru (console and file, colorized, with rotation)
- **Pre-flight DevOps checks**: auto-formatting, linting, and test coverage in one command
- **Modern Python packaging**: `pyproject.toml`-based, ready for CI/CD
- **Type-safe, validated settings**: all config is validated at startup
- **Tested and type-checked**: includes pytest-based tests for all config
- **CLI entrypoints** for both benchmarking and checks

## Quickstart

```bash
# Install with all dev dependencies
uv pip install -e '.[build]'

# Run the HTTP benchmark
uv run python-response-time

# Run all pre-flight DevOps checks (format, lint, test)
uv run checks
```

## Configuration

All settings are managed with Pydantic v2 and can be set via environment variables or a `.env` file:

| Variable | Type | Default | Description |
|---------------|--------|--------------------------|------------------------------------|
| TARGET_URL | str | https://httpbin.org/get | Target endpoint for benchmarking |
| NUM_REQUESTS | int | 10 | Total number of requests |
| CONCURRENCY | int | 2 | Number of concurrent requests |
| TIMEOUT | float | 10.0 | Request timeout (seconds) |
| LOG_LEVEL | str | INFO | Log level (DEBUG, INFO, etc.) |

Example `.env`:

```
TARGET_URL=https://example.com/api
NUM_REQUESTS=100
CONCURRENCY=5
TIMEOUT=5.0
LOG_LEVEL=DEBUG
```

## DevOps & Pythonic Practices

- **Pre-flight checks**: One command (`uv run checks`) runs ruff, isort, black, and pytest with coverage.
- **Strict config validation**: Pydantic v2 ensures all settings are valid before running.
- **Logging**: Loguru provides both human-friendly console logs and persistent file logs with rotation and compression.
- **Modern packaging**: Uses `pyproject.toml` for dependencies, scripts, and tool config.
- **CI/CD ready**: Semantic release and coverage tools are pre-configured.
- **Type annotations**: All code is type-annotated for clarity and safety.

## Project Structure

```
src/python_response_time/
main.py # Benchmark runner
pre_flight.py # DevOps checks
core/
config.py # Pydantic v2 settings
logging.py # Loguru setup
tests/
test_basic.py # Pytest-based config tests
```

## License

MIT
60 changes: 60 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
[project]
name = "python-response-time"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"black>=26.3.1",
"coverage>=7.13.5",
"isort>=8.0.1",
"loguru>=0.7.3",
"pydantic>=2.12.5",
"pydantic-settings>=2.13.1",
"pytest>=9.0.2",
"requests>=2.32.5",
"rich>=14.3.3",
"ruff>=0.15.7",
"typer>=0.24.1",
]

[project.optional-dependencies]
build = ["uv == 0.10.12"]

[build-system]
requires = ["uv_build == 0.10.12"]
build-backend = "uv_build"

[project.scripts]
python-response-time = "python_response_time.main:run_app"
checks = "python_response_time.pre_flight:run_checks"

[tool.semantic_release]
allow_version_zero = true
build_command = """
python -m pip install -e '.[build]'
uv lock --upgrade-package "$PACKAGE_NAME"
git add uv.lock
uv build
"""
version_toml = ["pyproject.toml:project.version"]

[tool.coverage.run]
source = ["src"]

[tool.coverage.report]
omit = ["tests/*"]

[tool.black]
target-version = ["py313"]

[tool.ruff]
line-length = 88
exclude = ["__pycache__", "build", "dist", ".venv"]

[tool.ruff.lint]
select = ["E", "F", "W", "C", "B", "I", "N", "D", "UP", "T", "A"]
ignore = []

[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401"]
1 change: 1 addition & 0 deletions src/python_response_time/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""python_response_time package."""
6 changes: 6 additions & 0 deletions src/python_response_time/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Core module for Python Response Time package."""

from python_response_time.core.config import app_settings
from python_response_time.core.logging import setup_logger

__all__ = ["app_settings", "setup_logger"]
36 changes: 36 additions & 0 deletions src/python_response_time/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Configuration settings for the HTTP benchmark."""

from typing import Annotated

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Configuration settings for the HTTP benchmark."""

TARGET_URL: Annotated[
str, Field(description="Target endpoint for benchmarking")
] = "https://httpbin.org/get"
NUM_REQUESTS: Annotated[
int, Field(gt=0, le=1_000_000, description="Total number of requests")
] = 10
CONCURRENCY: Annotated[
int, Field(gt=0, le=10_000, description="Concurrent requests")
] = 2
TIMEOUT: Annotated[
float, Field(gt=0, le=120, description="Request timeout in seconds")
] = 10.0
LOG_LEVEL: Annotated[
str, Field(pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
] = "INFO"

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="forbid",
validate_default=True,
)


app_settings = Settings()
53 changes: 53 additions & 0 deletions src/python_response_time/core/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Loguru logger configuration for python-app-template."""

import sys

from loguru import logger

log_format = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
"<level>{message}</level>"
)


def setup_logger(level: str = "INFO"):
"""Configure and return a Loguru logger instance.

Args:
level (str, optional): Logging level for file outputs (e.g., "INFO", "DEBUG",
"ERROR"). Defaults to "INFO".

Returns:
logger: Configured Loguru logger instance.

"""
logger.remove()
if level.upper() == "SILENT":
logger.add(
"app.log",
format=log_format,
level="ERROR",
rotation="1 MB",
retention="10 days",
compression="zip",
)
else:
logger.add(
sys.stdout,
format=log_format,
level=level,
backtrace=True,
diagnose=True,
enqueue=True,
)
logger.add(
"app.log",
format=log_format,
level=level,
rotation="1 MB",
retention="10 days",
compression="zip",
)
return logger
32 changes: 32 additions & 0 deletions src/python_response_time/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Main entry point for the Python Response Time application."""

import time

import requests
from loguru import logger

from python_response_time.core import app_settings, setup_logger

setup_logger(app_settings.LOG_LEVEL)


def run_app():
"""Run a simple HTTP benchmark against the configured target URL."""
logger.info("Starting HTTP benchmark...")
for i in range(app_settings.NUM_REQUESTS):
start_time = time.time()
try:
response = requests.get(
str(app_settings.TARGET_URL), timeout=app_settings.TIMEOUT
)
response.raise_for_status()
elapsed_time_ms = (time.time() - start_time) * 1000
logger.info(
f"Request {i + 1}: {response.status_code} - {elapsed_time_ms:.2f} ms"
)
except requests.RequestException as e:
logger.error(f"Request {i + 1} failed: {e}")


if __name__ == "__main__":
run_app()
37 changes: 37 additions & 0 deletions src/python_response_time/pre_flight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Pre-flight checks for Python Response Time package."""

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

from loguru import logger

from python_response_time.core.logging import setup_logger

ROOT = Path(__file__).resolve().parents[2]

setup_logger("DEBUG")


def _run(cmd: list[str]) -> None:
logger.info(f"Running: {' '.join(cmd)}")
subprocess.check_call(cmd, cwd=str(ROOT))


def run_checks() -> None:
"""Run ruff, isort, black and tests (coverage+pytest).

This is intended to be invoked via the project script entrypoint, e.g.:
`uv run checks` or `python -m python_response_time.pre_flight run_checks`
when installed.
"""
py = sys.executable
_run([py, "-m", "ruff", "check", ".", "--fix", "--exit-zero"])
_run([py, "-m", "isort", "."])
_run([py, "-m", "black", "."])
_run([py, "-m", "ruff", "check", ".", "--exit-zero"])
_run([py, "-m", "coverage", "run", "-m", "pytest"])

logger.info("All checks completed.")
Loading
Loading