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
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Allow-list: only what the image needs is sent to the build context.
*
!pyproject.toml
!uv.lock
!README.md
!src/
!app/
!.streamlit/
**/__pycache__
**/*.py[cod]
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: uv
directory: /
schedule: { interval: weekly }
groups:
python-deps: { patterns: ["*"] }
- package-ecosystem: docker
directory: /
schedule: { interval: weekly }
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
groups:
actions: { patterns: ["*"] }
162 changes: 162 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: CI

on:
push:
branches: [main]
tags: ["v*.*.*"]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
UV_VERSION: "0.12.15"
IMAGE_NAME: churn-explorer

jobs:
lint:
name: Lint & lockfile
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.1.0
with:
version: ${{ env.UV_VERSION }}
enable-cache: true
- name: Lockfile is up to date
run: uv lock --check
- name: Install dev dependencies
run: uv sync --locked
- name: Ruff lint
run: uv run ruff check --output-format=github .
- name: Ruff format
run: uv run ruff format --check .

test:
name: Tests (Python ${{ matrix.python }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.1.0
with:
version: ${{ env.UV_VERSION }}
python-version: ${{ matrix.python }}
enable-cache: true
- name: Install
run: uv sync --locked
- name: Run tests with coverage
run: uv run pytest --cov-report=xml --junitxml=junit.xml
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v7
with:
name: test-reports-py${{ matrix.python }}
path: |
coverage.xml
junit.xml

docker:
name: Docker build, smoke test & publish
needs: [lint, test]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7

- name: Lower-case image reference
id: ref
run: echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"

- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4

- name: Image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ steps.ref.outputs.image }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}

- name: Build image for smoke test
uses: docker/build-push-action@v7
with:
context: .
load: true
tags: churn-explorer:ci
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Smoke test container
run: |
# the installed package works inside the image
docker run --rm --entrypoint python churn-explorer:ci -m churn_app.sample_data --users 50 --out /tmp/s.parquet
docker run -d --name smoke -p 8501:8501 churn-explorer:ci
for i in $(seq 1 30); do
status=$(docker inspect -f '{{.State.Health.Status}}' smoke)
[ "$status" = "healthy" ] && break
sleep 2
done
docker logs smoke
test "$status" = "healthy"
curl -fsS http://localhost:8501/_stcore/health
curl -fsS http://localhost:8501/ | grep -q "<title>Streamlit</title>"
test "$(docker exec smoke id -u)" = "10001"
docker rm -f smoke

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Log in to Docker Hub (optional)
id: dockerhub
if: github.event_name != 'pull_request' && vars.DOCKERHUB_USERNAME != ''
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Add Docker Hub tags
id: tags
run: |
{
echo 'list<<EOF'
echo "${{ steps.meta.outputs.tags }}"
if [ "${{ steps.dockerhub.outcome }}" = "success" ]; then
echo "${{ steps.meta.outputs.tags }}" | sed "s#^${{ steps.ref.outputs.image }}#docker.io/${{ vars.DOCKERHUB_USERNAME }}/${IMAGE_NAME}#"
fi
echo 'EOF'
} >> "$GITHUB_OUTPUT"

- name: Build and push multi-arch image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.list }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: mode=max
sbom: true
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,12 @@ venv/
build/
dist/

codex_report.md
codex_report.md

# Tooling caches / reports
.pytest_cache/
.ruff_cache/
.coverage
coverage.xml
junit.xml
.claude/
23 changes: 23 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# uv tool install pre-commit && pre-commit install
# Hooks only cover the app/package; the research code is kept byte-for-byte as committed.
files: ^(src|app|tests|\.github|\.streamlit)/|^(pyproject\.toml|uv\.lock|Dockerfile|docker-compose\.yml|Makefile|\.pre-commit-config\.yaml)$
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: check-toml
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-added-large-files
args: ["--maxkb=1024"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.7
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.12.15
hooks:
- id: uv-lock
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
12 changes: 12 additions & 0 deletions .streamlit/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[server]
headless = true
# The full Kaggle train.parquet is ~1 GB; mount it via DATA_DIR instead when possible.
maxUploadSize = 2048
fileWatcherType = "none"

[browser]
gatherUsageStats = false

[theme]
base = "light"
primaryColor = "#d6453d"
69 changes: 69 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1.7
#
# Churn Explorer — Streamlit app image.
#
# docker build -t churn-explorer .
# docker run --rm -p 8501:8501 churn-explorer
# docker run --rm -p 8501:8501 -v "$PWD/churn-prediction-25-26:/data:ro" churn-explorer
#
# Base images are pinned by digest (kept fresh by Dependabot) and Python
# dependencies are installed from uv.lock with --frozen, so rebuilding the same
# commit produces the same environment.

ARG PYTHON_IMAGE=python:3.12-slim-bookworm@sha256:782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.15@sha256:62f8c047d0a0e9ece6b53fc63df902585a67a47a7f318ddec4a37db586edc8e3

FROM ${UV_IMAGE} AS uv

# --------------------------------------------------------------------------- #
FROM ${PYTHON_IMAGE} AS builder

COPY --from=uv /uv /usr/local/bin/uv
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/opt/venv

WORKDIR /src
# Dependencies first: this layer is cached until pyproject.toml / uv.lock change.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project

COPY README.md ./
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable

# --------------------------------------------------------------------------- #
FROM ${PYTHON_IMAGE} AS runtime

LABEL org.opencontainers.image.title="churn-explorer" \
org.opencontainers.image.description="Streamlit explorer and churn model for streaming-service event logs" \
org.opencontainers.image.source="https://github.com/Martinoor/datascience"

RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \
&& mkdir -p /data /app \
&& chown app:app /data /app

COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
COPY --chown=app:app .streamlit ./.streamlit
COPY --chown=app:app app ./app

ENV PATH="/opt/venv/bin:${PATH}" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DATA_DIR=/data \
STREAMLIT_SERVER_PORT=8501 \
STREAMLIT_SERVER_ADDRESS=0.0.0.0

USER app
EXPOSE 8501
VOLUME ["/data"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=4)"]

ENTRYPOINT ["streamlit", "run", "app/streamlit_app.py"]
33 changes: 33 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
IMAGE ?= churn-explorer:local

.PHONY: install lint format test run sample docker-build docker-run clean

install: ## Create .venv from uv.lock (exact versions)
uv sync --locked

lint: ## Ruff lint + format check + lockfile check
uv lock --check
uv run ruff check .
uv run ruff format --check .

format: ## Auto-fix lint and formatting
uv run ruff check --fix .
uv run ruff format .

test: ## Unit + app tests with coverage
uv run pytest

run: ## Start the app on http://localhost:8501
uv run streamlit run app/streamlit_app.py

sample: ## Write a synthetic dataset to data/sample_events.parquet
uv run python -m churn_app.sample_data --users 500 --out data/sample_events.parquet

docker-build: ## Build the container image
docker build -t $(IMAGE) .

docker-run: ## Run the image, mounting ./churn-prediction-25-26 as /data
docker run --rm -p 8501:8501 -v "$(CURDIR)/churn-prediction-25-26:/data:ro" $(IMAGE)

clean:
rm -rf .pytest_cache .ruff_cache .coverage coverage.xml junit.xml
Loading