diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1e95c06..7e42dad 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,6 +31,8 @@ jobs: uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: python + # Use the broader query suite (default + maintainability/style). + queries: security-and-quality - name: Perform CodeQL analysis uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1ed8739..ade5ed0 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,5 +1,7 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions +# KEEP IN SYNC with .gitlab-ci.yml. This template ships CI for both GitHub and +# GitLab so the package can move between them without rewriting pipelines. +# When you change the test matrix, install command, or pytest invocation here, +# mirror the change in .gitlab-ci.yml. # # Third-party actions are pinned to commit SHAs (with version comments) for # supply-chain safety. Dependabot's `github-actions` ecosystem keeps both the @@ -40,6 +42,11 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[test]" + - name: Type-check with mypy + if: matrix.python-version == '3.12' + run: | + pip install mypy + mypy my_package - name: Run tests with coverage run: pytest --cov --cov-report=xml - name: Upload coverage report diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 998106a..5182dd7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,22 +1,54 @@ -# Optional GitLab CI mirror of `.github/workflows/python-app.yml`. -# https://medium.com/swlh/automate-testing-with-gitlab-pipelines-4d35c72c18a +# KEEP IN SYNC with .github/workflows/python-app.yml and pre-commit.yml. +# This template ships CI for both GitHub and GitLab. The test stage below is +# behaviorally equivalent to python-app.yml: same Python matrix (3.10–3.13), +# same install command (pip install -e ".[test]"), same pytest invocation. +# Coverage is exported as Cobertura XML (GitLab's native format) instead of +# uploaded to Codecov. +# +# CodeQL has no GitLab equivalent here; if you host on GitLab, consider +# GitLab's built-in SAST template instead: +# https://docs.gitlab.com/ee/user/application_security/sast/ + default: - image: python:3.12 - before_script: - - python -m pip install --upgrade pip - - pip install -e ".[test]" + interruptible: true # cancel superseded pipelines, mirroring GH `concurrency` + stages: - - test + - lint + - test + +pre-commit: + stage: lint + image: python:3.12 + timeout: 10m + script: + - python -m pip install --upgrade pip + - pip install pre-commit + - pre-commit run --all-files + +mypy: + stage: lint + image: python:3.12 + timeout: 10m + script: + - python -m pip install --upgrade pip + - pip install -e ".[test]" mypy + - mypy my_package + test: - parallel: - matrix: - - PYTHON_VERSION: ["3.10", "3.11", "3.12", "3.13"] - image: python:$PYTHON_VERSION - script: - - pytest --cov --cov-report=term --cov-report=xml - coverage: '/TOTAL.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' - artifacts: - reports: - coverage_report: - coverage_format: cobertura - path: coverage.xml + stage: test + timeout: 15m + parallel: + matrix: + - PYTHON_VERSION: ["3.10", "3.11", "3.12", "3.13"] + image: python:$PYTHON_VERSION + before_script: + - python -m pip install --upgrade pip + - pip install -e ".[test]" + script: + - pytest --cov --cov-report=term --cov-report=xml + coverage: '/TOTAL.*? (\d+(?:\.\d+)?\%)$/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml diff --git a/.readthedocs.yml b/.readthedocs.yml index 286c9b5..6a46b8d 100755 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -21,12 +21,12 @@ build: # Build documentation in the "docs/" directory with Sphinx sphinx: - fail_on_warning: false + # Fail on warnings so dangling cross-references don't silently rot. Flip to + # `false` if you intentionally ship docs with known warnings. + fail_on_warning: true configuration: docs/conf.py # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs # builder: "dirhtml" - # Fail on all warnings to avoid broken references - # fail_on_warning: true # Optionally build your docs in additional formats such as PDF and ePub # formats: diff --git a/CITATION.cff b/CITATION.cff index b462f01..3e71aea 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,7 +5,7 @@ authors: given-names: Nathan orcid: https://orcid.org/0000-0002-0008-8749 title: "my_package" -version: 0.0.0 +version: 0.0.0.dev0 # Populate `doi` and `date-released` once Zenodo mints a DOI for the first # release. Until then, leave them absent so cff-convert validation passes. # doi: 10.5281/zenodo.XXXXXXX diff --git a/README.md b/README.md index 8c9132e..45a3e4c 100644 --- a/README.md +++ b/README.md @@ -14,33 +14,30 @@ PyPI Package Template Instructions 1. Choose a name that does not exist in [pypi](https://pypi.org/). You can check by going to `https://pypi.org/simple/{my_awesome_new_package}`; a 404 means the name is available. -2. Replace all `my_package` names, strings, etc. in this repo with your desired package name (e.g., `my_awesome_new_package`). The command below makes this simple; run it after cloning the repo locally. It restricts the rewrite to plain-text files and skips `.git/` so binaries (logos, etc.) are left alone. The `sed` syntax below is for GNU `sed` (Linux); on macOS use `sed -i ''` instead of `sed -i`. +2. Run the personalization script. It rewrites every place the original author's identity is baked in (package name, GitHub username, author name, email, ORCID, codecov token) and renames the `my_package/` directory in one shot: ```bash -find . -type f \ - -not -path "./.git/*" \ - \( -name "*.py" -o -name "*.md" -o -name "*.rst" -o -name "*.toml" \ - -o -name "*.cfg" -o -name "*.yml" -o -name "*.yaml" -o -name "*.in" \ - -o -name "*.cff" -o -name "*.sh" -o -name "Makefile*" -o -name "CODEOWNERS" \) \ - -exec sed -i "s/my_package/my_awesome_new_package/g" {} + +scripts/personalize.sh \ + --name my_awesome_new_package \ + --username my-github-handle \ + --author "Jane Doe" \ + --email jane@example.com \ + --orcid 0000-0001-2345-6789 # optional; line is dropped from CITATION.cff if omitted ``` -Similarly, replace all instances of `mahynski` with your GitHub username, and rename the `my_package/` directory to match your chosen name. - -```bash -git mv my_package my_awesome_new_package -git commit -m "rename package to my_awesome_new_package" -``` +Review the diff (`git diff`), then commit. Once you're satisfied you can delete the script with `rm -r scripts/`. 3. Replace the placeholder `LICENSE.md` with the full text of your chosen license (see [choosealicense.com](https://choosealicense.com/)) and update the `license` field in `pyproject.toml` accordingly. Per [PEP 639](https://peps.python.org/pep-0639/), prefer an SPDX expression: `license = "MIT"`. -4. Get coding! Follow the best practices below. When you are ready to publish, proceed to the next step. -5. Before tagging a release, update **all** of: +4. Replace `docs/_static/logo.png` and `logo_transparent.png` with your own art (or remove `html_logo` from `docs/conf.py`). The committed art is AI-generated `my_package` placeholder. +5. Once codecov is enabled for the new repo, replace `REPLACE_WITH_CODECOV_TOKEN` in `README.md` and `docs/index.rst` with your project's token. +6. Get coding! Follow the best practices below. When you are ready to publish, proceed to the next step. +7. Before tagging a release, update **all** of: - `__version__` in `my_package/__init__.py` - `version:` and `date-released:` in `CITATION.cff` (and the `doi:` once Zenodo mints it) - the `Development Status` classifier in `pyproject.toml` -6. When finished, first enable preservation of this repo on [Zenodo](https://zenodo.org/), then create a release on GitHub. Zenodo will detect the new release and mint a DOI and badge. Update the `{github_id}` placeholder in this `README.md` and in `docs/index.rst` with the ID generated by Zenodo. -7. Follow [these instructions](https://packaging.python.org/en/latest/tutorials/packaging-projects/) to publish to PyPI. -8. The included GitHub Actions workflows trigger on pushes and PRs to `main`. If you adopt a `dev` → `main` pattern for releases, update the `on:` blocks in `.github/workflows/*.yml` so CI runs on `dev` too. +8. When finished, first enable preservation of this repo on [Zenodo](https://zenodo.org/), then create a release on GitHub. Zenodo will detect the new release and mint a DOI and badge. Update the `{github_id}` placeholder in this `README.md` and in `docs/index.rst` with the ID generated by Zenodo. +9. Follow [these instructions](https://packaging.python.org/en/latest/tutorials/packaging-projects/) to publish to PyPI. +10. The included GitHub Actions workflows trigger on pushes and PRs to `main`. If you adopt a `dev` → `main` pattern for releases, update the `on:` blocks in `.github/workflows/*.yml` so CI runs on `dev` too. Best Practices === @@ -70,8 +67,12 @@ python -m ipykernel install --user --name=awesome_env # register Jupyter kerne jupyter notebook --port 4321 # launch on localhost (default auth enabled) ``` -The package declares the following extras in `pyproject.toml`: +The package declares the following extras in `pyproject.toml`. The default +`dependencies` list is **empty** on purpose — add only what your package +actually imports. The `science` extra is opt-in so users who don't need it +aren't forced to install hundreds of MB of wheels. +- `science` — `matplotlib`, `numpy`, `pandas`, `scikit-learn`, `scipy`, `seaborn` with loose floors - `test` — `pytest` + `pytest-cov` (what CI installs) - `docs` — Sphinx and the theme/extensions used by `docs/conf.py` - `notebook` — `IPython`, `ipywidgets`, `ipykernel` @@ -112,11 +113,23 @@ Code coverage is wired to [codecov.io](https://app.codecov.io/). Enable the repo CI/CD --- -Three additional workflows keep the repo healthy with no extra setup: +The template ships CI for **both GitHub and GitLab** so you can host on either +without rewriting pipelines. The two configs are kept behaviorally equivalent +for the test stage (same Python matrix, same install command, same coverage +report). When you change one, **mirror the change in the other**; both files +have a `KEEP IN SYNC` header pointing at the other. + +GitHub-specific workflows (with no GitLab equivalent in this template): -- `.github/workflows/pre-commit.yml` runs the pre-commit hooks on every push and PR to `main`, so lint/format failures are caught in CI rather than only locally. -- `.github/workflows/codeql.yml` runs GitHub's [CodeQL](https://codeql.github.com/) static-analysis scanner on every push, every PR, and weekly on Mondays. Findings appear under the repo's *Security → Code scanning* tab. -- `.github/dependabot.yml` opens weekly PRs to update GitHub Actions versions and pip dependencies. Tune the `interval` or `open-pull-requests-limit` if the volume is too high. +- `.github/workflows/python-app.yml` — test matrix on 3.10–3.13, coverage to Codecov. +- `.github/workflows/pre-commit.yml` — runs pre-commit hooks on push and PR to `main`. +- `.github/workflows/codeql.yml` — [CodeQL](https://codeql.github.com/) static analysis on push, PR, and weekly Mondays. Findings appear under *Security → Code scanning*. +- `.github/dependabot.yml` — weekly PRs to update GitHub Actions and pip dependencies. Tune `interval` or `open-pull-requests-limit` if volume is too high. + +GitLab equivalent: + +- `.gitlab-ci.yml` — mirrors the test matrix and adds `pre-commit` and `mypy` jobs. CodeQL has no GitLab analogue here; if you host on GitLab, consider GitLab's built-in [SAST](https://docs.gitlab.com/ee/user/application_security/sast/) instead. +- **No Dependabot on GitLab.** If you host on GitLab, consider [Renovate](https://docs.renovatebot.com/) (works on both platforms) or replace `.github/dependabot.yml` with a manual update cadence. Linting and Formatting --- @@ -138,12 +151,17 @@ pre-commit run --all-files Typing --- -Type hints are optional but recommended — see the [typing docs](https://docs.python.org/3/library/typing.html) and [mypy](https://mypy-lang.org/). Mypy is not run by pre-commit in this template; invoke it manually as needed: +Type hints are optional but recommended — see the [typing docs](https://docs.python.org/3/library/typing.html) and [mypy](https://mypy-lang.org/). [Mypy](https://mypy-lang.org/) is configured under `[tool.mypy]` in `pyproject.toml` and runs in CI on every push/PR (the GitHub `python-app.yml` test job runs it on Python 3.12; the GitLab `mypy` job runs in the `lint` stage). The package ships a [PEP 561](https://peps.python.org/pep-0561/) `py.typed` marker so downstream consumers see your annotations. + +Run mypy locally the same way CI does: ```bash -mypy --ignore-missing-imports my_new_file.py +pip install -e ".[dev]" +mypy my_package ``` +The default config is permissive (`ignore_missing_imports`, `check_untyped_defs`, no `disallow_untyped_defs`) so a fresh template checkout passes without effort. Tighten it as your code grows — e.g., flip on `disallow_untyped_defs = true` once you're ready to require annotations on public APIs. + Demo --- diff --git a/docs/conf.py b/docs/conf.py index 3585953..34e9d1b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,17 +5,13 @@ https://www.sphinx-doc.org/en/master/usage/configuration.html """ -import datetime -import os -import sys - -sys.path.insert(0, os.path.abspath("..")) - # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "my_package" -copyright = f"{datetime.date.today().year}, Nathan A. Mahynski" +# Hardcoded for reproducible builds. Bump the year manually (or replace with +# a "{start}–{current}" range) when appropriate. +copyright = "2026, Nathan A. Mahynski" author = "Nathan A. Mahynski" # -- General configuration --------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 73d3dbb..6ebb4a1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,11 +32,6 @@ License Information * Any mention of commercial products is for information only; it does not imply recommendation or endorsement by `NIST `_. -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - .. toctree:: :maxdepth: 3 :caption: API Reference diff --git a/my_package/__init__.py b/my_package/__init__.py index 922e87f..bcb27dc 100644 --- a/my_package/__init__.py +++ b/my_package/__init__.py @@ -4,6 +4,6 @@ Author: Nathan A. Mahynski """ -__version__ = "0.0.0" +__version__ = "0.0.0.dev0" __all__ = [] diff --git a/my_package/py.typed b/my_package/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 1007fb9..60f7db4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,20 +30,22 @@ readme = "README.md" # is populated. See PEP 639. license = {file = "LICENSE.md"} requires-python = ">=3.10" -dependencies = [ - "matplotlib>=3.9.0", - "numpy>=2.2.6", - "pandas>=2.1.4", - "scikit-learn>=1.7.2", - "scipy>=1.13.1", - "seaborn>=0.13.0", - "tqdm>=4.67.3", - "watermark>=2.6.0", - "missingno>=0.5.2", - "wandb>=0.26.1", -] +# Intentionally empty: this is a template. Add only what your package actually +# imports at runtime. Heavy science deps live in the `science` extra below so +# users opt in. +dependencies = [] [project.optional-dependencies] +science = [ + # Loose floors chosen to match Python 3.13 wheel availability on PyPI. + # Tighten only when your code actually depends on a newer API. + "matplotlib>=3.9", + "numpy>=2.1", + "pandas>=2.2", + "scikit-learn>=1.5", + "scipy>=1.14", + "seaborn>=0.13", +] test = [ "pytest>=7.4.0", "pytest-cov", @@ -97,10 +99,26 @@ select = [ "UP", # pyupgrade ] +[tool.mypy] +# Permissive defaults so a fresh template checkout passes. Tighten as your +# code grows: turn on `disallow_untyped_defs = true` once you're ready to +# require type annotations on public APIs. +python_version = "3.10" +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +strict_equality = true +check_untyped_defs = true + [tool.setuptools.packages.find] where = ["."] include = ["my_package*"] +[tool.setuptools.package-data] +# Ship the PEP 561 marker so downstream consumers see the package's type hints. +my_package = ["py.typed"] + [tool.setuptools.dynamic] version = {attr = "my_package.__version__"} diff --git a/scripts/personalize.sh b/scripts/personalize.sh new file mode 100755 index 0000000..f5c81a6 --- /dev/null +++ b/scripts/personalize.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Personalize this template for your project. +# +# Replaces every place the original author's identity is baked in +# (package name, GitHub username, author name, email, ORCID, codecov token) +# and renames the package directory. Run once, after cloning. +# +# Usage: +# scripts/personalize.sh \ +# --name my_awesome_package \ +# --username my-github-handle \ +# --author "Jane Doe" \ +# --email jane@example.com \ +# [--orcid 0000-0001-2345-6789] +# +# Flags --name, --username, --author, --email are required. --orcid is +# optional; if omitted, the orcid line is removed from CITATION.cff. +# +# After running, manually: +# 1. Replace LICENSE.md with your chosen license and update the `license` +# field in pyproject.toml to its SPDX expression. +# 2. Replace docs/_static/logo*.png with your own art (or remove +# `html_logo` from docs/conf.py). +# 3. Once codecov is enabled for the new repo, replace the placeholder +# token (the script leaves it as REPLACE_WITH_CODECOV_TOKEN). +# 4. Once Zenodo mints a DOI, fill in CITATION.cff and the badge in +# README.md / docs/index.rst. +# 5. Update pyproject.toml's `description` and the package docstring +# in /__init__.py. +# +# When you're satisfied, delete this script: `rm -r scripts/`. + +set -euo pipefail + +# ---- defaults (these are the strings to replace) ------------------------- +OLD_NAME="my_package" +OLD_USERNAME="mahynski" +OLD_AUTHOR="Nathan A. Mahynski" +OLD_EMAIL="nathan.mahynski@gmail.com" +OLD_ORCID="0000-0002-0008-8749" +OLD_CODECOV_TOKEN="YSLBQ33C7F" + +NEW_NAME="" +NEW_USERNAME="" +NEW_AUTHOR="" +NEW_EMAIL="" +NEW_ORCID="" + +# ---- arg parse ----------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --name) NEW_NAME="$2"; shift 2 ;; + --username) NEW_USERNAME="$2"; shift 2 ;; + --author) NEW_AUTHOR="$2"; shift 2 ;; + --email) NEW_EMAIL="$2"; shift 2 ;; + --orcid) NEW_ORCID="$2"; shift 2 ;; + -h|--help) + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 2 ;; + esac +done + +for var in NEW_NAME NEW_USERNAME NEW_AUTHOR NEW_EMAIL; do + if [[ -z "${!var}" ]]; then + echo "Missing required flag for ${var#NEW_} (lowercased). See --help." >&2 + exit 2 + fi +done + +# CITATION.cff stores given/family names as separate fields. Split the new +# author on the last space: "Jane Doe" -> given="Jane", family="Doe". +# "Jane A. Doe" -> given="Jane A.", family="Doe". +NEW_FAMILY="${NEW_AUTHOR##* }" +NEW_GIVEN="${NEW_AUTHOR% *}" +OLD_FAMILY="Mahynski" +OLD_GIVEN="Nathan" + +# ---- platform-aware sed -i ---------------------------------------------- +if [[ "$(uname)" == "Darwin" ]]; then + sedi=(sed -i '') +else + sedi=(sed -i) +fi + +# ---- collect target files (plain text, exclude .git, _build, this script) - +mapfile -t files < <(find . -type f \ + -not -path "./.git/*" \ + -not -path "./docs/_build/*" \ + -not -path "./scripts/personalize.sh" \ + \( -name "*.py" -o -name "*.md" -o -name "*.rst" -o -name "*.toml" \ + -o -name "*.cfg" -o -name "*.yml" -o -name "*.yaml" -o -name "*.in" \ + -o -name "*.cff" -o -name "*.sh" -o -name "Makefile*" \ + -o -name "CODEOWNERS" \)) + +# ---- rewrite ------------------------------------------------------------- +# Use | as sed delimiter for fields that may contain / (none here, but +# author/email are safer that way too). +for f in "${files[@]}"; do + "${sedi[@]}" \ + -e "s|${OLD_AUTHOR}|${NEW_AUTHOR}|g" \ + -e "s|${OLD_EMAIL}|${NEW_EMAIL}|g" \ + -e "s|${OLD_CODECOV_TOKEN}|REPLACE_WITH_CODECOV_TOKEN|g" \ + -e "s/${OLD_NAME}/${NEW_NAME}/g" \ + -e "s/${OLD_USERNAME}/${NEW_USERNAME}/g" \ + "$f" +done + +# CITATION.cff name fields (given/family stored separately). +"${sedi[@]}" \ + -e "s/family-names: ${OLD_FAMILY}/family-names: ${NEW_FAMILY}/" \ + -e "s/given-names: ${OLD_GIVEN}/given-names: ${NEW_GIVEN}/" \ + CITATION.cff + +# ORCID: rewrite if provided, otherwise drop the orcid line from CITATION.cff. +if [[ -n "$NEW_ORCID" ]]; then + "${sedi[@]}" -e "s|${OLD_ORCID}|${NEW_ORCID}|g" CITATION.cff +else + "${sedi[@]}" -e "/orcid:.*${OLD_ORCID}/d" CITATION.cff +fi + +# ---- rename package directory ------------------------------------------- +if [[ -d "$OLD_NAME" && "$OLD_NAME" != "$NEW_NAME" ]]; then + if git ls-files --error-unmatch "$OLD_NAME" >/dev/null 2>&1; then + git mv "$OLD_NAME" "$NEW_NAME" + else + mv "$OLD_NAME" "$NEW_NAME" + fi +fi + +cat <