diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4913f923b..5e045630e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version (just in case). - python-versions-linux: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' # Since the test suite takes ~4 minutes to complete on windows, and windows is billed higher # we are only going to run it on the oldest version of python we support. The older version # will be the most likely area to fail as newer minor versions maintain compatibility. diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index cb807c1b5..c7d1033c5 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -76,8 +76,8 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version. - python-versions-linux: '["3.8", "3.13"]' - python-versions-windows: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' + python-versions-windows: '["3.8", "3.14"]' files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 509e66b87..4f9e277c4 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -14,6 +14,11 @@ on: type: boolean required: true default: true + python3-14: + description: 'Test Python 3.14?' + type: boolean + required: true + default: true python3-13: description: 'Test Python 3.13?' type: boolean @@ -79,6 +84,7 @@ jobs: "3.11" if str(os.getenv("INPUT_PY3_11", False)).lower() == str(True).lower() else None, "3.12" if str(os.getenv("INPUT_PY3_12", False)).lower() == str(True).lower() else None, "3.13" if str(os.getenv("INPUT_PY3_13", False)).lower() == str(True).lower() else None, + "3.14" if str(os.getenv("INPUT_PY3_14", False)).lower() == str(True).lower() else None, ])) linux_versions = ( @@ -105,6 +111,7 @@ jobs: INPUT_PY3_11: ${{ inputs.python3-11 }} INPUT_PY3_12: ${{ inputs.python3-12 }} INPUT_PY3_13: ${{ inputs.python3-13 }} + INPUT_PY3_14: ${{ inputs.python3-14 }} INPUT_LINUX: ${{ inputs.linux }} INPUT_WINDOWS: ${{ inputs.windows }} run: | diff --git a/pyproject.toml b/pyproject.toml index 45c9c2ea1..4b2263493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,13 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] readme = "README.rst" -authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] +authors = [ + { name = "Rolf Erik Lekang", email = "me@rolflekang.com" }, + { name = "codejedi365", email = "codejedi365@gmail.com" }, +] dependencies = [ "click ~= 8.1.0", "click-option-group ~= 0.5", @@ -29,7 +33,7 @@ dependencies = [ "requests ~= 2.25", "jinja2 ~= 3.1", "python-gitlab >= 4.0.0, < 7.0.0", - "tomlkit ~= 0.11", + "tomlkit ~= 0.13.0", "dotty-dict ~= 1.3", "importlib-resources ~= 6.0", "pydantic ~= 2.0", @@ -52,7 +56,8 @@ repository = "http://github.com/python-semantic-release/python-semantic-release. [project.optional-dependencies] build = [ - "build ~= 1.2" + "build ~= 1.2", + "tomlkit ~= 0.13.0", ] docs = [ "Sphinx ~= 7.4", diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 000000000..2aa90ba99 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi +} + +function with_temp_working_dir() { + local working_dir="${1:?"Working directory not specified, but is required!"}" + pushd "$working_dir" >/dev/null || return 1 + explicit_run_cmd "${@:2}" || return 1 + popd >/dev/null || return 1 +} + +function build_sdist() { + local status_msg="${1:?"Status message not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + if ! explicit_run_cmd_w_status_wrapper \ + "$status_msg" \ + python3 -m build --sdist . --outdir "$output_dir" ">/dev/null"; + then + return 1 + fi + find "$output_dir" -type f -name "*.tar.gz" -exec \ + sh -c 'printf "%s\n" "Successfully built $1"' shell {} \; +} + +function unpack_sdist() { + local sdist_file="${1:?"Source distribution file not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + mkdir -p "$output_dir" + if ! explicit_run_cmd_w_status_wrapper \ + "Unpacking sdist code into '$output_dir'" \ + tar -xzf "$sdist_file" -C "$output_dir" --strip-components=1; + then + return 1 + fi +} + + +function strip_optional_dependencies { + local -r pyproject_file="${1:?'param[1]: Path to pyproject.toml file is required'}" + local -r exclude_groups=("${@:2}") + local python_snippet="\ + from pathlib import Path + from sys import argv, exit + try: + import tomlkit + except ModuleNotFoundError: + print('Failed Import: Missing build requirement \'tomlkit\'.') + exit(1) + + pyproject_file = Path(argv[1]) + config = tomlkit.loads(pyproject_file.read_text()) + proj_config = config.get('project', {}) + + if not (opt_deps := proj_config.get('optional-dependencies', {})): + exit(0) + + if not (dep_group_to_remove := argv[2:]): + exit(0) + + for group in dep_group_to_remove: + if group in opt_deps: + opt_deps.pop(group) + + if not opt_deps: + proj_config.pop('optional-dependencies') + + pyproject_file.write_text(tomlkit.dumps(config)) + " + # make whitespace nice for python (remove indent) + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + if [ "${#exclude_groups[@]}" -eq 0 ]; then + error "At least one dependency group to exclude must be specified!" + return 1 + fi + + python3 -c "$python_snippet" "$pyproject_file" "${exclude_groups[@]}" || return 1 +} + +remove_empty_init_files() { + local dirpath="${1:-.}" + + # SNIPPET: Remove empty __init__.py files + local python_snippet='\ + from pathlib import Path + from sys import exit, argv, stderr + + if len(argv) < 2 or not (dirpath := Path(argv[1])).is_dir(): + print("Usage: ", file=stderr) + exit(1) + + for filepath in dirpath.resolve().rglob("__init__.py"): + if not filepath.is_file(): + continue + if not filepath.read_text().strip(): + filepath.unlink() + print(f"Removed {filepath}") + ' + # make whitespace nice for python + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + python3 -c "$python_snippet" "$dirpath" || return 1 +} + +function build_production_whl() { + # Assumes the current working directory is the directory to modify + local dest_dir="${1:?"param[1]: output directory not specified, but required!"}" + + # Strip out development dependencies + explicit_run_cmd_w_status_wrapper \ + "Masking development dependencies" \ + strip_optional_dependencies "pyproject.toml" "build" "dev" "docs" "test" "mypy" || return 1 + + # Optimize code for runtime + explicit_run_cmd_w_status_wrapper \ + "Removing empty '__init__.py' files" \ + remove_empty_init_files "src" || return 1 + + # Remove editable info from the source directory before wheel build + rm -rf src/*.egg-info/ + + # Build the wheel into the output directory + explicit_run_cmd_w_status_wrapper \ + "Constructing wheel package" \ + python3 -m build --wheel . --outdir "$dest_dir" || return 1 +} + +function build_wheel_from_sdist() { + local build_dir="${1:?"param[1]: Build directory not specified, but is required!"}" + local dest_dir="${2:?"param[2]: Output directory not specified, but is required!"}" + local tmp_src_dir="$build_dir/sdist" + + unpack_sdist "$build_dir/*.tar.gz" "$tmp_src_dir" || return 1 + + with_temp_working_dir "$tmp_src_dir" build_production_whl "$dest_dir" || return 1 + + rm -rf "$tmp_src_dir" +} + +function build_production_package() { + local dest_dir + local output_dir="${1:?"param[1]: Output directory not specified, but required!"}" + local build_dir="build" + + # If the output directory is not an absolute path, make it absolute + if ! stdout "$output_dir" | grep -q -E '^/'; then + dest_dir="$(realpath ".")/$output_dir" + else + dest_dir="$output_dir" + fi + + # Clean up any existing output directory + if [ -d "$dest_dir" ]; then + rm -rf "$dest_dir" + fi + + # Clean up any existing build directory + if [ -d "$build_dir" ]; then + rm -rf "$build_dir" + fi + + build_sdist "Bundling source code" "$build_dir" || return 1 + + explicit_run_cmd_w_status_wrapper \ + "Building production wheel from sdist" \ + build_wheel_from_sdist "$build_dir" "$dest_dir" || return 1 + + rm -rf "$build_dir" +} + +function main() { + set -eu -o pipefail + + cd "$PROJ_ROOT_DIR" + + if ! explicit_run_cmd_w_status_wrapper \ + "Verifying Python environment" \ + verify_python "$MINIMUM_PYTHON_VERSION"; + then + info "Please run the dev setup script and activate the virtual environment first." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Verifying build dependencies exist" \ + python3 -m pip install -e ".[build]" ">/dev/null" + + explicit_run_cmd_w_status_wrapper \ + "Building production package" \ + build_production_package "dist" +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 000000000..384f9735b --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +function load_base_env() { + set -eu -o pipefail + + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + PROJ_ROOT_DIR="$(realpath "$(dirname "$(realpath "$(dirname "$__FILE__")")")")" + export PROJ_ROOT_DIR + + export DIST_DIR="$PROJ_ROOT_DIR/dist" + export SCRIPTS_DIR="$PROJ_ROOT_DIR/scripts" + export VENV_DIR="$PROJ_ROOT_DIR/.venv" + export PROJECT_CONFIG_FILE="$PROJ_ROOT_DIR/pyproject.toml" + export MINIMUM_PYTHON_VERSION="3.9" + export PIP_DISABLE_PIP_VERSION_CHECK="true" +} + +function stdout { printf "%b\n" "$*"; } +function stderr { stdout "$@" >&2; } +function info { stdout "[+] $*"; } + +function warning { + local prefix="[!] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::notice::" + fi + stderr "${prefix}WARNING: $*"; +} + +function error { + local prefix="[-] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::error::" + fi + stderr "${prefix}ERROR: $*"; +} + +function is_command { + local cmd="${1:?"param[1]: missing command to check."}" + command -v "$cmd" >/dev/null || { + error "Command '$cmd' not found." + return 1 + } +} + +function explicit_run_cmd { + local cmd="${1:?"param[1]: command not specified, but is required!"}" + set -- "${@:2}" # shift off the first argument + local args="$*" + + # Default as a function call + local log_msg="$cmd($args)" + + # Needs to run in bash because zsh which will return 0 for a defined function + if bash -c "which $cmd >/dev/null"; then + log_msg="${SHELL:-/bin/sh} -c '$cmd $args'" + fi + + stderr " $log_msg" + eval "$cmd $args" +} + +function explicit_run_cmd_w_status_wrapper { + local status_msg="${1:?"param[1]: status message not specified, but is required!"}" + local cmd="${2:?"param[2]: command not specified, but is required!"}" + set -- "${@:3}" # shift off the first two arguments + + if [ -z "$cmd" ]; then + error "Command not specified, but is required!" + return 1 + fi + + info "${status_msg}..." + if ! explicit_run_cmd "$cmd" "$@"; then + error "${status_msg}...FAILED" + return 1 + fi + info "${status_msg}...DONE" +} + +function verify_python_version() { + local python3_exe="${1:?"param[1]: path to python3 executable is required"}" + local min_version="${2:?"param[2]: minimum python version is required"}" + + if ! [[ "$min_version" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then + error "Invalid minimum python version format: '$min_version'. Expected format: 'X', 'X.Y', or 'X.Y.Z'" + return 1 + fi + + local min_major_version="" + min_major_version="$(stdout "$min_version" | cut -d. -f1 | tr -d 'v')" + + local min_minor_version="" + min_minor_version="$(stdout "$min_version" | cut -d. -f2)" + min_minor_version="${min_minor_version:-0}" + + local min_patch_version="" + min_patch_version="$(stdout "$min_version" | cut -d. -f3)" + min_patch_version="${min_patch_version:-0}" + + local python_version_str="" + if ! python_version_str="$("$python3_exe" --version 2>&1 | awk '{print $2}')"; then + error "Failed to get python version string from '$python3_exe'" + return 1 + fi + + local python_major_version="" + python_major_version="$(stdout "$python_version_str" | cut -d. -f1)" + + local python_minor_version="" + python_minor_version="$(stdout "$python_version_str" | cut -d. -f2)" + + local python_patch_version="" + python_patch_version="$(stdout "$python_version_str" | cut -d. -f3)" + + if [ "$python_major_version" -ne "$min_major_version" ]; then + error "Python major version mismatch! Required version: $min_major_version, Found version: $python_version_str" + return 1 + fi + + if [ "$python_minor_version" -lt "$min_minor_version" ] || [ "$python_patch_version" -lt "$min_patch_version" ]; then + error "Python version ^${min_major_version}.${min_minor_version}.${min_patch_version}+ is required! Found version: $python_version_str" + return 1 + fi +} + +function verify_python() { + set -eu -o pipefail + local -r min_python_version="${1:?"param[1]: minimum python version parameter is required!"}" + + is_command "python3" || { + error "Python 3 is not detected. Script requires Python $min_python_version+!" + return 1 + } + + local python3_exe="" + python3_exe="$(which python3)" + + if ! [ -f "$(dirname "$python3_exe")/../pyvenv.cfg" ]; then + error "No virtual environment detected." + return 1 + fi + + verify_python_version "$python3_exe" "$min_python_version" +} + +export UTILITIES_SH_LOADED="true"