diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fbbb0d6..3408527 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,24 +1,17 @@ FROM python:3.11-slim -# Basic OS deps +ARG UV_VERSION=0.10.0 + RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - git \ - make \ - build-essential \ + && apt-get install -y --no-install-recommends git make build-essential \ + && python -m pip install --no-cache-dir "uv==${UV_VERSION}" \ && rm -rf /var/lib/apt/lists/* -# Upgrade pip tooling -RUN python -m pip install --upgrade pip setuptools wheel - -# Create non-root user with fixed home directory RUN groupadd -r app && useradd -r -g app -d /home/app -m app -# Set environment for the user -ENV HOME=/home/app +ENV HOME=/home/app \ + PYTHONNOUSERSITE=1 \ + UV_LINK_MODE=copy -# Devcontainers typically mount the repo; keep workdir generic WORKDIR /work - -# Switch to non-root user USER app diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 537c6fa..ff9f932 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,12 +4,12 @@ "dockerfile": "Dockerfile", "context": ".." }, + "postCreateCommand": "if [ -f uv.lock ]; then uv sync --group dev --group docs --frozen; else uv sync --group dev --group docs; fi", "customizations": { "vscode": { "extensions": [ "ms-python.python", "ms-python.vscode-pylance", - "ms-python.black-formatter", "charliermarsh.ruff", "ms-toolsai.jupyter" ] diff --git a/.dockerignore b/.dockerignore index 23d7d51..9ccfa42 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,10 @@ -# Python bytecode / caches +.git/ +.venv/ __pycache__/ *.py[cod] - -# Test caches .pytest_cache/ - -# Packaging artifacts +.ruff_cache/ *.egg-info/ build/ dist/ - -# Docs build output docs/build/ - -# VCS -.git/ - -# Local automation state -.sisyphus/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46f3f5f..99cbd60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,25 +2,16 @@ name: CI on: push: - branches: [ main, master, develop ] + branches: [main] pull_request: - branches: [ main, master, develop ] + branches: [main] jobs: - test: + verify: runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Verify (Docker build + pytest + app/main.py) - run: make docker-verify - - - name: Check for workspace changes - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "Error: Workspace modified by verification. Run 'make docker-verify' locally and commit the changes." - git status --porcelain - exit 1 - fi + - uses: actions/checkout@v4 + - name: Canonical verification + run: make verify + - name: Assert verification did not mutate checkout + run: test -z "$(git status --porcelain)" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e44d9a3..27778ac 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,10 +1,8 @@ -name: Deploy Docs to GitHub Pages +name: Docs on: push: - branches: - - main - - master + branches: [main] workflow_dispatch: permissions: @@ -12,51 +10,41 @@ permissions: pages: write id-token: write -# Allow only one concurrent deployment concurrency: - group: "pages" + group: pages cancel-in-progress: false -env: - PYTHON_VERSION: '3.11' - DOCS_DIR: docs/build/html - jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: ${{ env.PYTHON_VERSION }} - cache: 'pip' - - - name: Install dependencies + python-version: "3.11" + - name: Install uv + run: python -m pip install "uv==0.10.0" + - name: Build docs run: | - pip install -U pip - pip install '.[docs]' - - - name: Build documentation - run: make -C docs apidoc html - - - name: Setup Pages - uses: actions/configure-pages@v5 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + if [ -f uv.lock ]; then + uv sync --group docs --frozen + else + echo "WARNING: uv.lock missing; resolving dependencies" + uv sync --group docs + fi + uv run --no-sync sphinx-build -W -b html docs/source docs/build/html + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 with: - path: ${{ env.DOCS_DIR }} + path: docs/build/html deploy: + needs: build + runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build steps: - - name: Deploy to GitHub Pages + - name: Deploy id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 700864e..2024f83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,213 +1,15 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation +__pycache__/ +*.py[cod] +*.so +.venv/ +.env +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +build/ +dist/ +*.egg-info/ docs/build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -.vscode/ - -# Tool state -.sisyphus/ - -# OS generated files +.ipynb_checkpoints/ .DS_Store - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/LICENSE b/LICENSE index 82537a3..b3930bb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 fabian - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 fabian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 4b6f50d..58bdc61 100644 --- a/Makefile +++ b/Makefile @@ -1,52 +1,75 @@ IMAGE_TAG ?= science-template:verify +UV ?= uv -.PHONY: docker-build docker-verify docker-verify-rw clean +.PHONY: init lock sync lint test docs smoke local-verify verify docker-build docker-verify docker-verify-rw clean + +init: + python scripts/init_project.py + +lock: + $(UV) lock + +sync: + @if [ -f uv.lock ]; then \ + $(UV) sync --group dev --group docs --frozen; \ + else \ + echo "WARNING: uv.lock missing; resolving dependencies from pyproject.toml"; \ + $(UV) sync --group dev --group docs; \ + fi + +lint: + $(UV) run --no-sync ruff check . + +test: + $(UV) run --no-sync pytest + +docs: + $(UV) run --no-sync sphinx-build -W -b html docs/source docs/build/html + +smoke: + $(UV) run --no-sync python app/main.py + +local-verify: sync lint test docs smoke + +verify: docker-verify docker-build: docker build -f .devcontainer/Dockerfile -t $(IMAGE_TAG) . -# Env guardrails: HOME=/tmp and PYTHONNOUSERSITE=1 prevent pip user-site writes (e.g. /.local) +# Canonical gate: the checkout is read-only; all installation/build writes happen in /tmp/work. docker-verify: docker-build - mkdir -p .sisyphus/evidence docker run --rm \ -v "$(CURDIR)":/work:ro \ - -v science-template-pip-cache:/tmp/pip-cache \ - -e PYTHONDONTWRITEBYTECODE=1 \ - -e XDG_CACHE_HOME=/tmp/.cache \ - -e PIP_CACHE_DIR=/tmp/pip-cache \ -e HOME=/tmp \ + -e XDG_CACHE_HOME=/tmp/.cache \ + -e UV_CACHE_DIR=/tmp/uv-cache \ -e PYTHONNOUSERSITE=1 \ $(IMAGE_TAG) \ sh -lc 'cp -a /work /tmp/work \ && cd /tmp/work \ - && python -m venv /tmp/venv \ - && /tmp/venv/bin/pip install -U pip \ - && /tmp/venv/bin/pip install ".[test]" \ - && /tmp/venv/bin/pytest -q -o cache_dir=/tmp/pytest_cache \ - && /tmp/venv/bin/python app/main.py' \ - | tee .sisyphus/evidence/task-1-docker-verify.log - -# Debugging target: read-write mount (use only when read-only breaks) + && if [ -f uv.lock ]; then uv sync --group dev --group docs --frozen; else echo "WARNING: uv.lock missing; resolving dependencies"; uv sync --group dev --group docs; fi \ + && uv run --no-sync ruff check . \ + && uv run --no-sync pytest \ + && uv run --no-sync sphinx-build -W -b html docs/source docs/build/html \ + && uv run --no-sync python app/main.py' + +# Debug only: permits writes to the checkout and is intentionally not used by CI. docker-verify-rw: docker-build - @echo "WARNING: Using read-write mount. This may pollute the workspace." - mkdir -p .sisyphus/evidence + @echo "WARNING: read-write verification may create local artifacts." docker run --rm \ -v "$(CURDIR)":/work \ -w /work \ - -e PYTHONDONTWRITEBYTECODE=1 \ - -e XDG_CACHE_HOME=/tmp/.cache \ - -e PIP_CACHE_DIR=/tmp/pip-cache \ -e HOME=/tmp \ + -e XDG_CACHE_HOME=/tmp/.cache \ + -e UV_CACHE_DIR=/tmp/uv-cache \ -e PYTHONNOUSERSITE=1 \ $(IMAGE_TAG) \ - sh -lc 'python -m venv /tmp/venv \ - && /tmp/venv/bin/pip install -U pip \ - && /tmp/venv/bin/pip install ".[test]" \ - && /tmp/venv/bin/pytest -q -o cache_dir=/tmp/pytest_cache \ - && /tmp/venv/bin/python app/main.py' \ - | tee .sisyphus/evidence/task-1-docker-verify-rw.log + sh -lc 'if [ -f uv.lock ]; then uv sync --group dev --group docs --frozen; else echo "WARNING: uv.lock missing; resolving dependencies"; uv sync --group dev --group docs; fi \ + && uv run --no-sync ruff check . \ + && uv run --no-sync pytest \ + && uv run --no-sync sphinx-build -W -b html docs/source docs/build/html \ + && uv run --no-sync python app/main.py' clean: - rm -rf build/ dist/ src/*.egg-info/ .pytest_cache/ .sisyphus/evidence/ - find . -type d -name "__pycache__" -exec rm -rf {} + - $(MAKE) -C docs clean + rm -rf .venv build dist .pytest_cache .ruff_cache docs/build src/*.egg-info + find . -type d -name "__pycache__" -prune -exec rm -rf {} + diff --git a/README.md b/README.md index 700dd75..a10a676 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,99 @@ -# python-science-template +# Scientific Python Template -This project follows the Mirror-Environment Pattern: your development environment (DevContainer) matches your testing environment (GitHub Actions) by running verification inside the same Docker image. +A small project template for researchers who want **one reproducible verification path** instead of separate local and CI environments. -## Quickstart (Docker verification) +The same Dockerfile powers the Dev Container and `make verify`; GitHub Actions runs that same command. Verification mounts your checkout read-only, copies it into an ephemeral container workspace, then installs, lints, tests, builds docs, and runs a smoke entry point. -The project uses a **Clean Verification Flow** to ensure reproducibility and avoid polluting the host workspace with root-owned artifacts (like `__pycache__` or `.pytest_cache`). +## 30-second start -### 1. Build the image +1. Click **Use this template** on GitHub and clone the new repository. +2. Initialize it: ```bash -make docker-build +python scripts/init_project.py ``` -### 2. Run verification - -The standard verification target mounts your source code as **read-only** and performs the installation and testing inside a temporary directory in the container: +3. Verify exactly what CI will verify: ```bash -make docker-verify +make verify ``` -This command: -- Mounts the current directory as `:ro`. -- Copies the source to `/tmp/work` to avoid permission issues during installation. -- Creates a virtual environment in `/tmp/venv`. -- Installs the package with `[test]` extras. -- Runs tests and the entrypoint. +For non-interactive setup: -### Dependency Extras -- `pip install '.[test]'`: Minimal dependencies for running tests (used in CI). -- `pip install '.[dev]'`: Full development environment, including documentation tools (Sphinx) and testing utilities. +```bash +python scripts/init_project.py \ + --name my-analysis \ + --description "Muon-background analysis" \ + --author "Your Name" \ + --profile eda +``` -## Design Rationale: Permission & CI Hardening +### Project profiles + +The initializer asks one practical question: **what kind of scientific project is this?** It then adds only a minimal proof-of-life harness. + +| Profile | Adds | Proof of life | +| --- | --- | --- | +| `generic` | standard library only | summarize a numeric sample | +| `eda` | pandas | summarize numeric columns in a DataFrame | +| `ml` | scikit-learn | fit and predict with a deterministic baseline | +| `api` | FastAPI + Uvicorn | create an app with `/health` | + +These are starting harnesses, not frameworks. Delete or replace them once your real project has equivalent coverage. + + +## Why this template exists + +Scientific projects often drift into three subtly different environments: a laptop, a container, and CI. This template deliberately rejects that split. + +```text +source checkout (read-only) + │ + ▼ +.devcontainer/Dockerfile + │ + ┌─────┴─────┐ + ▼ ▼ +Dev Container make verify / GitHub Actions + │ + ▼ + ephemeral /tmp/work + │ + uv sync --frozen + │ + lint + tests + docs + smoke +``` -This template is designed to be "CI-native" by avoiding common pitfalls with Docker permissions and volume mounts. +The initializer creates a `uv.lock` for the selected profile when `uv` can reach the package index. The template itself stays profile-neutral. + -### Why we avoid `docker run --user $(id -u):$(id -g)` -While mapping the host UID/GID to the container is a common local development pattern, we avoid it in CI for several reasons: -1. **Identity Mismatch**: In environments like GitHub Actions, the runner UID (typically 1001) may not exist in the container's `/etc/passwd`, leading to "I have no name!" errors and broken tool behavior. -2. **Home Directory Access**: Many tools (pip, git, etc.) expect a valid `$HOME`. If you force a UID that doesn't have a home directory defined in the image, these tools may fail or try to write to `/`, which is restricted. -3. **Portability**: The template should work regardless of the host's UID. By running as the container's internal user (`app`) and isolating all write operations to `/tmp`, we ensure consistent behavior across local and CI environments. +## Fast local iteration -### Hardening with `HOME=/tmp` and `PYTHONNOUSERSITE=1` -To make the container execution truly ephemeral and secure: -- **`HOME=/tmp`**: We override the home directory to `/tmp`. Since `/tmp` is world-writable, this guarantees that any tool attempting to write configuration or cache to `~` will succeed without needing complex volume permission management. -- **`PYTHONNOUSERSITE=1`**: This prevents Python from loading packages from the user-specific site-packages directory (usually `~/.local`). This ensures that the environment is strictly defined by the virtual environment created during verification, preventing "poisoning" from the container's global state. +If you do not need the full container gate on every edit: -### Debugging non-root containers -If you need to use a different non-root user or if you encounter permission issues: -1. Use `make docker-verify-rw` to run with a read-write mount and inspect the artifacts. -2. Verify that your container user has write access to `/tmp`. -3. Check if your source code contains pre-existing `*.egg-info` or `__pycache__` directories owned by `root`, which might interfere with the `cp -a` command or tool execution. +```bash +uv sync --group dev --group docs --frozen +make local-verify +``` -## Troubleshooting +`make local-verify` runs the same lint/test/docs/smoke checks in your current environment. `make verify` remains the canonical gate. -### Persistence of root-owned files -If you previously ran commands that created files inside the container with a read-write mount, they might be owned by `root` on your host. Use this to clean them: +## What is intentionally not included -```bash -make clean -``` +No notebook framework, experiment tracker, cloud stack, database, LLM integration, or deployment platform is selected for you. Add those only when the project actually needs them. -### Failures in Read-Only mode -Some legacy Python packaging tools might try to write to the source directory even during installation (e.g., updating `src/*.egg-info`). +## Reproducibility contract -If `make docker-verify` fails due to read-only restrictions, you can use the debug target which uses a read-write mount: +- `scripts/init_project.py` creates `uv.lock` after profile selection; when present, every verification uses it with `--frozen`. +- `.devcontainer/Dockerfile` defines the OS/Python toolchain used by development and verification. +- Before initialization, or if a lockfile is intentionally absent, the commands emit a warning and resolve from `pyproject.toml` instead of pretending the environment is frozen. +- `make verify` mounts the repository read-only and performs writes only in an ephemeral copy. +- CI calls `make verify`; it does not reimplement installation or testing separately. +- `make local-verify` is a convenience path, not the canonical reproducibility proof. -```bash -make docker-verify-rw -``` -*Note: This may create root-owned artifacts in your workspace.* +See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design rationale. + +## License -CI runs the same `make docker-verify` step. +MIT. diff --git a/app/main.py b/app/main.py index 484f68f..bdbb208 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,6 @@ -from science_lib import add, hello_world +import json + +from science_project import summarize if __name__ == "__main__": - print(hello_world()) - result = add(2, 3) - print(f"Result: {result}") + print(json.dumps(summarize([1.0, 2.0, 3.0]), sort_keys=True)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e8af05..661bb89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,226 +1,51 @@ -# Architecture Documentation +# Architecture -This document explains the design decisions and patterns used in this scientific Python project. +## Goal -## Table of Contents +Keep a scientific Python project boring enough to reproduce. -1. [Package Structure: src-layout](#package-structure-src-layout) -2. [Application vs Library Separation](#application-vs-library-separation) -3. [Reproducibility Guarantee](#reproducibility-guarantee) -4. [Test Conventions](#test-conventions) -5. [Documentation Conventions](#documentation-conventions) +The repository has one canonical gate: `make verify`. Both a developer and GitHub Actions build `.devcontainer/Dockerfile` and run the same locked checks. The host checkout is mounted read-only; verification copies it to `/tmp/work` before installing or generating artifacts. ---- - -## Package Structure: src-layout - -### Why src-layout? - -This project uses the **src-layout** pattern instead of a flat layout: - -``` -project/ -├── src/ -│ └── science_lib/ # Package code here -│ ├── __init__.py -│ └── core.py -└── tests/ - └── science_lib/ # Tests mirror src/ structure - └── test_core.py -``` - -**Benefits:** - -1. **Prevents accidental imports**: During development, Python cannot import from the local `src/` directory unless the package is properly installed. This catches packaging issues early. - -2. **Forces testing against installed package**: When you run `pytest`, it imports from the installed package (via `pip install -e .`), not from local files. This ensures your tests validate the actual distribution. - -3. **Industry best practice**: Recommended by PyPA (Python Packaging Authority) for modern Python projects. - -**Gotcha:** Never add `__init__.py` to test subdirectories that mirror package names (e.g., `tests/science_lib/__init__.py`). This causes namespace shadowing and breaks imports. - ---- - -## Application vs Library Separation - -### Directory Structure - -``` -project/ -├── src/ -│ └── science_lib/ # Reusable library code -│ ├── core.py -│ └── utils.py -└── app/ - └── main.py # Entry points / CLI scripts -``` - -### Why separate app/ from src/? - -- **`src/science_lib/`**: Contains pure library logic that can be imported and tested in isolation. This code should have minimal side effects and be highly reusable. - -- **`app/`**: Contains entry points, CLI scripts, and orchestration code that ties together library components. This is where `if __name__ == "__main__"` blocks live. - -**Benefits:** -- Clean separation of concerns -- Library can be imported without executing entry points -- Easier to test library logic independently -- Supports multiple entry points (CLI, web API, notebooks) using same library - ---- - -## Reproducibility Guarantee - -### Mirror-Environment Pattern - -This project uses the **Mirror-Environment Pattern**: Your development environment (DevContainer) is byte-for-byte identical to your testing environment (CI). - -``` -┌─────────────────────┐ ┌─────────────────────┐ -│ DevContainer │ │ GitHub Actions CI │ -│ │ │ │ -│ FROM python:3.11 │ ───▶ │ FROM python:3.11 │ -│ RUN apt-get... │ SAME │ RUN apt-get... │ -│ RUN pip install... │ │ RUN pip install... │ -└─────────────────────┘ └─────────────────────┘ -``` - -**Implementation:** -- `.devcontainer/Dockerfile` defines the exact environment -- CI workflow uses **identical** Dockerfile for testing -- No "works on my machine" issues - -**Benefits:** -1. **Scientific reproducibility**: Results are identical across machines -2. **Zero environment drift**: Dev and CI cannot diverge -3. **Fast onboarding**: New contributors get correct environment instantly -4. **Audit trail**: Dockerfile serves as executable documentation of dependencies - -### Base Image: python:3.11-slim - -- **Why Slim?**: Debian-based (better compatibility than Alpine), includes glibc (required by NumPy/SciPy), smaller than full image (~150MB vs ~1GB) -- **Why Python 3.11?**: Modern Python with performance improvements, stable for scientific libraries - ---- - -## Test Conventions - -### Directory Structure - -``` -tests/ -├── conftest.py # Pytest fixtures and configuration -└── science_lib/ # NO __init__.py here! - └── test_core.py # Tests for src/science_lib/core.py -``` - -### Rules - -1. **Mirror src/ structure**: Test directories should mirror the package structure for discoverability. - -2. **No `__init__.py` in test subdirs**: NEVER create `tests/science_lib/__init__.py`. This causes namespace shadowing and prevents pytest from importing the installed package. - -3. **Import from installed package**: Tests should import via: - ```python - from science_lib.core import add_numbers # Correct - ``` - Not via relative imports or sys.path hacks. - -4. **Run tests against installed package**: Always run `pip install -e ".[test]"` before `pytest`. This ensures you're testing the distribution, not local files. - -### Example Test - -```python -# tests/science_lib/test_core.py -from science_lib.core import add_numbers - -def test_add_numbers(): - """Test basic addition.""" - assert add_numbers(2, 3) == 5 -``` - ---- - -## Documentation Conventions - -### Sphinx + autodoc + napoleon - -This project uses **Sphinx** with two key extensions: - -1. **`sphinx.ext.autodoc`**: Automatically generates API documentation from docstrings in source code. - -2. **`sphinx.ext.napoleon`**: Parses Google-style and NumPy-style docstrings into reStructuredText. - -### Docstring Style: Google Format - -```python -def add_numbers(a, b): - """Add two numbers together. - - This is a simple addition function that demonstrates - proper docstring formatting for Sphinx autodoc. - - Args: - a: First number (int or float) - b: Second number (int or float) - - Returns: - Sum of a and b - - Raises: - TypeError: If inputs are not numeric - - Example: - >>> add_numbers(2, 3) - 5 - """ - return a + b +```text +Git checkout (read-only) + │ + ▼ +.devcontainer/Dockerfile + │ + ├── Dev Container + │ + └── make verify ── GitHub Actions + │ + ▼ + /tmp/work + │ + uv sync --frozen (after initialization) + │ + ruff → pytest → Sphinx → smoke ``` -### Building Documentation - -```bash -# Generate API stubs from source code -make -C docs apidoc - -# Build HTML documentation -make -C docs html - -# View docs: open docs/build/html/index.html -``` +## Contracts -### Configuration +### `src/` layout -- **Sphinx config**: `docs/source/conf.py` - - Adds `src/` to `sys.path` so autodoc can import `science_lib` - - Configures extensions and theme (sphinx_rtd_theme) +Importable code lives under `src//`. Tests import the installed package, not files by relative path. -- **Makefile**: `docs/Makefile` - - `apidoc` target: Runs `sphinx-apidoc` to generate `.rst` stubs - - `html` target: Builds HTML documentation - - `clean` target: Removes build artifacts +### Locked Python dependencies -### Living Documentation +`uv.lock` is generated by `scripts/init_project.py` after selecting a profile. Commit the lockfile. When it exists, `make verify` uses `uv sync --frozen`. The profile-neutral template can run without a lock and prints an explicit warning while resolving from `pyproject.toml`; generated research projects should keep the lockfile. -The `apidoc` target ensures documentation stays synchronized with code: +### One container definition -1. Developer updates docstrings in `src/science_lib/` -2. `make -C docs apidoc` regenerates `.rst` stubs -3. `make -C docs html` rebuilds HTML with latest changes -4. CI can enforce documentation builds successfully +`.devcontainer/Dockerfile` is used for interactive development and canonical verification. CI does not maintain a second package-install recipe. -**Result:** Documentation is always up-to-date with the codebase. +### Read-only source during canonical verification ---- +`make verify` mounts the checkout at `/work:ro`, copies it into `/tmp/work`, and performs all installs/builds there. This catches tools that unexpectedly write into source while avoiding root-owned host artifacts. -## Summary +### Minimal profiles -This project follows modern Python best practices: +The initializer offers `generic`, `eda`, `ml`, and `api`. A profile only chooses the first dependency/harness needed to prove the project works end-to-end. It is not an application framework and can be replaced immediately. -- ✅ **src-layout** prevents import issues and forces proper testing -- ✅ **app/ separation** keeps entry points distinct from library logic -- ✅ **Mirror-Environment Pattern** guarantees reproducibility -- ✅ **Proper test conventions** ensure tests validate the installed package -- ✅ **Sphinx autodoc** keeps documentation synchronized with code +## Why `make local-verify` also exists -For questions or issues, refer to individual section details above. +Container verification is the reproducibility proof but is slower. `make local-verify` runs equivalent checks in the current uv environment for fast iteration. CI intentionally uses only `make verify`. diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 725dcad..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -SPHINXBUILD = sphinx-build -SOURCEDIR = source -BUILDDIR = build -MODULE ?= science_lib - -.PHONY: help apidoc html clean - -help: - @echo "Use 'make apidoc' to generate API stubs" - @echo "Use 'make html' to build HTML documentation" - -apidoc: - sphinx-apidoc -f -o $(SOURCEDIR)/api ../src/$(MODULE) - -html: - $(SPHINXBUILD) -b html $(SOURCEDIR) $(BUILDDIR)/html - -clean: - rm -rf $(BUILDDIR) diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..d0f8ef8 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,7 @@ +API +=== + +.. automodule:: science_project + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/modules.rst b/docs/source/api/modules.rst deleted file mode 100644 index d2f1638..0000000 --- a/docs/source/api/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -science_lib -=========== - -.. toctree:: - :maxdepth: 4 - - science_lib diff --git a/docs/source/api/science_lib.rst b/docs/source/api/science_lib.rst deleted file mode 100644 index 9fe43b3..0000000 --- a/docs/source/api/science_lib.rst +++ /dev/null @@ -1,29 +0,0 @@ -science\_lib package -==================== - -Submodules ----------- - -science\_lib.core module ------------------------- - -.. automodule:: science_lib.core - :members: - :show-inheritance: - :undoc-members: - -science\_lib.hello module -------------------------- - -.. automodule:: science_lib.hello - :members: - :show-inheritance: - :undoc-members: - -Module contents ---------------- - -.. automodule:: science_lib - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/source/conf.py b/docs/source/conf.py index 210e8cf..8abc000 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,57 +1,15 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -import os -import sys import tomllib from pathlib import Path -sys.path.insert(0, os.path.abspath('../../src')) - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -# Read from pyproject.toml -pyproject_path = Path(__file__).parent.parent.parent / 'pyproject.toml' -with open(pyproject_path, 'rb') as f: - pyproject = tomllib.load(f) - -project = pyproject.get('tool', {}).get('template', {}).get('project_title', 'Science Project') -author = pyproject.get('tool', {}).get('template', {}).get('author', 'Science Team') -release = pyproject.get('project', {}).get('version', '0.1.0') -copyright = f'2026, {author}' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', -] - -templates_path = ['_templates'] -exclude_patterns = [] - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +ROOT = Path(__file__).resolve().parents[2] +with (ROOT / "pyproject.toml").open("rb") as handle: + pyproject = tomllib.load(handle) -html_theme = 'sphinx_rtd_theme' -html_static_path = ['_static'] +project = pyproject["tool"]["science-template"]["project_title"] +author = pyproject["project"]["authors"][0]["name"] +release = pyproject["project"]["version"] -# -- Napoleon settings ------------------------------------------------------- -napoleon_google_docstring = False +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] +exclude_patterns: list[str] = [] +html_theme = "sphinx_rtd_theme" napoleon_numpy_docstring = True -napoleon_include_init_with_doc = False -napoleon_include_private_with_doc = False -napoleon_include_special_with_doc = True -napoleon_use_admonition_for_examples = False -napoleon_use_admonition_for_notes = False -napoleon_use_admonition_for_references = False -napoleon_use_ivar = False -napoleon_use_param = True -napoleon_use_rtype = True -napoleon_preprocess_types = False -napoleon_type_aliases = None -napoleon_attr_annotations = True diff --git a/docs/source/index.rst b/docs/source/index.rst index c30a2d2..93c711c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,19 +1,15 @@ -Science Project Documentation -============================= +Science Project +=============== -Welcome to the Science Project documentation! - -This documentation is automatically generated from source code docstrings using Sphinx autodoc. +This documentation is built from the same locked project environment used by verification. .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Contents - api/modules + api -Indices and tables -================== +Development contract +-------------------- -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +``make verify`` is the canonical project gate. It builds the shared container, mounts the checkout read-only, and runs lint, tests, docs, and the smoke entry point in an ephemeral copy. diff --git a/pyproject.toml b/pyproject.toml index 12f5142..3fce0c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,24 +1,26 @@ [build-system] -requires = ["setuptools>=68", "wheel"] +requires = ["setuptools>=75", "wheel"] build-backend = "setuptools.build_meta" [project] name = "science-project" version = "0.1.0" -description = "Reproducible scientific Python scaffold" +description = "Reproducible scientific Python project" +readme = "README.md" requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Science Team" }] dependencies = [] -[project.optional-dependencies] -test = [ - "pytest>=8" +[dependency-groups] +dev = [ + "pytest>=8.3", + "ruff>=0.9", ] docs = [ - "sphinx>=7", - "sphinx-rtd-theme>=2" -] -dev = [ - "science-project[test,docs]" + "sphinx>=8", + "sphinx-rtd-theme>=3", ] [tool.setuptools] @@ -31,7 +33,17 @@ where = ["src"] testpaths = ["tests"] addopts = "-q" -[tool.template] -module_name = "science_lib" +[tool.ruff] +target-version = "py311" +line-length = 100 +src = ["src", "tests", "scripts"] + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "SIM"] + +[tool.science-template] +module_name = "science_project" project_title = "Science Project" -author = "Science Team" +profile = "generic" +scaffold_version = "0.2.0" +initialized = false diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/init_project.py b/scripts/init_project.py new file mode 100644 index 0000000..a77ff16 --- /dev/null +++ b/scripts/init_project.py @@ -0,0 +1,449 @@ +"""Initialize a repository created from python-science-template. + +The script intentionally does only four things: rename metadata/package paths, +select one small project profile, write a proof-of-life harness, and refresh the +uv lockfile when uv is available. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import unicodedata +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BASE_MODULE = "science_project" +BASE_DIST = "science-project" +BASE_TITLE = "Science Project" +BASE_AUTHOR = "Science Team" +SCAFFOLD_VERSION = "0.2.0" + +TEXT_SUFFIXES = {".py", ".toml", ".md", ".rst", ".yml", ".yaml", ".json", ".txt"} +SKIP_DIRS = {".git", ".venv", "docs/build", "__pycache__"} + + +@dataclass(frozen=True) +class Profile: + dependencies: tuple[str, ...] + harness_filename: str + harness: str + test_filename: str + test: str + app: str + run_hint: str + + +def profile_spec() -> dict[str, Profile]: + return { + "generic": Profile( + dependencies=(), + harness_filename="harness.py", + harness="""\ +# Small generic harness that should be replaced by real project logic. + +from statistics import fmean + + +def summarize(values: list[float]) -> dict[str, float | int]: + if not values: + raise ValueError("values must not be empty") + return { + "count": len(values), + "mean": fmean(values), + "minimum": min(values), + "maximum": max(values), + } +""", + test_filename="test_harness.py", + test="""\ +from __MODULE__.harness import summarize + + +def test_summarize_numeric_sample() -> None: + assert summarize([1.0, 2.0, 3.0])["mean"] == 2.0 +""", + app="""\ +import json + +from __MODULE__.harness import summarize + + +if __name__ == "__main__": + print(json.dumps(summarize([1.0, 2.0, 3.0]), sort_keys=True)) +""", + run_hint="uv run python app/main.py", + ), + "eda": Profile( + dependencies=("pandas>=2.2",), + harness_filename="analysis.py", + harness="""\ +# Minimal tabular EDA harness. + +import pandas as pd + + +def summarize_numeric(frame: pd.DataFrame) -> pd.DataFrame: + # Return a compact summary of numeric columns. + numeric = frame.select_dtypes(include="number") + if numeric.shape[1] == 0: + raise ValueError("frame has no numeric columns") + return numeric.describe().T[["count", "mean", "std", "min", "max"]] +""", + test_filename="test_analysis.py", + test="""\ +import pandas as pd + +from __MODULE__.analysis import summarize_numeric + + +def test_summarize_numeric() -> None: + frame = pd.DataFrame({"x": [1.0, 2.0, 3.0], "label": ["a", "b", "c"]}) + summary = summarize_numeric(frame) + assert summary.loc["x", "mean"] == 2.0 + assert list(summary.index) == ["x"] +""", + app="""\ +import pandas as pd + +from __MODULE__.analysis import summarize_numeric + + +if __name__ == "__main__": + demo = pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [3.0, 2.0, 1.0]}) + print(summarize_numeric(demo).to_string()) +""", + run_hint="uv run python app/main.py", + ), + "ml": Profile( + dependencies=("scikit-learn>=1.5",), + harness_filename="model.py", + harness="""\ +# Minimal deterministic ML baseline. + +from collections.abc import Sequence + +from sklearn.linear_model import LogisticRegression + + +def fit_baseline( + features: Sequence[Sequence[float]], labels: Sequence[int] +) -> LogisticRegression: + # Fit a tiny deterministic classifier as a pipeline smoke test. + model = LogisticRegression(random_state=0, solver="liblinear") + return model.fit(features, labels) +""", + test_filename="test_model.py", + test="""\ +from __MODULE__.model import fit_baseline + + +def test_fit_baseline_predicts_training_sample() -> None: + x = [[0.0], [0.2], [0.8], [1.0]] + y = [0, 0, 1, 1] + model = fit_baseline(x, y) + assert model.predict([[0.1], [0.9]]).tolist() == [0, 1] +""", + app="""\ +from __MODULE__.model import fit_baseline + + +if __name__ == "__main__": + x = [[0.0], [0.2], [0.8], [1.0]] + y = [0, 0, 1, 1] + model = fit_baseline(x, y) + print(model.predict([[0.1], [0.9]]).tolist()) +""", + run_hint="uv run python app/main.py", + ), + "api": Profile( + dependencies=("fastapi>=0.115", "uvicorn>=0.30"), + harness_filename="api.py", + harness="""\ +# Minimal FastAPI application. + +from fastapi import FastAPI + +app = FastAPI(title="__TITLE__") + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} +""", + test_filename="test_api.py", + test="""\ +from __MODULE__.api import app, health + + +def test_health_contract() -> None: + assert health() == {"status": "ok"} + assert "/health" in {route.path for route in app.routes} +""", + app="""\ +from __MODULE__.api import app + + +if __name__ == "__main__": + paths = sorted(route.path for route in app.routes if hasattr(route, "path")) + print(f"API ready with routes: {paths}") + print("Run: uv run uvicorn __MODULE__.api:app --reload") +""", + run_hint="uv run uvicorn __MODULE__.api:app --reload", + ), + } + + +def _ascii_slug_source(name: str) -> str: + normalized = unicodedata.normalize("NFKD", name) + return normalized.encode("ascii", "ignore").decode("ascii") + + +def module_name_from_project(name: str) -> str: + source = _ascii_slug_source(name) + module = re.sub(r"[^a-zA-Z0-9]+", "_", source.strip()).strip("_").lower() + if not module: + raise ValueError("project name must contain letters or numbers") + if module[0].isdigit(): + module = f"project_{module}" + if not module.isidentifier(): + raise ValueError(f"cannot derive a valid Python module from {name!r}") + return module + + +def distribution_name(name: str) -> str: + source = _ascii_slug_source(name) + dist = re.sub(r"[^a-zA-Z0-9]+", "-", source.strip()).strip("-").lower() + if not dist: + raise ValueError("project name must contain letters or numbers") + return dist + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--name") + parser.add_argument("--module") + parser.add_argument("--description") + parser.add_argument("--author") + parser.add_argument("--profile", choices=tuple(profile_spec())) + parser.add_argument("--no-lock", action="store_true", help="do not run `uv lock`") + parser.add_argument("--yes", action="store_true", help="accept defaults without prompting") + return parser.parse_args() + + +def prompt(value: str | None, label: str, default: str, *, noninteractive: bool) -> str: + if value: + return value.strip() + if noninteractive: + return default + entered = input(f"{label} [{default}]: ").strip() + return entered or default + + +def replace_text(root: Path, replacements: dict[str, str]) -> None: + for path in root.rglob("*"): + if not path.is_file() or path.suffix not in TEXT_SUFFIXES: + continue + relative = path.relative_to(root).as_posix() + if any(relative == skip or relative.startswith(f"{skip}/") for skip in SKIP_DIRS): + continue + text = path.read_text(encoding="utf-8") + updated = text + for old, new in replacements.items(): + updated = updated.replace(old, new) + if updated != text: + path.write_text(updated, encoding="utf-8") + + +def set_project_dependencies(pyproject: Path, dependencies: tuple[str, ...]) -> None: + text = pyproject.read_text(encoding="utf-8") + rendered = "dependencies = [" + ", ".join(json.dumps(dep) for dep in dependencies) + "]" + text, count = re.subn(r"(?m)^dependencies = \[[^\n]*\]$", rendered, text, count=1) + if count != 1: + raise RuntimeError("expected exactly one single-line project dependencies entry") + pyproject.write_text(text, encoding="utf-8") + + +def set_template_state(pyproject: Path, profile: str, initialized: bool) -> None: + text = pyproject.read_text(encoding="utf-8") + text = re.sub(r'(?m)^profile = ".*"$', f'profile = "{profile}"', text, count=1) + text = re.sub( + r"(?m)^initialized = (true|false)$", + f"initialized = {'true' if initialized else 'false'}", + text, + count=1, + ) + pyproject.write_text(text, encoding="utf-8") + + +def replace_template_readme_section(readme: Path, profile: str, run_hint: str) -> None: + text = readme.read_text(encoding="utf-8") + block = f"""\ +## Project setup + +This repository was initialized from `fbientrigo/python-science-template` +with the **{profile}** profile. + +```bash +uv sync --group dev --group docs +uv lock +make verify +``` + +Profile smoke command: + +```bash +{run_hint} +``` + +The starter harness is intentionally small. Replace it with real project logic +while keeping at least one fast proof-of-life test. +""" + text, count = re.subn( + r".*?", + block.rstrip(), + text, + count=1, + flags=re.DOTALL, + ) + if count != 1: + raise RuntimeError("template README markers are missing or duplicated") + readme.write_text(text, encoding="utf-8") + + +def regenerate_lock(root: Path) -> bool: + lock = root / "uv.lock" + lock.unlink(missing_ok=True) + uv = shutil.which("uv") + if uv is None: + print("NOTE: uv is not installed; run `uv lock` before `make verify`.", file=sys.stderr) + return False + result = subprocess.run([uv, "lock"], cwd=root, check=False) + if result.returncode != 0: + lock.unlink(missing_ok=True) + print( + "NOTE: `uv lock` failed; initialization succeeded but verification needs a lockfile.", + file=sys.stderr, + ) + return False + return True + + +def main() -> int: + args = parse_args() + pyproject = ROOT / "pyproject.toml" + current = pyproject.read_text(encoding="utf-8") + if "initialized = true" in current: + raise SystemExit("This repository has already been initialized.") + + name = prompt(args.name, "Project name", BASE_TITLE, noninteractive=args.yes) + dist = distribution_name(name) + module = args.module or module_name_from_project(name) + if not module.isidentifier(): + raise SystemExit(f"Invalid module name: {module!r}") + description = prompt( + args.description, + "Description", + "Reproducible scientific Python project", + noninteractive=args.yes, + ) + author = prompt(args.author, "Author", BASE_AUTHOR, noninteractive=args.yes) + profile = prompt( + args.profile, + "Profile (generic/eda/ml/api)", + "generic", + noninteractive=args.yes, + ) + specs = profile_spec() + if profile not in specs: + raise SystemExit(f"Unknown profile {profile!r}; choose one of: {', '.join(specs)}") + spec = specs[profile] + + src_old = ROOT / "src" / BASE_MODULE + src_new = ROOT / "src" / module + tests_old = ROOT / "tests" / BASE_MODULE + tests_new = ROOT / "tests" / module + if src_new != src_old: + src_old.rename(src_new) + if tests_new != tests_old: + tests_old.rename(tests_new) + + replacements = { + BASE_DIST: dist, + BASE_MODULE: module, + BASE_TITLE: name, + BASE_AUTHOR: author, + 'description = "Reproducible scientific Python project"': ( + f'description = {json.dumps(description)}' + ), + } + replace_text(ROOT, replacements) + set_project_dependencies(pyproject, spec.dependencies) + set_template_state(pyproject, profile, True) + + # Replace the generic harness with the selected profile's small proof of life. + for candidate in src_new.glob("*.py"): + if candidate.name != "__init__.py": + candidate.unlink() + for candidate in tests_new.glob("test_*.py"): + candidate.unlink() + + def render(text: str) -> str: + return text.replace("__MODULE__", module).replace("__TITLE__", name) + + (src_new / spec.harness_filename).write_text(render(spec.harness), encoding="utf-8") + (tests_new / spec.test_filename).write_text(render(spec.test), encoding="utf-8") + (ROOT / "app" / "main.py").write_text(render(spec.app), encoding="utf-8") + + export = { + "generic": "summarize", + "eda": "summarize_numeric", + "ml": "fit_baseline", + "api": "app", + }[profile] + module_stem = Path(spec.harness_filename).stem + (src_new / "__init__.py").write_text( + ( + f'"""{name} package."""\n\n' + f"from {module}.{module_stem} import {export}\n\n" + f'__all__ = ["{export}"]\n' + ), + encoding="utf-8", + ) + + replace_template_readme_section(ROOT / "README.md", profile, render(spec.run_hint)) + # Template-only tests should not leak into generated research projects. + (ROOT / "tests" / "test_initializer.py").unlink(missing_ok=True) + + (ROOT / ".science-template.json").write_text( + json.dumps( + { + "scaffold_version": SCAFFOLD_VERSION, + "distribution": dist, + "module": module, + "profile": profile, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + lock_ok = True if args.no_lock else regenerate_lock(ROOT) + print(f"Initialized {name!r} as module {module!r} with profile {profile!r}.") + if args.no_lock: + print("Skipped lock generation; run `uv lock` before `make verify`.") + elif lock_ok: + print("Updated uv.lock.") + print("Next: make verify") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/science_lib/__init__.py b/src/science_lib/__init__.py deleted file mode 100644 index 6402c30..0000000 --- a/src/science_lib/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Science library for reproducible research.""" -__version__ = "0.1.0" - -from science_lib.core import add -from science_lib.hello import hello_world - -__all__ = ["add", "hello_world"] diff --git a/src/science_lib/core.py b/src/science_lib/core.py deleted file mode 100644 index 2c1dfbe..0000000 --- a/src/science_lib/core.py +++ /dev/null @@ -1,16 +0,0 @@ -def add(a: float, b: float) -> float: - """Add two numbers. - - Parameters - ---------- - a : float - The first number. - b : float - The second number. - - Returns - ------- - float - The sum of a and b. - """ - return a + b diff --git a/src/science_lib/hello.py b/src/science_lib/hello.py deleted file mode 100644 index ea202a1..0000000 --- a/src/science_lib/hello.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Module for greeting functionality.""" - -def hello_world() -> str: - """Return a standard greeting. - - Returns - ------- - str - The greeting string "Hello World". - """ - return "Hello World" diff --git a/src/science_project/__init__.py b/src/science_project/__init__.py new file mode 100644 index 0000000..6a8d4e8 --- /dev/null +++ b/src/science_project/__init__.py @@ -0,0 +1,5 @@ +"""Minimal proof-of-life package for a reproducible scientific project.""" + +from science_project.harness import summarize + +__all__ = ["summarize"] diff --git a/src/science_project/harness.py b/src/science_project/harness.py new file mode 100644 index 0000000..b4695f8 --- /dev/null +++ b/src/science_project/harness.py @@ -0,0 +1,15 @@ +# Small generic harness that should be replaced by real project logic. + +from statistics import fmean + + +def summarize(values: list[float]) -> dict[str, float | int]: + """Return a tiny deterministic summary for a numeric sample.""" + if not values: + raise ValueError("values must not be empty") + return { + "count": len(values), + "mean": fmean(values), + "minimum": min(values), + "maximum": max(values), + } diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 24614b0..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1 +0,0 @@ -# pytest configuration and fixtures diff --git a/tests/science_lib/test_core.py b/tests/science_lib/test_core.py deleted file mode 100644 index 1b78f05..0000000 --- a/tests/science_lib/test_core.py +++ /dev/null @@ -1,4 +0,0 @@ -from science_lib.core import add - -def test_add(): - assert add(1, 2) == 3 diff --git a/tests/science_lib/test_hello.py b/tests/science_lib/test_hello.py deleted file mode 100644 index cede5f9..0000000 --- a/tests/science_lib/test_hello.py +++ /dev/null @@ -1,5 +0,0 @@ -from science_lib.hello import hello_world - -def test_hello_world(): - """Test the hello_world function.""" - assert hello_world() == "Hello World" diff --git a/tests/science_project/test_harness.py b/tests/science_project/test_harness.py new file mode 100644 index 0000000..aa72dfa --- /dev/null +++ b/tests/science_project/test_harness.py @@ -0,0 +1,6 @@ +from science_project.harness import summarize + + +def test_summarize_numeric_sample() -> None: + result = summarize([1.0, 2.0, 3.0]) + assert result == {"count": 3, "mean": 2.0, "minimum": 1.0, "maximum": 3.0} diff --git a/tests/test_initializer.py b/tests/test_initializer.py new file mode 100644 index 0000000..0b68f84 --- /dev/null +++ b/tests/test_initializer.py @@ -0,0 +1,24 @@ +import runpy +from pathlib import Path + +INITIALIZER = runpy.run_path("scripts/init_project.py") +module_name_from_project = INITIALIZER["module_name_from_project"] +profile_spec = INITIALIZER["profile_spec"] + + +def test_module_name_from_project() -> None: + assert module_name_from_project("Muon Background 2026") == "muon_background_2026" + assert module_name_from_project("3D-analysis") == "project_3d_analysis" + + +def test_profiles_are_small_and_explicit() -> None: + assert set(profile_spec()) == {"generic", "eda", "ml", "api"} + for spec in profile_spec().values(): + assert len(spec.dependencies) <= 2 + assert spec.harness_filename.endswith(".py") + + +def test_template_metadata_exists() -> None: + text = Path("pyproject.toml").read_text(encoding="utf-8") + assert "[tool.science-template]" in text + assert "initialized = false" in text