diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..87b4ea7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,46 @@ +## Summary + +_Describe the change and the user-facing or developer-facing impact._ + +## Related Issues + +Closes #(issue number) or Relates to #(issue number) + +## What Changed + +- _Summarize the code, tests, docs, or tooling changes._ +- _Call out any API, CLI, or behavior changes explicitly._ + +## Type of Change + +- **Bugfix**: Fix an existing feature +- **Feature**: Introduces new functionality without breaking changes +- **Enhancement**: Improves existing functionality +- **Breaking Change**: Introduces breaking changes that require migration +- **Refactoring**: Code restructuring without behavioral changes +- **Build/Infrastructure**: Build system, CI/CD, dependencies, Docker +- **Documentation**: Documentation, comments, or changelog updates + +## Validation + +- `./tools/lint.sh --changed` +- `./tools/test.sh -q` +- `./tools/docs.sh` (if docs or public API changed) +- `python -m build` (if packaging or release files changed) +- Manual testing described below when automated coverage is not enough + +## Breaking Changes + +_Describe any migration, compatibility, or deprecation impact. Write `None` if not applicable._ + +## Notes for Reviewers + +_Call out anything that deserves extra attention: edge cases, tradeoffs, follow-up work, or known limitations._ + +## Checklist + +- Code follows project style guidelines +- Self-review completed +- Comments added for complex logic +- No breaking changes without documentation +- Commit messages follow semantic convention: `{type}({scope}): {message}` diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 17ba339..be6ea7c 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -2,33 +2,43 @@ name: cmdcraft unit testing on: push: - branches: [master] + branches: + - master + - v*-rc pull_request: - branches: [master, v*-rc] + workflow_dispatch: jobs: test: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - name: Install Dependencies + cache: pip + + - name: Install dependencies run: | - sudo apt remove python3-pip python -m pip install --upgrade pip - python -m pip install . ruff coverage pytest readme_renderer - - name: Analysis - run: ./tools/analyse.sh + pip install -e '.[dev]' + + - name: Lint + run: ./tools/lint.sh --changed + - name: Tests run: ./tools/test.sh + - name: Validate README.rst - run: python -m readme_renderer README.rst \ No newline at end of file + run: python -m readme_renderer.rst README.rst > /dev/null \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7da1ea1..99dddb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,110 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "cmdcraft" +dynamic = ["version"] +description = "Library for building interactive prompt-driven developer tools from Python callables" +readme = { file = "README.rst", content-type = "text/x-rst" } +authors = [{ name = "A. M. Weinsen Jr." }] +license = "BSD-3-Clause" +requires-python = ">=3.10" +dependencies = ["prompt_toolkit>=3.0.52,<4"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", +] + +[project.optional-dependencies] +dev = [ + "build", + "coverage", + "pytest", + "readme_renderer", + "ruff", +] +docs = [ + "furo", + "sphinx", +] + +[project.urls] +Homepage = "https://github.com/weinsen/cmdcraft" +Repository = "https://github.com/weinsen/cmdcraft" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.dynamic] +version = {attr = "cmdcraft.__version__"} + +[tool.setuptools.packages.find] +where = ["src"] + +# git-cliff configuration lives in pyproject.toml and is extracted by +# tools/changelog.sh when generating release notes. +[tool.git-cliff.changelog] +header = """ +Changelog +========= + +""" +body = """ +{% if version %}{{ version }} +------------------------------ + +{% else %}Unreleased +------------------------------ + +{% endif %}{% for group, commits in commits | group_by(attribute="group") %}{{ group }} +****************************** + +{% for commit in commits %}- {{ commit.message | trim | upper_first }} +{% endfor %} +{% endfor %} +""" +trim = true +render_always = false + +[tool.git-cliff.git] +conventional_commits = true +filter_unconventional = false +split_commits = false +protect_breaking_commits = false +filter_commits = false +tag_pattern = "v[0-9].*" +topo_order = false +topo_order_commits = true +sort_commits = "oldest" +commit_preprocessors = [ + { pattern = "^new:\\s*", replace = "feat: " }, + { pattern = "^doc:\\s*", replace = "docs: " }, + { pattern = "^infra:\\s*", replace = "chore(infra): " }, +] +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(infra\\)", group = "Infrastructure" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^Merge pull request", skip = true }, + { message = "^fixup", skip = true }, + { message = "^Update version$", skip = true }, + { message = "^.*$", group = "Other" }, +] + [tool.ruff] # Exclude a variety of commonly ignored directories. exclude = [ @@ -33,8 +140,8 @@ exclude = [ line-length = 88 indent-width = 4 -# Assume Python 3.8 -target-version = "py38" +# Assume Python 3.10 +target-version = "py310" [tool.ruff.lint] select = ["RUF", "E", "W", "F", "I", "D", "UP"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 885a9ea..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -prompt_toolkit==3.0.47 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index fb6a0c0..0000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -"""Setup cmdcraft library.""" - -import os -import re - -from setuptools import find_packages, setup - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: - """Extract version from README.rst.""" - long_description = f.read() - - -def get_version(package): - """Return package version as listed in `__version__` in `__init__.py`.""" - path = os.path.join(os.path.dirname(__file__), "src", package, "__init__.py") - with open(path, "rb") as f: - init_py = f.read().decode("utf-8") - return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) - - -setup( - name="cmdcraft", - author="A. M. Weinsen Jr.", - version=get_version("cmdcraft"), - url="https://github.com/weinsen/cmdcraft", - description="", - long_description=long_description, - long_description_content_type="text/x-rst", - packages=find_packages(where="src"), - package_dir={"": "src"}, - package_data={"cmdcraft": ["py.typed"]}, - install_requires=["prompt_toolkit"], - python_requires=">=3.10.0", - extras_require={ - "dev": ["ruff", "pytest"], - }, - classifiers=[ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python", - "Topic :: Software Development", - ], -) diff --git a/tools/analyse.sh b/tools/analyse.sh deleted file mode 100755 index 93fd79f..0000000 --- a/tools/analyse.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -# ***************************************************************************** -# Copyright (c) 2024, Antonio Mario Weinsen Junior -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. -# ***************************************************************************** - -# Test only new / modified files -FILES=$(git diff --name-only --diff-filter=d develop | grep -E "(\.py$)") - -if [ -z "${FILES}" ]; then - exit 0 -fi - -ruff check ${FILES} \ No newline at end of file diff --git a/tools/common.sh b/tools/common.sh new file mode 100644 index 0000000..30a8add --- /dev/null +++ b/tools/common.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** + +tool_repo_root() { + git rev-parse --show-toplevel 2>/dev/null || pwd +} + +tool_first_existing_ref() { + for ref in "$@"; do + if [ -n "$ref" ] && git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then + printf '%s\n' "$ref" + return 0 + fi + done + + return 1 +} + +tool_python_bin() { + if [ -n "${PYTHON:-}" ]; then + printf '%s\n' "${PYTHON}" + return 0 + fi + + if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "${VIRTUAL_ENV}/bin/python" ]; then + printf '%s\n' "${VIRTUAL_ENV}/bin/python" + return 0 + fi + + repo_root=$(tool_repo_root) + if [ -x "${repo_root}/.venv/bin/python" ]; then + printf '%s\n' "${repo_root}/.venv/bin/python" + return 0 + fi + + if command -v python3 >/dev/null 2>&1; then + printf '%s\n' 'python3' + return 0 + fi + + if command -v python >/dev/null 2>&1; then + printf '%s\n' 'python' + return 0 + fi + + echo 'No Python interpreter found on PATH.' >&2 + return 1 +} + +tool_resolve_base_ref() { + base_ref_override=${TOOLS_BASE_REF:-${CMDCRAFT_BASE_REF:-}} + if [ -n "${base_ref_override}" ]; then + tool_first_existing_ref "${base_ref_override}" "origin/${base_ref_override}" + return $? + fi + + if [ -n "${GITHUB_BASE_REF:-}" ]; then + tool_first_existing_ref "origin/${GITHUB_BASE_REF}" "${GITHUB_BASE_REF}" + return $? + fi + + if [ -n "${GITHUB_SHA:-}" ] && git rev-parse --verify --quiet HEAD^ >/dev/null; then + printf '%s\n' 'HEAD^' + return 0 + fi + + if git symbolic-ref refs/remotes/origin/HEAD >/dev/null 2>&1; then + git symbolic-ref --short refs/remotes/origin/HEAD + return 0 + fi + + tool_first_existing_ref origin/master master origin/main main +} + +tool_tracked_python_files() { + git ls-files '*.py' +} + +tool_changed_python_files() { + all_files=${TOOLS_ALL_FILES:-${CMDCRAFT_ALL_FILES:-0}} + if [ "${all_files}" = "1" ]; then + tool_tracked_python_files + return 0 + fi + + base_ref=$(tool_resolve_base_ref 2>/dev/null || true) + if [ -n "$base_ref" ]; then + git diff --name-only --diff-filter=d "$base_ref" -- '*.py' + return 0 + fi + + tool_tracked_python_files +} + +tool_python_targets() { + tool_changed_python_files | sed '/^$/d' +} + +tool_python_targets_for_scope() { + case "${1:-changed}" in + changed) + tool_changed_python_files + ;; + all) + tool_tracked_python_files + ;; + *) + echo "Unknown scope: ${1}" >&2 + return 1 + ;; + esac | sed '/^$/d' +} \ No newline at end of file diff --git a/tools/docs.sh b/tools/docs.sh deleted file mode 100755 index e3a2282..0000000 --- a/tools/docs.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# ***************************************************************************** -# Copyright (c) 2024, Antonio Mario Weinsen Junior -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. -# ***************************************************************************** - -rm -rf docs/build -sphinx-build -M html docs/source docs/build \ No newline at end of file diff --git a/tools/format.sh b/tools/format.sh index 22a98c9..027d7e5 100755 --- a/tools/format.sh +++ b/tools/format.sh @@ -1,18 +1,23 @@ #!/bin/sh # ***************************************************************************** -# Copyright (c) 2024, Antonio Mario Weinsen Junior +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. # ***************************************************************************** -# Test only new / modified files -FILES=$(git diff --name-only --diff-filter=d develop | grep -E "(\.py$)") +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/common.sh" + +PYTHON_BIN=$(tool_python_bin) +FILES=$(tool_python_targets_for_scope changed) if [ -z "${FILES}" ]; then exit 0 fi -ruff check ${FILES} --fix -ruff format ${FILES} \ No newline at end of file +"${PYTHON_BIN}" -m ruff check --fix ${FILES} +"${PYTHON_BIN}" -m ruff format ${FILES} \ No newline at end of file diff --git a/tools/lint.sh b/tools/lint.sh new file mode 100755 index 0000000..409ea04 --- /dev/null +++ b/tools/lint.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# ***************************************************************************** +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +# ***************************************************************************** + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/common.sh" + +usage() { + echo "Usage: $0 [--all|--changed]" >&2 +} + +scope=all + +case "${1:-}" in + "") + ;; + --all) + scope=all + ;; + --changed) + scope=changed + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; +esac + +PYTHON_BIN=$(tool_python_bin) +FILES=$(tool_python_targets_for_scope "$scope") + +if [ -z "${FILES}" ]; then + exit 0 +fi + +"${PYTHON_BIN}" -m ruff check ${FILES} +"${PYTHON_BIN}" -m ruff format --check ${FILES} \ No newline at end of file diff --git a/tools/test.sh b/tools/test.sh index ea4480c..88a59f3 100755 --- a/tools/test.sh +++ b/tools/test.sh @@ -1,10 +1,17 @@ #!/bin/sh # ***************************************************************************** -# Copyright (c) 2024, Antonio Mario Weinsen Junior +# Copyright (c) 2024-2026, Antonio Mario Weinsen Junior # All rights reserved. # # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. # ***************************************************************************** -python3 -m pytest test \ No newline at end of file +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/common.sh" + +PYTHON_BIN=$(tool_python_bin) + +"${PYTHON_BIN}" -m pytest test "$@" \ No newline at end of file