diff --git a/.github/.githubrc b/.github/.githubrc new file mode 100644 index 0000000..9eb67ca --- /dev/null +++ b/.github/.githubrc @@ -0,0 +1 @@ +alias cicd="$PWD/scripts/git-cicd.sh" \ No newline at end of file diff --git a/.github/.gitignore b/.github/.gitignore new file mode 100644 index 0000000..e02504a --- /dev/null +++ b/.github/.gitignore @@ -0,0 +1,5 @@ +.* + +!.gitignore +!.githubrc +!.github/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..99dea36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,25 @@ +name: Bug Report +description: Report something that isn't working correctly. +labels: ["bug"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What is the bug? Include steps to reproduce if applicable. + validations: + required: true + - type: textarea + id: root-cause + attributes: + label: Root cause + description: What is causing the bug? Include relevant code snippets. + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which files, modules, or components are affected? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/build.yaml b/.github/ISSUE_TEMPLATE/build.yaml new file mode 100644 index 0000000..6b6490a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/build.yaml @@ -0,0 +1,25 @@ +name: Build +description: Propose a build system or dependency change. +labels: ["build"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What build system or dependency change is needed? + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this change needed? + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which build files, configs, or dependencies are affected? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/cicd.yaml b/.github/ISSUE_TEMPLATE/cicd.yaml new file mode 100644 index 0000000..277c550 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cicd.yaml @@ -0,0 +1,25 @@ +name: CI/CD +description: Propose a CI/CD pipeline change. +labels: ["cicd"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What CI/CD change is needed? + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this change needed? What does it improve? + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which workflows, pipelines, or config files are affected? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..d444640 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,25 @@ +name: Feature Request +description: Propose a new feature or capability. +labels: ["feature"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What is the feature? Describe the desired behavior. + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this feature needed? What problem does it solve? + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which files, modules, or components would be affected? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/refactor.yaml b/.github/ISSUE_TEMPLATE/refactor.yaml new file mode 100644 index 0000000..3ab2bfc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yaml @@ -0,0 +1,25 @@ +name: Refactor +description: Propose a code restructuring without behavior change. +labels: ["refactor"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What should be restructured and what does the end state look like? + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this restructuring needed? + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which files, modules, or components are affected? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/test.yaml b/.github/ISSUE_TEMPLATE/test.yaml new file mode 100644 index 0000000..c417347 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test.yaml @@ -0,0 +1,25 @@ +name: Test +description: Add or improve test coverage or test infrastructure. +labels: ["test"] +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What needs to be tested or what test infrastructure is needed? + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + description: Why is this test work needed? What gap does it fill? + validations: + required: true + - type: textarea + id: affected-code + attributes: + label: Affected code + description: Which files, modules, or components are affected? + validations: + required: false diff --git a/.github/actions/add-label/action.yaml b/.github/actions/add-label/action.yaml new file mode 100644 index 0000000..aa4c02f --- /dev/null +++ b/.github/actions/add-label/action.yaml @@ -0,0 +1,22 @@ +name: Add label +description: Add specified label to pull request. +inputs: + label: + description: Label to add to the pull request. + required: true + type: string + +runs: + using: composite + steps: + - name: Verify event + if: ${{ github.event_name != 'pull_request' }} + shell: bash + run: | + echo "Error: This action can only be used on pull request events." + exit 1 + + - name: Add label + shell: bash + run: | + gh pr edit "${{ github.event.number }}" --add-label "${{ inputs.label }}" diff --git a/.github/actions/build-release/action.yaml b/.github/actions/build-release/action.yaml new file mode 100644 index 0000000..db0c01b --- /dev/null +++ b/.github/actions/build-release/action.yaml @@ -0,0 +1,34 @@ +name: Build release +description: Build distribution for the target version. +inputs: + source: + required: true + type: string + version: + required: true + type: string + +runs: + using: composite + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-tags: true + persist-credentials: false + ref: ${{ inputs.version }} + + - name: Install uv & prepare python + uses: astral-sh/setup-uv@v5 + with: + enable-cache: false + + - name: Build distribution artifacts + shell: bash + run: uv build ${{ inputs.source }} --sdist --wheel --out-dir ${{ inputs.source }}-dist-${{ inputs.version }} + + - name: Store distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.source }}-dist-${{ inputs.version }} + path: ${{ inputs.source }}-dist-${{ inputs.version }}/ diff --git a/.github/actions/get-touched-files/action.yaml b/.github/actions/get-touched-files/action.yaml new file mode 100644 index 0000000..d4a65c7 --- /dev/null +++ b/.github/actions/get-touched-files/action.yaml @@ -0,0 +1,31 @@ +name: Get touched files +description: Get a list of files that have been modified in a pull request. +inputs: + pathspec: + description: 'Optional pathspec(s) to filter files. E.g., "src/**" will only get files in the src directory.' + required: false + type: string +outputs: + touched: + description: 'List of files that have been modified in a pull request.' + value: ${{ steps.get-touched-files.outputs.touched }} + +runs: + using: composite + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Get touched files + id: get-touched-files + shell: bash + run: | + eval "pathspec=('${{ inputs.pathspec }}')" + echo "pathspec: '${pathspec[@]}'" + touched=$(git diff --name-only HEAD origin/master -- ${pathspec[@]}) + touched=$(echo "$touched" | tr '\n' ' ' | xargs) + echo "touched: '$touched'" + echo "touched=$touched" >> $GITHUB_OUTPUT diff --git a/.github/actions/get-wool-labs-app-token/action.yaml b/.github/actions/get-wool-labs-app-token/action.yaml new file mode 100644 index 0000000..cdd1d29 --- /dev/null +++ b/.github/actions/get-wool-labs-app-token/action.yaml @@ -0,0 +1,32 @@ +name: get-wool-labs-app-token +description: "Obtain a GitHub App installation access token to use in place of a PAT" +inputs: + app-id: + description: "The ID of the GitHub App" + required: true + app-installation-id: + description: "The installation ID of the GitHub App" + required: true + app-private-key: + description: "The private key of the GitHub App" + required: true +outputs: + access-token: + description: "Access token to use in place of a PAT" + value: ${{ steps.generate-access-token.outputs.access-token }} + +runs: + using: "composite" + steps: + - name: Generate Access Token + id: generate-access-token + shell: bash + run: | + echo "${{ inputs.app-private-key }}" > /tmp/app-private-key.pem + access_token="$(APP_INSTALLATION_ID=${{ inputs.app-installation-id }} \ + APP_ID=${{ inputs.app-id }} \ + SIGNING_KEY_PATH="/tmp/app-private-key.pem" \ + .github/scripts/generate-github-access-token.sh)" + echo "::add-mask::$access_token" + echo "access-token=$access_token" >> $GITHUB_OUTPUT + \ No newline at end of file diff --git a/.github/actions/publish-github-release/action.yaml b/.github/actions/publish-github-release/action.yaml new file mode 100644 index 0000000..c7264a0 --- /dev/null +++ b/.github/actions/publish-github-release/action.yaml @@ -0,0 +1,30 @@ +name: Publish release to GitHub +description: Publish the target version to GitHub. + +inputs: + source: + required: true + type: string + version: + required: true + type: string + +runs: + using: composite + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.source }}-dist-${{ inputs.version }} + path: ${{ inputs.source }}-dist-${{ inputs.version }}/ + + - name: Sign the artifacts with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: ./${{ inputs.source }}-dist-${{ inputs.version }}/*.tar.gz ./${{ inputs.source }}-dist-${{ inputs.version }}/*.whl + + - name: Upload artifact signatures to GitHub release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload ${{ inputs.version }} ${{ inputs.source }}-dist-${{ inputs.version }}/** --repo ${{ github.repository }} diff --git a/.github/actions/publish-pypi-release/action.yaml b/.github/actions/publish-pypi-release/action.yaml new file mode 100644 index 0000000..51ad878 --- /dev/null +++ b/.github/actions/publish-pypi-release/action.yaml @@ -0,0 +1,30 @@ +name: Publish release to PyPI +description: Publish the target version to PyPI. + +inputs: + source: + required: true + type: string + version: + required: true + type: string + pypi-token: + required: true + type: string + +runs: + using: composite + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.source }}-dist-${{ inputs.version }} + path: ${{ inputs.source }}-dist-${{ inputs.version }}/ + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Upload distribution to PyPI + shell: bash + run: | + .github/scripts/publish-distribution.sh --source ${{ inputs.source }}-dist-${{ inputs.version }} ${{ inputs.pypi-token }} diff --git a/.github/scripts/bump-version.sh b/.github/scripts/bump-version.sh new file mode 100755 index 0000000..8e146c1 --- /dev/null +++ b/.github/scripts/bump-version.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +USAGE="Usage: $0 major|minor|patch VERSION [short|full=short]" + +# Evaluate arguments +case $# in + 3) + case $3 in + short) + FULL=false + ;; + full) + FULL=true + ;; + *) + FULL=false + ;; + esac + ;; + 2) + FULL=false + ;; + *) + echo $USAGE + exit 1 + ;; +esac + +# Evaluate version segment +case $1 in + major|minor|patch) + SEGMENT=$1 + ;; + *) + echo "ERROR: Invalid version segment: $1" >&2 + echo $USAGE + exit 1 + ;; +esac + +# Determine release cycle +VERSION=$2 +case $VERSION in + *a*) + CYCLE="a" + PRE_RELEASE=true + ;; + *b*) + CYCLE="b" + PRE_RELEASE=true + ;; + *rc*) + CYCLE="rc" + PRE_RELEASE=true + ;; + *) + CYCLE="." + PRE_RELEASE=false + ;; +esac + +if [ "$PRE_RELEASE" = true ] && [[ "$SEGMENT" == "major" ]]; then + echo "ERROR: Cannot bump major version segment of a pre-release version" >&2 + exit 1 +fi + +#Split version +read MAJOR MINOR PATCH <<< $(.github/scripts/split-version.sh $VERSION) + +case $SEGMENT in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + case $CYCLE in + ".") + MINOR=$((MINOR + 1)) + ;; + "a") + MINOR=$((MINOR)) + CYCLE="b" + ;; + "b") + MINOR=$((MINOR)) + CYCLE="rc" + ;; + "rc") + MINOR=$((MINOR)) + CYCLE="." + ;; + esac + PATCH=0 + ;; + patch) + if [ -z "$PATCH" ]; then + PATCH=0 + fi + PATCH=$((PATCH + 1)) + ;; +esac + +if [ "$CYCLE" == "." ] && [ "$PATCH" -eq 0 ] && [ "$FULL" == false ]; then + echo "v$MAJOR.$MINOR" +else + echo "v$MAJOR.$MINOR$CYCLE$PATCH" +fi diff --git a/.github/scripts/cut-release.sh b/.github/scripts/cut-release.sh new file mode 100755 index 0000000..f547cc8 --- /dev/null +++ b/.github/scripts/cut-release.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +USAGE="Usage: $0 major|minor [BRANCH=release]" + +# Evaluate arguments +case $1 in + major|minor) + RELEASE_TYPE=$1 + ;; + *) + echo "ERROR: Invalid release type: $1" >&2 + echo $USAGE + exit 1 + ;; +esac +case $# in + 1) + BRANCH="release" + ;; + 2) + BRANCH=$2 + ;; + *) + echo $USAGE + exit 1 + ;; +esac + +git fetch --unshallow >/dev/null 2>&1 +git checkout main >/dev/null 2>&1 +git pull >/dev/null 2>&1 + +# Check if the release branch already exists +if git show-ref --verify --quiet refs/heads/$BRANCH; then + echo "ERROR: Branch '$BRANCH' already exists." >&2 + exit 1 +fi + +# Get the latest version tag, default to 0.0.0 +VERSION=$(git describe --tags --abbrev=0) + +# Verify no active release candidates exist +if [[ $VERSION == *rc* ]]; then + echo "ERROR: An active release candidate already exists: $VERSION" >&2 + exit 1 +fi + +read MAJOR MINOR PATCH <<< $(.github/scripts/split-version.sh $VERSION) + +# Bump the version +case $RELEASE_TYPE in + major) + RELEASE_VERSION="$((MAJOR + 1)).0rc0" + ;; + minor) + RELEASE_VERSION="${MAJOR}.$((MINOR + 1))rc0" + ;; +esac + +RELEASE_TAG="v$RELEASE_VERSION" + +# Create a new branch for the release candidate +OUTPUT=$(git checkout -b $BRANCH >/dev/null 2>&1) +if [ $? -ne 0 ]; then + echo "ERROR: Failed to create branch '$BRANCH'." >&2 + echo "$OUTPUT" >&2 + exit 1 +fi +OUTPUT=$(git push origin $BRANCH 2>&1) +if [ $? -ne 0 ]; then + echo "ERROR: Failed to push branch '$BRANCH' to origin." >&2 + echo "$OUTPUT" >&2 + exit 1 +fi + +echo $RELEASE_TAG diff --git a/.github/scripts/find-cicd.sh b/.github/scripts/find-cicd.sh new file mode 100644 index 0000000..6847472 --- /dev/null +++ b/.github/scripts/find-cicd.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +if [ -d "$(pwd)/..git" ]; then + echo "$(pwd)" +elif [ -d "$(pwd)/.github" ]; then + echo "$(pwd)/.github" +else + echo "." +fi diff --git a/.github/scripts/generate-github-access-token.sh b/.github/scripts/generate-github-access-token.sh new file mode 100755 index 0000000..9fb839f --- /dev/null +++ b/.github/scripts/generate-github-access-token.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +# Inspired by implementation by Will Haley at: +# http://willhaley.com/blog/generate-jwt-with-bash/ + +# Stolen from +# https://stackoverflow.com/questions/46657001/how-do-you-create-an-rs256-jwt-assertion-with-bash-shell-scripting +# and simplified to suit our needs + +set -euo pipefail + +app_id=${APP_ID?"Need to set APP_ID"} # The app id of the app +app_installation_id=${APP_INSTALLATION_ID?"Need to set APP_INSTALLATION_ID"} # The installation id of the app +siging_key_path=${SIGNING_KEY_PATH?"Need to set SIGNING_KEY_PATH"} # Path to the private key used to sign the JWT as a pem file + +header_template='{ + "typ": "JWT", + "kid": "0001", + "iss": "https://gist.github.com/Nastaliss/7f8466f59072d744540190721a63672d" +}' + +build_header() { + jq -c \ + --arg iat_str "$(date +%s)" \ + --arg alg RS256 \ + ' + ($iat_str | tonumber) as $iat + | .alg = $alg + | .iat = $iat + | .exp = ($iat + 1) + ' <<<"$header_template" | tr -d '\n' +} + +b64enc() { openssl enc -base64 -A | tr '+/' '-_' | tr -d '='; } +json() { jq -c . | LC_CTYPE=C tr -d '\n'; } + +sign() { + local payload header sig secret=$2 + header=$(build_header) || return + payload=${1} + signed_content="$(json <<<"$header" | b64enc).$(json <<<"$payload" | b64enc)" + sig=$(printf %s "$signed_content" | openssl dgst -binary -sha256 -sign <(printf '%s\n' "$secret") | b64enc) + printf '%s.%s\n' "${signed_content}" "${sig}" +} + +# Construct the payload +# Max duration is one hour, account for clock skew by subtracting 10 seconds +payload="{\"iat\": $(($(date +%s) - 10)),\"exp\": $(($(date +%s) - 10 + 10 * 60)),\"iss\": \"${app_id}\"}" + +# Get the secret from a file +# The secret needs to be a pem file with line breaks, with or without leading/trailing line break +secret=$(cat "$siging_key_path") + +# Generate a jwt, according to https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-an-installation-access-token-for-a-github-app +# and https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#generating-a-json-web-token-jwt +jwt=$(sign "$payload" "$secret") + +# Actually get an access token from the github api +curl -s --location --request POST "https://api.github.com/app/installations/$app_installation_id/access_tokens" \ + --header "Authorization: Bearer $jwt" \ + --header 'Accept: application/vnd.github+json' \ + --header 'X-GitHub-Api-Version: 2022-11-28' | jq ".token" | tr -d '"' diff --git a/.github/scripts/git-cicd.sh b/.github/scripts/git-cicd.sh new file mode 100755 index 0000000..dfea859 --- /dev/null +++ b/.github/scripts/git-cicd.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +if [ -d "$(pwd)/..git" ]; then + GIT_DIR="$(pwd)/..git" + WORK_TREE="$(pwd)" +elif [ -d "$(pwd)/.github" ]; then + GIT_DIR="$(pwd)/.github/..git" + WORK_TREE="$(pwd)/.github" +else + echo "Neither '.git' nor '.github' directory found." + exit 1 +fi +# Execute the git command with all the provided arguments +git --git-dir="$GIT_DIR" --work-tree="$WORK_TREE" "$@" diff --git a/.github/scripts/init.sh b/.github/scripts/init.sh new file mode 100644 index 0000000..7382693 --- /dev/null +++ b/.github/scripts/init.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +mv .github/.git .github/..git +source .github/.githubrc diff --git a/.github/scripts/install-python-packages.sh b/.github/scripts/install-python-packages.sh new file mode 100755 index 0000000..7d79944 --- /dev/null +++ b/.github/scripts/install-python-packages.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Install all Python namespace packages in the current directory +for dir in */; do + if [ -d "$dir" ] && [ -f "$dir/pyproject.toml" ]; then + (cd "$dir" && uv pip install --no-deps .) + fi +done diff --git a/.github/scripts/install-tools.sh b/.github/scripts/install-tools.sh new file mode 100755 index 0000000..aa11045 --- /dev/null +++ b/.github/scripts/install-tools.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +# Check if Homebrew (brew) is installed +if ! command -v brew &> /dev/null; then + + # Prompt the user to install Homebrew (defaults to "yes") + read -p "Homebrew (brew) is not installed. Do you want to install it now? (Y/n): " brew_choice + brew_choice=${brew_choice:-Y} + if [[ "$brew_choice" == "y" || "$brew_choice" == "Y" ]]; then + + # Install Homebrew using the official installation script + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + # Confirm the installation + if ! command -v brew &> /dev/null; then + echo "Error: Homebrew installation failed." + exit 1 + fi + else + echo "Homebrew is required. Please install it manually." + exit 1 + fi +else + echo "Homebrew is already installed." +fi + +# Check if UV is installed +if ! command -v uv &> /dev/null; then + + # Prompt the user to install UV (defaults to "yes") + read -p "UV is required but not installed. Do you want to install it now? (Y/n): " gh_choice + uv_choice=${uv_choice:-Y} + if [[ "$uv_choice" == "y" || "$uv_choice" == "Y" ]]; then + + # Install UV using Homebrew + brew install uv + + # Confirm the installation + if ! command -v uv &> /dev/null; then + echo "Error: UV installation failed." + exit 1 + fi + else + echo "UV is required. Please install it manually." + exit 1 + fi +else + echo "UV is already installed." +fi + +# Check if the GitHub CLI is installed +if ! command -v gh &> /dev/null; then + + # Prompt the user to install GitHub CLI (defaults to "yes") + read -p "GitHub CLI (gh) is required but not installed. Do you want to install it now? (Y/n): " gh_choice + gh_choice=${gh_choice:-Y} + if [[ "$gh_choice" == "y" || "$gh_choice" == "Y" ]]; then + + # Install GitHub CLI using Homebrew + brew install gh + + # Confirm the installation + if ! command -v gh &> /dev/null; then + echo "Error: GitHub CLI installation failed." + exit 1 + fi + else + echo "GitHub CLI is required. Please install it manually." + exit 1 + fi +else + echo "GitHub CLI is already installed." +fi + +# Check if the keychain secrets manager (ks) is installed +if ! command -v ks &> /dev/null; then + + # Prompt the user to install ks (defaults to "yes") + read -p "Keychain secrets manager is required but not installed. Do you want to install it now? (Y/n): " ks_choice + ks_choice=${ks_choice:-Y} + if [[ "$ks_choice" == "y" || "$ks_choice" == "Y" ]]; then + + # Install keychain secrets manager using Homebrew + brew tap loteoo/formulas + brew install ks + + # Confirm the installation + if ! command -v ks &> /dev/null; then + echo "Error: Keychain secrets manager installation failed." + exit 1 + fi + else + echo "Keychain secrets manager is required. Please install it manually." + exit 1 + fi +else + echo "Keychain secrets manager is already installed." +fi diff --git a/.github/scripts/publish-distribution.sh b/.github/scripts/publish-distribution.sh new file mode 100755 index 0000000..cad7710 --- /dev/null +++ b/.github/scripts/publish-distribution.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +USAGE="Usage: $0 [-s|--source=dist] [[KEYCHAIN=dev SECRET=pypi-token] | [TOKEN]]" +SOURCE="dist" +KEYCHAIN="dev" +SECRET="pypi-token" + +# Parse options +ARGS=() +while [[ "$#" -gt 0 ]]; do + case $1 in + -s|--source) + # Set source directory + if [[ -z "$2" ]]; then + echo "Error: --source requires a value." + echo $USAGE + exit 1 + fi + SOURCE="$2" + shift + ;; + *) + # Collect arguments + ARGS+=("$1") + ;; + esac + shift +done +echo "Publishing artifacts in '$SOURCE' directory..." + +# Parse arguments +case ${#ARGS[@]} in + 0) + # Attempt to retrieve token from default keychain + if command -v ks &> /dev/null; then + if [[ -n $(ks -k $KEYCHAIN ls | grep "\b$SECRET\b") ]]; then + echo "Publishing with token from keychain..." + TOKEN=$(ks -k $KEYCHAIN show $SECRET) + else + echo "Warning: Keychain does not contain 'pypi-token' secret." + echo "Publishing without token..." + fi + else + echo "Warning: Keychain secrets manager (ks) is not installed. Please install it to use keychain secrets." + echo "Publishing without token..." + fi + ;; + 1) + # Use the specified token + echo "Publishing with provided token..." + TOKEN="${ARGS[0]}" + ;; + 2) + # Attempt to retrieve token from the specified keychain + echo "Publishing with token from keychain..." + KEYCHAIN="${ARGS[0]}" + SECRET="${ARGS[1]}" + TOKEN=$(ks -k $KEYCHAIN show $SECRET) + ;; + *) + # Improper usage + echo $USAGE + exit 1 + ;; +esac + +# Publish the package +if [ -n "$TOKEN" ]; then + uv publish --username "__token__" --password "$TOKEN" "$SOURCE"/* +else + uv publish "$SOURCE/*" +fi diff --git a/.github/scripts/release-pr-exists.sh b/.github/scripts/release-pr-exists.sh new file mode 100755 index 0000000..de5ff0c --- /dev/null +++ b/.github/scripts/release-pr-exists.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Check for the existence of a PR with the label "release" and state "open" +gh pr list --label release --state open --json number --jq '. | length > 0' diff --git a/.github/scripts/set-secrets.sh b/.github/scripts/set-secrets.sh new file mode 100755 index 0000000..1148b10 --- /dev/null +++ b/.github/scripts/set-secrets.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +USAGE="Usage: $0 [-k|--keychain ] [-l|--logout]" + +if [[ "$1" == "--help" ]]; then + echo $USAGE + exit 0 +fi + +LOGOUT=false +while [[ "$#" -gt 0 ]]; do + case $1 in + -l|--logout) LOGOUT=true;; + -k|--keychain) KEYCHAIN="$2"; shift ;; + *) echo $USAGE; exit 1 ;; + esac + shift +done + +if [[ ! $(gh auth status) ]]; then + gh auth login +fi +for KEY in "pypi-token" "wool-labs-app-id" "wool-labs-installation-id" "wool-labs-private-key"; do + if [[ -n "$KEYCHAIN" && -n $(ks -k $KEYCHAIN ls | grep "\b$KEY\b") ]]; then + echo "Using $KEY from keychain" + SECRET=$(ks -k $KEYCHAIN show $KEY) + else + read -sp "Enter a value for $KEY: " SECRET + if [[ -z "$SECRET" ]]; then + echo "" + echo "Error: A value for $KEY is required." + exit 1 + fi + fi + gh secret set $(echo $KEY | tr '-' '_' | tr '[:lower:]' '[:upper:]') --app actions --body $SECRET +done +if [[ "$LOGOUT" == true ]]; then + gh auth logout +fi diff --git a/.github/scripts/split-version.sh b/.github/scripts/split-version.sh new file mode 100755 index 0000000..a69a46b --- /dev/null +++ b/.github/scripts/split-version.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +USAGE="Usage: $0 [VERSION=0.0.0]" + +# Evaluate arguments +case $# in + 1) + VERSION=$1 + ;; + *) + VERSION="0.0.0" + ;; +esac + +# Strip the leading "v" if there is one +if [[ $VERSION == v* ]]; then + VERSION=${VERSION#v} +fi + +# Replace pre-release identifiers with a dot +if [[ $VERSION == *a* ]]; then + VERSION=${VERSION//a/.} +elif [[ $VERSION == *b* ]]; then + VERSION=${VERSION//b/.} +elif [[ $VERSION == *rc* ]]; then + VERSION=${VERSION//rc/.} +fi + +# Split the tag into its components +IFS='.' read -r -a VERSION_PARTS <<< "$VERSION" + +echo "${VERSION_PARTS[0]} ${VERSION_PARTS[1]} ${VERSION_PARTS[2]}" diff --git a/.github/workflows/cut-release.yaml b/.github/workflows/cut-release.yaml new file mode 100644 index 0000000..ad70126 --- /dev/null +++ b/.github/workflows/cut-release.yaml @@ -0,0 +1,158 @@ +name: Cut release +description: Cut a release branch and publish a release-candidate. + +on: + workflow_dispatch: + inputs: + release-type: + type: choice + options: + - major + - minor + default: minor + +jobs: + verify-code-changes: + name: Verify code has changed + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + - name: Get touched files + id: get-touched-files + uses: ./.github/actions/get-touched-files + with: + pathspec: 'wool/src/ wool/pyproject.toml' + - name: Fail if code hasn't changed + if: ${{ ! steps.get-touched-files.outputs.touched }} + run: | + echo "ERROR: No code changes detected." + exit 1 + + cut-release: + name: Cut ${{ github.event.inputs.release-type }} release + needs: verify-code-changes + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + version: ${{ steps.cut-release-branch.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Verify main is up to date + run: | + git fetch origin main + git fetch origin master + if [[ $(git rev-list --count --left-only origin/master..origin/main) -gt 0 ]]; then + echo "Error: main is not up to date with master" >&2 + exit 1 + fi + - name: Create release branch + id: cut-release-branch + run: | + version=$(.github/scripts/cut-release.sh ${{ github.event.inputs.release-type }} release) + echo "Release candidate: $version" + echo "version=$version" >> $GITHUB_OUTPUT + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: release + - name: Tag version + run: | + git tag ${{ steps.cut-release-branch.outputs.version }} + git push origin ${{ steps.cut-release-branch.outputs.version }} + - name: Create pull request + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + if $(.github/scripts/release-pr-exists.sh); then + echo "Release PR already exists. Exiting..." + exit 0 + fi + workflow=$(echo '${{ github.workflow }}' | tr '[:upper:]' '[:lower:]') + body='Auto-generated by the ['$workflow'](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.' + version=$(.github/scripts/bump-version.sh minor ${{ steps.cut-release-branch.outputs.version }}) + gh pr create -B master -H release --title 'Release '$version'' --body "$body" + + build-release: + name: Build release + needs: cut-release + runs-on: ubuntu-latest + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/build-release + with: + source: ${{ matrix.source }} + version: ${{ needs.cut-release.outputs.version }} + + create-github-release: + name: Create GitHub release + needs: cut-release + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "${{ needs.cut-release.outputs.version }}" == *rc* ]]; then + prerelease_flag="--prerelease" + else + prerelease_flag="" + fi + gh release create ${{ needs.cut-release.outputs.version }} \ + --repo ${{ github.repository }} \ + --generate-notes \ + $prerelease_flag + + publish-github-release: + name: Publish release to GitHub + needs: + - cut-release + - build-release + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/publish-github-release + with: + source: ${{ matrix.source }} + version: ${{ needs.cut-release.outputs.version }} + + publish-pypi-release: + name: Publish release to PyPI + needs: + - cut-release + - build-release + runs-on: ubuntu-latest + permissions: + id-token: write + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/publish-pypi-release + with: + source: ${{ matrix.source }} + version: ${{ needs.cut-release.outputs.version }} + pypi-token: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/label-pr.yaml b/.github/workflows/label-pr.yaml new file mode 100644 index 0000000..af9f915 --- /dev/null +++ b/.github/workflows/label-pr.yaml @@ -0,0 +1,62 @@ +name: Label pull request + +on: + pull_request: + branches: + - master + - main + - release + types: + - opened + - reopened + - synchronize + paths: + - wool/src/** + - wool/pyproject.toml + +jobs: + add-code-change-label: + name: Add code-change label + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + if: ${{ github.head_ref != 'release' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Add label + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + uses: ./.github/actions/add-label + with: + label: code-change + + add-release-label: + name: Add release label + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + if: ${{ github.base_ref == 'master' && github.head_ref == 'release' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Add label + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + uses: ./.github/actions/add-label + with: + label: release diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml new file mode 100644 index 0000000..a3c5759 --- /dev/null +++ b/.github/workflows/publish-release.yaml @@ -0,0 +1,209 @@ +name: Publish release +description: Tag, build, and publish a release or release-candidate on PR merge. + +on: + pull_request: + branches: + - master + - release + types: + - closed + paths: + - wool/src/** + - wool/pyproject.toml + workflow_dispatch: + inputs: + version: + type: string + +jobs: + bump-version: + name: Bump version + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged == true || github.event.inputs.version != '' }} + permissions: + contents: write + outputs: + version: ${{ steps.bump-version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Determine version segment to bump + id: determine-version-segment + if: ${{ github.event.inputs.version == '' }} + run: | + case ${{ github.base_ref }} in + master) + case ${{ github.head_ref }} in + release) + echo "segment=minor" >> $GITHUB_OUTPUT + ;; + *) + echo "segment=patch" >> $GITHUB_OUTPUT + ;; + esac + ;; + release) + echo "segment=patch" >> $GITHUB_OUTPUT + ;; + *) + echo "Error: Unsupported base branch ${{ github.base_ref }}" >&2 + exit 1 + ;; + esac + - name: Bump version + id: bump-version + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + if [[ "${{ github.event.inputs.version }}" != "" ]]; then + new_version="${{ github.event.inputs.version }}" + echo "Using manually specified version: $new_version" + else + git fetch --unshallow + old_version=$(git describe --tags $(git rev-list --tags --max-count=1)) + new_version=$(.github/scripts/bump-version.sh ${{ steps.determine-version-segment.outputs.segment }} $old_version) + echo "Bumping $old_version to $new_version" + fi + echo "version=$new_version" >> $GITHUB_OUTPUT + + tag-version: + name: Tag version + needs: bump-version + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.merged == true || github.event.inputs.version != '' }} + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Create tag + id: create-tag + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + new_version=${{ needs.bump-version.outputs.version }} + echo "Tagging $new_version" + git tag $new_version + git push origin $new_version + echo "version=$new_version" >> $GITHUB_OUTPUT + + build-release: + name: Build release + needs: + - bump-version + - tag-version + runs-on: ubuntu-latest + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/build-release + with: + source: ${{ matrix.source }} + version: ${{ needs.bump-version.outputs.version }} + + create-github-release: + name: Create GitHub release + needs: + - bump-version + - tag-version + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ github.token }} + run: | + if [[ "${{ needs.bump-version.outputs.version }}" == *rc* ]]; then + prerelease_flag="--prerelease" + else + prerelease_flag="" + fi + gh release create ${{ needs.bump-version.outputs.version }} \ + --repo ${{ github.repository }} \ + --generate-notes \ + $prerelease_flag + + publish-github-release: + name: Publish release to GitHub + needs: + - bump-version + - tag-version + - build-release + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/publish-github-release + with: + source: ${{ matrix.source }} + version: ${{ needs.bump-version.outputs.version }} + + publish-pypi-release: + name: Publish release to PyPI + needs: + - bump-version + - tag-version + - build-release + runs-on: ubuntu-latest + permissions: + id-token: write + strategy: + matrix: + source: [wool] + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/publish-pypi-release + with: + source: ${{ matrix.source }} + version: ${{ needs.bump-version.outputs.version }} + pypi-token: ${{ secrets.PYPI_TOKEN }} + + drop-release: + name: Drop release + needs: build-release + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.head_ref == 'release' && github.base_ref == 'master' + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Drop release branch + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch --unshallow + if [[ -n $(git ls-remote --heads origin release) ]]; then + git push origin --delete release + else + echo "Release branch not found, skipping." + fi diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..e214af2 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,66 @@ +name: Run tests + +on: + pull_request: + branches: + - main + - master + - release + +jobs: + lint: + name: Lint / pyright + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv and prepare python + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.13' + + - name: Install packages + run: | + .github/scripts/install-python-packages.sh + uv pip install -e './wool[dev]' + + - name: Run pyright + run: | + cd wool + uv run pyright + + run-tests: + name: Namespace ${{ matrix.namespace }} / Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + namespace: + - 'wool' + python-version: + - '3.11' + - '3.12' + - '3.13' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv and prepare python + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install packages + env: + NAMESPACE: ${{ matrix.namespace }} + run: | + .github/scripts/install-python-packages.sh + uv pip install -e './${{ env.NAMESPACE }}[dev]' + uv pip freeze + + - name: Run tests + env: + NAMESPACE: ${{ matrix.namespace }} + run: | + cd ${{ env.NAMESPACE }} + uv run pytest diff --git a/.github/workflows/sync-branches.yaml b/.github/workflows/sync-branches.yaml new file mode 100644 index 0000000..8de5591 --- /dev/null +++ b/.github/workflows/sync-branches.yaml @@ -0,0 +1,68 @@ +name: Sync branches +description: Sync main, master, and release branches on PR merge. + +on: + pull_request: + branches: + - master + - release + types: + - closed + +jobs: + sync-main: + name: Sync main + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Create pull request + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + workflow=$(echo '${{ github.workflow }}' | tr '[:upper:]' '[:lower:]') + body='Auto-generated by the ['$workflow'](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.' + url=$(gh pr create -B main -H ${{ github.base_ref }} --title 'Sync main with ${{ github.base_ref }}' --body "$body") + gh pr merge $url --merge --admin + gh pr review $url --approve + + sync-release: + name: Sync release + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true && github.head_ref != 'release' && github.base_ref == 'master' + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + - uses: ./.github/actions/get-wool-labs-app-token + id: get-wool-labs-app-token + with: + app-id: ${{ secrets.WOOL_LABS_APP_ID }} + app-installation-id: ${{ secrets.WOOL_LABS_INSTALLATION_ID }} + app-private-key: ${{ secrets.WOOL_LABS_PRIVATE_KEY }} + - name: Create pull request + env: + GH_TOKEN: ${{ steps.get-wool-labs-app-token.outputs.access-token }} + run: | + git fetch --unshallow + if [[ -n $(git ls-remote --heads origin release) ]]; then + workflow=$(echo '${{ github.workflow }}' | tr '[:upper:]' '[:lower:]') + body='Auto-generated by the ['$workflow'](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow.' + url=$(gh pr create -B release -H ${{ github.base_ref }} --title 'Sync release with ${{ github.base_ref }}' --body "$body") + gh pr merge $url --auto --merge + gh pr review $url --approve + else + echo "Release branch not found, skipping." + fi diff --git a/.github/workflows/validate-pr.yaml b/.github/workflows/validate-pr.yaml new file mode 100644 index 0000000..1787fe8 --- /dev/null +++ b/.github/workflows/validate-pr.yaml @@ -0,0 +1,26 @@ +name: Validate pull request + +on: + pull_request: + branches: + - master + - main + - release + types: + - edited + - opened + - reopened + - synchronize + paths: + - wool/src/** + - wool/pyproject.toml + +jobs: + merge-forbidden: + name: Merge forbidden + runs-on: ubuntu-latest + if: ${{ github.base_ref == 'master' && github.head_ref != 'release' }} + steps: + - run: | + echo "ERROR: Merging code changes directly into master forbidden." + exit 1 diff --git a/.github/workflows/validate-repo.yaml b/.github/workflows/validate-repo.yaml new file mode 100644 index 0000000..05857e0 --- /dev/null +++ b/.github/workflows/validate-repo.yaml @@ -0,0 +1,22 @@ +name: Validate repository +description: Confirm that the required secrets are defined in the repository + +on: + workflow_dispatch: + +jobs: + validate-secrets: + name: Validate secrets + runs-on: ubuntu-latest + strategy: + matrix: + secret_name: [PYPI_TOKEN, WOOL_LABS_APP_ID, WOOL_LABS_INSTALLATION_ID, WOOL_LABS_PRIVATE_KEY] + steps: + - name: Check ${{ matrix.secret_name }} secret + env: + SECRET_VALUE: ${{ secrets[matrix.secret_name] }} + if: ${{ env.SECRET_VALUE == '' }} + run: | + echo 'The secret "${{ matrix.secret_name }}" has not been defined' + echo 'Go to "settings \> secrets \> actions" to define it' + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ffc5d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Ignore all files with prefix "."... +.* +# ...except for +!/.github +!/.gitignore +!.gitkeep +!.coveragerc + +*.code-workspace +*.egg-info +*.task.json +__pycache__ +build +dist +generated +uv.lock + +# Generated by EditableInstallHook +wool/src/wool/locking + +# Generated by protoc +*_pb2.py* +*_pb2_grpc.py + +# Claude context +CLAUDE.md +ROADMAP.md +STYLEGUIDE.md + +wool/src/wool/cli.py +specs/ diff --git a/LICENSE b/LICENSE index 261eeb9..de24be4 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 Wool Labs LLC Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md new file mode 120000 index 0000000..6539ecf --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +wool/README.md \ No newline at end of file diff --git a/assets/wool-sillhoutte-logo.svg b/assets/wool-sillhoutte-logo.svg new file mode 100644 index 0000000..479fb19 --- /dev/null +++ b/assets/wool-sillhoutte-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/wool-unicorn-2048.png b/assets/wool-unicorn-2048.png new file mode 100644 index 0000000..4e0bf0f Binary files /dev/null and b/assets/wool-unicorn-2048.png differ diff --git a/assets/woolly-1024.png b/assets/woolly-1024.png new file mode 100644 index 0000000..aeeff84 Binary files /dev/null and b/assets/woolly-1024.png differ diff --git a/assets/woolly-2048.png b/assets/woolly-2048.png new file mode 100644 index 0000000..99b910f Binary files /dev/null and b/assets/woolly-2048.png differ diff --git a/assets/woolly-512.png b/assets/woolly-512.png new file mode 100644 index 0000000..5925565 Binary files /dev/null and b/assets/woolly-512.png differ diff --git a/assets/woolly-clean-logo.svg b/assets/woolly-clean-logo.svg new file mode 100644 index 0000000..2fd8764 --- /dev/null +++ b/assets/woolly-clean-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/woolly-logo-multilayer.svg b/assets/woolly-logo-multilayer.svg new file mode 100644 index 0000000..3553b26 --- /dev/null +++ b/assets/woolly-logo-multilayer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/woolly-logo.png b/assets/woolly-logo.png new file mode 100644 index 0000000..6e41d9d Binary files /dev/null and b/assets/woolly-logo.png differ diff --git a/assets/woolly-logo.svg b/assets/woolly-logo.svg new file mode 100644 index 0000000..253e032 --- /dev/null +++ b/assets/woolly-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/woolly-transparent-2048.png b/assets/woolly-transparent-2048.png new file mode 100644 index 0000000..b40be0c Binary files /dev/null and b/assets/woolly-transparent-2048.png differ diff --git a/assets/woolly-transparent-bg-2048.png b/assets/woolly-transparent-bg-2048.png new file mode 100644 index 0000000..b50f09c Binary files /dev/null and b/assets/woolly-transparent-bg-2048.png differ diff --git a/build-hooks/__init__.py b/build-hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build-hooks/_git.py b/build-hooks/_git.py new file mode 100644 index 0000000..5f46ae8 --- /dev/null +++ b/build-hooks/_git.py @@ -0,0 +1,37 @@ +import git +from _version import PythonicVersion, parser + + +@parser("git") +def parse() -> str: + """ + Parses the current git repository to generate a version string. + + Returns: + A version string based on the latest tag, commit hash, and uncommitted + changes. + + Raises: + RuntimeError: If the repository is empty. + """ + repo = git.Repo(search_parent_directories=True) + if repo.bare: + raise RuntimeError(f"The repo at '{repo.working_dir}' cannot be empty!") + head_commit = repo.head.commit + try: + tag = max(repo.tags, key=lambda t: PythonicVersion.parse(str(t))) + except ValueError: + tag_name = "0" + tag_commit = None + else: + tag_name = tag.name + tag_commit = tag.commit + public, *local = tag_name.split("+") + if head_commit != tag_commit: + commit_label = repo.git.rev_parse(head_commit.hexsha, short=True) + local.append(commit_label) + dirty = repo.index.diff(None) or repo.untracked_files + if dirty: + local.append("dirty") + local = ".".join(local) + return f"{public}+{local}" if local else public diff --git a/build-hooks/_version.py b/build-hooks/_version.py new file mode 100644 index 0000000..a1380c5 --- /dev/null +++ b/build-hooks/_version.py @@ -0,0 +1,732 @@ +from __future__ import annotations + +import enum +import functools +import re +from typing import ( + TYPE_CHECKING, + Callable, + Generic, + MutableSequence, + Optional, + Type, + TypeVar, + overload, +) + +try: + from typing import Self +except ImportError: + from typing_extensions import Self + +__version_parsers__: dict = {} + + +def parser(alias: str): + """ + Registers a version parser function or callable type under a given alias. + + Examples: + >>> @parser("example") + ... def example_parser(): + ... return "1.0.0" + """ + + def decorator(decorated: Callable[[], str]) -> Callable[[], str]: + __version_parsers__[alias] = decorated + return decorated + + return decorator + + +def grammatical_series(*words: str) -> str: + """ + Formats a series of words into a grammatical series. + """ + if len(words) > 2: + separator = ", " + last = f'and "{words[-1]}"' + return separator.join([*(f'"{w}"' for w in words[:-1]), last]) + elif len(words) > 1: + return " and ".join((f'"{w}"' for w in words)) + else: + assert words, "At least one word must be provided" + return f'"{words[0]}"' + + +class NonConformingVersionString(Exception): + """ + Exception raised when a version string does not conform to the expected + pattern. + """ + + def __init__(self, version: Optional[str], pattern: re.Pattern): + super().__init__( + f"Version string must match the specified pattern, cannot parse " + f"{repr(version)} with pattern r{repr(pattern.pattern)}." + ) + + +class NonConformingVersionPattern(Exception): + """ + Exception raised when a version pattern does not define all required + capture groups. + """ + + def __init__(self, missing_capture_groups: set[str]): + super().__init__( + f"Version pattern must define all required capture groups, " + f"missing capture groups " + f"{grammatical_series(*sorted(missing_capture_groups))}." + ) + + +class NumericVersionSegment(int): + """Represents a numeric segment of a version. + + Numeric version segments behave as integers and may be incremented by + addition, e.g. `segment += 1`. + + Args: + value (Optional[int | str]): The numeric value of the segment. + format (str): The format string used to render the segment. + + Raises: + ValueError: If the value is negative. + + Examples: + >>> segment = NumericVersionSegment(1, "{}") + >>> repr(segment) + '' + >>> segment.render() + '1' + """ + + def __new__(cls, value: Optional[int | str], format: str): + if value and int(value) < 0: + raise ValueError(f"{cls.__name__} must be positive") + if value is None: + value = -1 + value = max(int(value), -1) + return super().__new__(cls, value) + + def __init__(self, value: Optional[int | str], format: str): + if value is None: + value = -1 + value = max(int(value), -1) + self.__NumericVersionSegment_value__: int = value + self.__NumericVersionSegment_format__: str = format + + def __repr__(self) -> str: + return ( + f"<{type(self).__qualname__}: {repr(self.__NumericVersionSegment_value__)}>" + ) + + def __lt__(self, value: int | AlphanumericVersionSegment) -> bool: + if isinstance(value, AlphanumericVersionSegment): + return True + return super().__lt__(value) + + def render(self) -> str: + return ( + self.__NumericVersionSegment_format__.format( + str(self.__NumericVersionSegment_value__) + ) + if self.__NumericVersionSegment_value__ > -1 + else "" + ) + + +class AlphanumericVersionSegment(str): + """Represents an alphanumeric segment of a version. + + Alphanumeric segments behave as strings and may contain numbers, letters, + or dashes ("-"). + + Args: + value (Optional[str]): The alphanumeric value of the segment. + format (str): The format string used to render the segment. + + Raises: + ValueError: If the value is an empty string or contains invalid + characters. + + Examples: + >>> segment = AlphanumericVersionSegment("alpha", "-{}") + >>> repr(segment) + '' + >>> segment.render() + '-alpha' + """ + + def __new__(cls, value: Optional[str], format: str, *, whitelist: str = ""): + if value == "": + raise ValueError(f"{cls.__name__} cannot be an empty string") + if value is None: + value = "" + if any(not v.isalnum() and v not in ("-" + whitelist) for v in value): + raise ValueError( + f"{cls.__name__} may only contain alphanumeric characters and hyphens, got {value}" + ) + return super().__new__(cls, value) + + def __init__(self, value: Optional[str], format: str, *, whitelist: str = ""): + if value is None: + value = "" + self.__AlphanumericVersionSegment_value__: str = value + self.__AlphanumericVersionSegment_format__: str = format + self.__AlphanumericVersionSegment_whitelist__: str = whitelist + + def __repr__(self) -> str: + return ( + f"<{type(self).__qualname__}: " + f"{repr(self.__AlphanumericVersionSegment_value__)}>" + ) + + def __lt__(self, value: str | NumericVersionSegment) -> bool: + if isinstance(value, NumericVersionSegment): + return False + return super().__lt__(value) + + def render(self) -> str: + return ( + self.__AlphanumericVersionSegment_format__.format( + str(self.__AlphanumericVersionSegment_value__) + ) + if self.__AlphanumericVersionSegment_value__ + else "" + ) + + +@functools.total_ordering +class PreReleaseVersionSegment: + """Represents a pre-release segment of a version. + + Args: + *values (Optional[str | int]): The values of the pre-release segment. + format (str): The format string used to render the segment. + + Examples: + >>> segment = PreReleaseVersionSegment("alpha", 1, format="-{}") + >>> repr(segment) + '' + >>> segment.render() + '-alpha.1' + """ + + def __init__(self, *values: Optional[str | int], format: str = "-{}"): + self.__PreReleaseVersionSegment_value__: list[ + AlphanumericVersionSegment | NumericVersionSegment + ] = [ + ( + NumericVersionSegment(v, format="{}") + if isinstance(v, int) + else AlphanumericVersionSegment(v, format="{}") + ) + for v in values + ] + self.__PreReleaseVersionSegment_format__: str = format + + def __repr__(self) -> str: + return ( + f"<{type(self).__qualname__}: " + f"{repr('.'.join(v.render() for v in self.__PreReleaseVersionSegment_value__))}>" + ) + + def __eq__(self, other: object): + if not isinstance(other, PreReleaseVersionSegment): + return super().__eq__(other) + else: + return ( + self.__PreReleaseVersionSegment_value__ + == other.__PreReleaseVersionSegment_value__ + ) + + def __lt__(self, other: PreReleaseVersionSegment) -> bool: + if not self.__PreReleaseVersionSegment_value__: + return False + elif not isinstance(other, PreReleaseVersionSegment): + return super().__lt__(other) + elif len(self.__PreReleaseVersionSegment_value__) < len( + other.__PreReleaseVersionSegment_value__ + ): + return True + else: + for this, that in zip( + self.__PreReleaseVersionSegment_value__, + other.__PreReleaseVersionSegment_value__, + ): + if this != that: + if isinstance(this, NumericVersionSegment) and isinstance( + that, AlphanumericVersionSegment + ): + return True + elif isinstance(this, AlphanumericVersionSegment) and isinstance( + that, NumericVersionSegment + ): + return False + else: + return this < that + return False + + def render(self) -> str: + return ( + self.__PreReleaseVersionSegment_format__.format( + ".".join(v.render() for v in self.__PreReleaseVersionSegment_value__) + ) + if self.__PreReleaseVersionSegment_value__ + else "" + ) + + +@functools.total_ordering +class ReleaseCycle(metaclass=enum.EnumMeta): + """Represents a release cycle of a version. + + Attributes: + Alpha (ReleaseCycle): Alpha release cycle. + Beta (ReleaseCycle): Beta release cycle. + ReleaseCandidate (ReleaseCycle): Release candidate cycle. + Production (ReleaseCycle): Production release cycle. + """ + + __ReleaseCycle_mapping__: dict[int | str, ReleaseCycle] = {} + __ReleaseCycle_int__: Optional[int] = None + __ReleaseCycle_str__: Optional[str] = None + + Alpha = 0, "a" + Beta = 1, "b" + ReleaseCandidate = 2, "rc" + Production = 3, "." + + def __new__(cls, ordinal: int, identifier: str): + self = object.__new__(cls) + self.__ReleaseCycle_int__ = ordinal + self.__ReleaseCycle_str__ = identifier + cls.__ReleaseCycle_mapping__.update({ordinal: self, identifier: self}) + return self + + def __lt__(self, other: ReleaseCycle) -> bool: + return int(self).__lt__(int(ReleaseCycle(other))) + + def __int__(self) -> int: + return self.__ReleaseCycle_int__ or 0 + + @classmethod + def _missing_(cls, key: int | str) -> Optional[ReleaseCycle]: + if key == -1: + key = 3 + return cls.__ReleaseCycle_mapping__.get(key) + + def render(self) -> str: + return self.__ReleaseCycle_str__ or str(self) + + +VersionSegment = Optional[ + NumericVersionSegment | AlphanumericVersionSegment | ReleaseCycle +] + +segment = type("segment", (property,), {}) + + +class VersionMeta(type): + @property + def parse(cls: Self) -> VersionParser[Self]: + return VersionParser(cls) + + @property + def segments(cls: Self) -> dict[str, segment]: + return {k: v for k, v in cls.__dict__.items() if isinstance(v, segment)} + + +class Version(metaclass=VersionMeta): + """ + Base class for version objects. + + This class provides common functionality for version objects, including + parsing and rendering. + """ + + PATTERN: re.Pattern + + segments: dict[str, segment] + + def __init__(self): + self._dict: dict[str, VersionSegment] = { + k: getattr(self, k) + for k, v in type(self).__dict__.items() + if isinstance(v, segment) + } + + def __iter__(self): + return iter(self._dict) + + def __repr__(self) -> str: + return f"<{type(self).__qualname__}: {repr(str(self))}>" + + def __str__(self) -> str: + return "".join(v.render() for v in self.values() if v is not None) + + @overload + def __getitem__(self, item: slice) -> dict[str, VersionSegment]: ... + + @overload + def __getitem__(self, item: str) -> VersionSegment: ... + + def __getitem__(self, item): + if isinstance(item, slice): + segments = list(type(self).segments.values()) + if item.start: + start = segments.index(item.start) + else: + start = None + if item.stop: + stop = segments.index(item.stop) + if stop > len(segments): + stop = None + else: + stop = None + return {k: v for k, v in list(self.items())[slice(start, stop)]} + else: + return self._dict[item] + + def keys(self): + return self._dict.keys() + + def items(self): + return self._dict.items() + + def values(self): + return self._dict.values() + + +T = TypeVar("T", bound=Version) + + +class VersionParser(Generic[T]): + """ + Parses version strings into version objects. + + This class is used to parse version strings into instances of the specified + version class. It supports custom version patterns and provides a callable + interface for parsing. + + A custom parsing pattern may optionally be specified. This pattern must + specify all named capture groups required for the version type as defined + by its "segment" properties. + + Registered parsers may be accessed as attributes, e.g. `parser.git()`. + """ + + def __init__(self, version_class: Type[T]): + self.version_class = version_class + + def __call__(self, version: str, *, pattern: re.Pattern | None = None) -> T: + if pattern is not None: + missing_capture_groups = set( + self.version_class.PATTERN.groupindex.keys() + ) - set(pattern.groupindex.keys()) + if missing_capture_groups: + raise NonConformingVersionPattern(missing_capture_groups) + else: + pattern = self.version_class.PATTERN + match = pattern.match(version) + if not match: + raise NonConformingVersionString(version, pattern) + segments: dict[str, str] = { + k: v for k, v in match.groupdict().items() if v is not None + } + return self.version_class(**segments) + + def __getattr__(self, attribute): + return lambda: self(__version_parsers__[attribute]()) + + +@functools.total_ordering +class PythonicVersion(Version): + """ + Class representing a Pythonic version as described by PEP 440. + + PEP 440 is a standard for versioning Python projects. This class provides + methods to parse, compare, and render versions according to PEP 440. + + Examples: + >>> version = PythonicVersion(major_release=1, minor_release=0, patch_release=0) + >>> repr(version) + "" + >>> str(version) + '1.0.0' + >>> version.minor_release += 1 + >>> str(version) + '1.1.0' + """ + + PATTERN: re.Pattern = re.compile( + r"v?" + r"((?P\d+)(?:!))?" + r"(?P\d+)?" + r"(?:(?:\.)(?P\d+))?" + r"((?P\.|a|b|rc)" + r"(?P\d+))?" + r"((?:\.post)(?P\d+))?" + r"((?:\.dev)(?P\d+))?" + r"((?:\+)(?P[a-zA-Z0-9.]+))?" + r"$" + ) + + if TYPE_CHECKING: + parse: VersionParser[PythonicVersion] + + def __init__( + self, + epoch: Optional[int | str] = None, + major_release: int | str = 0, + minor_release: Optional[int | str] = None, + release_cycle: Optional[int | str | ReleaseCycle] = None, + patch_release: Optional[int | str] = None, + post_release: Optional[int | str] = None, + dev_release: Optional[int | str] = None, + local_identifier: Optional[str] = None, + ): + assert major_release is not None, "Major release must be defined" + if patch_release is not None: + assert minor_release is not None, ( + "Minor release must be defined if patch release is defined" + ) + assert (release_cycle is None) == (patch_release is None), ( + "Patch release and release cycle must be defined together" + ) + self._epoch = epoch + self._major_release = major_release + self._minor_release = minor_release + self._release_cycle = release_cycle + self._patch_release = patch_release + self._post_release = post_release + self._dev_release = dev_release + self._local_identifier = local_identifier or None + super().__init__() + + def __eq__(self, other: object): + if not isinstance(other, PythonicVersion): + return super().__eq__(other) + else: + return all( + ( + self.epoch == other.epoch, + self.major_release == other.major_release, + self.minor_release == other.minor_release, + self.release_cycle == other.release_cycle, + self.patch_release == other.patch_release, + self.post_release == other.post_release, + self.dev_release == other.dev_release, + self.local_identifier == other.local_identifier, + ) + ) + + def __lt__(self, other: PythonicVersion) -> bool: + if isinstance(other, PythonicVersion): + for segment in PythonicVersion.segments: + this, that = getattr(self, segment), getattr(other, segment) + if this is None: + this = -1 + if that is None: + that = -1 + if this != that: + return this < that + return False + else: + return super().__lt__(other) + + @segment + def epoch(self) -> Optional[NumericVersionSegment]: + return ( + self._epoch + if self._epoch is None + else NumericVersionSegment(self._epoch, format="{}!") + ) + + @segment + def major_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._major_release, format="{}") + + @segment + def minor_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._minor_release, format=".{}") + + @segment + def release_cycle(self) -> Optional[ReleaseCycle]: + if self._release_cycle is None: + return self._release_cycle + else: + return ReleaseCycle(self._release_cycle) + + @segment + def patch_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._patch_release, format="{}") + + @segment + def post_release(self) -> Optional[NumericVersionSegment]: + return ( + self._post_release + if self._post_release is None + else NumericVersionSegment(self._post_release, format=".post{}") + ) + + @segment + def dev_release(self) -> Optional[NumericVersionSegment]: + return ( + self._dev_release + if self._dev_release is None + else NumericVersionSegment(self._dev_release, format=".dev{}") + ) + + @segment + def local_identifier(self) -> Optional[AlphanumericVersionSegment]: + return ( + self._local_identifier + if self._local_identifier is None + else AlphanumericVersionSegment( + self._local_identifier, format="+{}", whitelist="." + ) + ) + + @property + def local(self) -> str: + return "".join( + v.render() + for v in self[type(self).local_identifier :].values() + if v is not None + ) + + @property + def public(self) -> str: + return "".join( + v.render() + for v in self[: type(self).local_identifier].values() + if v is not None + ) + + +@functools.total_ordering +class SemanticVersion(Version): + """ + Class representing a semantic version as described by SemVer 2.0. + + This class provides methods to parse, compare, and render versions + according to the SemVer 2.0 specification. + + Examples: + >>> version = SemanticVersion(major_release=1, minor_release=0, patch_release=0) + >>> str(version) + '1.0.0' + >>> version.minor_release += 1 + >>> str(version) + '1.1.0' + """ + + PATTERN: re.Pattern = re.compile( + r"^v?" + r"(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?" + r"$" + ) + + def __init__( + self, + major_release: int | str = 0, + minor_release: Optional[int | str] = None, + patch_release: Optional[int | str] = None, + pre_release: Optional[str | MutableSequence[int | str]] = None, + build: Optional[int | str] = None, + ): + assert major_release is not None, "Major release must be defined" + if patch_release is not None: + assert minor_release is not None, ( + "Minor release must be defined if patch release is defined" + ) + self._major_release = major_release + self._minor_release = minor_release + self._patch_release = patch_release + self._pre_release = ( + pre_release.split(".") + if isinstance(pre_release, str) + else [pre_release] + if isinstance(pre_release, int) + else pre_release + ) + self._build = build + super().__init__() + + def __eq__(self, other): + if not isinstance(other, SemanticVersion): + return super().__eq__(other) + else: + return all( + ( + self.major_release == other.major_release, + self.minor_release == other.minor_release, + self.patch_release == other.patch_release, + self.pre_release == other.pre_release, + self.build == other.build, + ) + ) + + def __lt__(self, other: SemanticVersion) -> bool: + if isinstance(other, SemanticVersion): + for segment in SemanticVersion.segments: + this, that = getattr(self, segment), getattr(other, segment) + if segment == "pre_release": + if this is None: + this = PreReleaseVersionSegment(format="{}") + if that is None: + that = PreReleaseVersionSegment(format="{}") + if this != that: + return this < that + else: + if this is None: + this = -1 + if that is None: + that = -1 + if this != that: + return this < that + return False + else: + return super().__lt__(other) + + @segment + def major_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._major_release, format="{}") + + @segment + def minor_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._minor_release, format=".{}") + + @segment + def patch_release(self) -> Optional[NumericVersionSegment]: + return NumericVersionSegment(self._patch_release, format=".{}") + + @segment + def pre_release( + self, + ) -> Optional[PreReleaseVersionSegment]: + if self._pre_release: + return PreReleaseVersionSegment(*self._pre_release, format="-{}") + + @segment + def build(self) -> Optional[AlphanumericVersionSegment]: + return ( + self._build + if self._build is None + else AlphanumericVersionSegment(str(self._build), format="+{}") + ) + + @property + def local(self) -> str: + return "" + + @property + def public(self) -> str: + return "".join(v.render() for v in self.values() if v is not None) diff --git a/build-hooks/build.py b/build-hooks/build.py new file mode 100644 index 0000000..bcee45e --- /dev/null +++ b/build-hooks/build.py @@ -0,0 +1,110 @@ +import os +import pathlib +import re + +import toml +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class InstallError(Exception): + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + self.code = 1 + self.name = "InstallError" + self.description = "An error occurred during the installation process." + + +class EditableInstallHook(BuildHookInterface): + """ + Wool's project layout is a monorepo with several namespace packages. + This hook enables editable installs of the project by creating symlinks + for each `wool` subpackage in the repo. These symlinks are added to + `.gitignore` as well. + """ + + def initialize(self, version: str, build_data: dict) -> None: + for root, _, filenames in os.walk( + pathlib.Path(self.root) / "src" / "wool" / "runtime" / "protobuf" + ): + for filename in filenames: + if filename.endswith("_pb2_grpc.py"): + filepath = os.path.join(root, filename) + with open(filepath, "r") as file: + content = file.read() + + updated_content = re.sub( + r"^import (\w+_pb2) as (\w+__pb2)", + r"from . import \1 as \2", + content, + flags=re.MULTILINE, + ) + + with open(filepath, "w") as file: + file.write(updated_content) + + wool, repo = ( + (root := pathlib.Path(self.root)) / "src" / "wool", + root.parent, + ) + if version != "editable": + return + if not root.name == "wool": + raise InstallError( + "Editable installs are only supported for the `wool` package. " + f"Try running `pip install -e {repo / 'wool'}` instead." + ) + if not (gitignore := repo / ".gitignore").exists(): + gitignore.touch() + with gitignore.open("r+") as gitignore: + ignored = set(line.strip() for line in gitignore if line.strip()) + targets = None + for project in repo.iterdir(): + if ( + project.is_dir() + and (project / "pyproject.toml").exists() + and project != root + and project.name.startswith("wool") + and (src := project / "src" / "wool").exists() + and src.is_dir() + ): + with (project / "pyproject.toml").open("r") as toml_file: + project_config = toml.load(toml_file) + for plugins in ["wool_cli_plugins"]: + if entry_points := ( + project_config.get("project", {}) + .get("entry-points", {}) + .get(plugins, {}) + ): + # Copy entry-points + self.metadata.core.entry_points.setdefault( + plugins, {} + ).update(entry_points) + + targets = [] + for subpackage in src.iterdir(): + if ( + subpackage.name.endswith(".py") + or subpackage.is_dir() + and (subpackage / "__init__.py").exists() + ): + # Create symlink for each subpackage + target = wool / subpackage.name + if not target.exists(): + target.symlink_to( + subpackage, + target_is_directory=subpackage.is_dir(), + ) + if str(target.relative_to(repo)) not in ignored: + targets.append(target) + if targets: + gitignore.write( + "\n".join( + [ + "", + f"# Generated by {self.__class__.__name__}", + *(f"{target.relative_to(repo)}" for target in targets), + "", + ] + ) + ) diff --git a/build-hooks/metadata.py b/build-hooks/metadata.py new file mode 100644 index 0000000..13eed9f --- /dev/null +++ b/build-hooks/metadata.py @@ -0,0 +1,45 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +from copy import copy + +import _git as _git +import _version +from hatchling.metadata.plugin.interface import MetadataHookInterface +from packaging.requirements import Requirement + + +def _get_subpackages(directory): + subpackages = [] + for entry in os.scandir(directory): + if entry.is_dir() and entry.name.startswith("wool"): + subpackages.append(entry.name) + return subpackages + + +class WoolMetadataHook(MetadataHookInterface): + PLUGIN_NAME = "wool-metadata" + + def update(self, metadata): + subpackages = _get_subpackages(f"{os.path.dirname(__file__)}/..") + version = _version.PythonicVersion.parse.git() + metadata["version"] = str(version) + for dependencies in ( + metadata["dependencies"], + *metadata["optional-dependencies"].values(), + ): + for dependency in copy(dependencies): + requirement = Requirement(dependency) + if ( + requirement.name.startswith("wool") + and requirement.name in subpackages + and not requirement.specifier + ): + if version.local: + requirement = f"{requirement.name} @ {{root:parent:uri}}/{requirement.name}" + else: + requirement.specifier &= f"=={version}" + dependencies.remove(dependency) + dependencies.append(str(requirement)) diff --git a/gemini-feedback.md b/gemini-feedback.md new file mode 100644 index 0000000..b89d68b --- /dev/null +++ b/gemini-feedback.md @@ -0,0 +1,126 @@ +Alright team, I've completed my review of the `wool` modules. Overall, this is a very impressive piece of engineering. The architecture is well-thought-out, the code is clean, and the use of modern Python features like `asyncio`, type hints, and `dataclasses` is excellent. It's clear that a lot of care went into the design. + +My review is intended to be constructive, focusing on ensuring the long-term stability and maintainability of the framework. I'll start with the high-level view and then drill down into specifics. + +----- + +### High-Level Summary + +The project implements a sophisticated distributed task execution framework. The core architecture cleanly separates four key responsibilities: + +1. **Service Discovery** (`discovery.py`): Abstracting how workers find each other, with a solid Zeroconf/mDNS implementation for LAN environments. +2. **Task Definition** (`task.py`): An elegant `@work` decorator provides a user-friendly API for defining distributed functions. +3. **Worker Execution** (`worker.py`): A robust model for running tasks in separate processes, complete with gRPC communication and lifecycle management. +4. **Client Orchestration** (`workerpool.py`): Manages the pool of workers, connections, and load balancing from the client's perspective. + +The choice of `asyncio` and `gRPC` provides a powerful, high-performance foundation. The system is designed to be extensible through the use of `Protocol`s and abstract base classes. + +----- + +### Architectural Strengths + + * **Excellent Separation of Concerns**: The modules have clear boundaries and responsibilities. The `LoadBalancer` abstraction, for example, is a perfect illustration of this, cleanly decoupling the worker selection strategy from the connection management. + * **Modern and Idiomatic Python**: The code makes great use of type hints, dataclasses, and protocols. This significantly improves readability and maintainability. The use of `@final` to create template methods in base classes is a nice touch. + * **Robust Lifecycle Management**: The `start()`/`stop()` semantics on services, workers, and pools are clear and well-defined. The inclusion of signal handlers (`worker.py`) for graceful shutdown shows a mature approach to process management. + +----- + +### 🚩 Critical Concern: Monkey-Patching `asyncio` + +The most significant issue that needs to be addressed is in `task.py`. + +```python +# task.py +def _run(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + if current_routine := self._context.get(_current_task): + WoolTaskEvent("task-started", task=current_routine).emit() + try: + result = fn(self, *args, **kwargs) + finally: + WoolTaskEvent("task-stopped", task=current_routine).emit() + return result + else: + return fn(self, *args, **kwargs) + + return wrapper + + +asyncio.Handle._run = _run(asyncio.Handle._run) +``` + +**Modifying the standard library at runtime is a dangerous practice.** This technique is extremely fragile and can lead to: + + * **Version Incompatibility**: It can break unexpectedly with any minor Python update. + * **Library Conflicts**: If another library (e.g., a performance profiler) tries to patch the same object, it will cause unpredictable behavior. + * **Hard-to-Debug Errors**: When things go wrong, it's incredibly difficult to trace the root cause because the standard library isn't behaving as expected. + +**Recommendation**: This must be refactored. The goal is to wrap task execution to emit events. This can be achieved safely within the worker's gRPC servicer. The servicer is the central point where all incoming tasks are handled. You can wrap the task execution there. + +**Example (Conceptual)**: + +```python +# In your gRPC WorkerService class in worker.py + +async def dispatch(self, request, context): + # ... unpickle the task ... + task = unpickle_task(request) + + # Set context and emit events here, safely! + _current_task.set(task) + WoolTaskEvent("task-started", task=task).emit() + try: + # Await the actual task coroutine + result = await task.callable(*task.args, **task.kwargs) + WoolTaskEvent("task-completed", task=task).emit() + return pickle_result(result) + except Exception as e: + # Handle exceptions + finally: + WoolTaskEvent("task-stopped", task=task).emit() + _current_task.set(None) +``` + +This approach achieves the same goal without the risks of monkey-patching. + +----- + +### Module-Specific Feedback + +#### `discovery.py` + + * **`PredicatedQueue`**: This is a very clever but complex piece of code. Re-implementing core concurrency primitives is difficult to get right. While the current implementation looks thoughtful, its complexity is a maintenance burden. + * **Suggestion**: Consider if this can be simplified. For example, could a `RoundRobinLoadBalancer` just use `asyncio.Queue` and re-queue a worker if it's fetched but can't be used? For more complex predicate-based routing, perhaps a manager task that listens to the discovery service and populates multiple, simpler queues based on worker tags would be more explicit and easier to reason about. + * **Action**: At a minimum, this class needs extensive comments explaining the intricate logic in `_wakeup_next_getter`, especially how it prevents race conditions. + +#### `worker.py` + + * **IP Address Detection**: The `_get_ip_address` function connects to Google's DNS to find the host IP. This is a common pattern, but it's brittle. It will fail in environments without internet access and doesn't account for multi-homed machines (multiple network cards). + * **Suggestion**: The bind address for the gRPC server should be an explicit configuration parameter (e.g., `0.0.0.0` to bind on all interfaces). The address that gets *published* to the discovery service should also be configurable, falling back to auto-detection as a convenience. + * **Default `LanRegistryService`**: The `Worker` `__init__` defaults to `LanRegistryService()`. This tightly couples the generic `Worker` abstraction to the specific `Lan` implementation. + * **Suggestion**: Use dependency injection. The factory or code that creates the worker should be responsible for creating and passing in the appropriate registry service. + +#### `workerpool.py` + + * **`ChannelToken.__del__`**: Attempting to schedule an async `close()` call from a destructor is unreliable. The docstring correctly notes this. While it's a nice-to-have for cleanup, we should rely on the explicit `WorkerPool` context manager to manage the `ChannelPool`'s lifecycle. + * **Action**: Ensure that the `WorkerPool.__aexit__` explicitly calls `_channel_pool.close()` to guarantee cleanup. + +#### `task.py` + + * **`@work` decorator**: The use of `__wool_remote__` is a functional way to control execution flow. However, it's implicit and a bit "magical." + * **Suggestion (Low Priority)**: For clarity, you could consider a design where the decorator replaces the function with a "proxy" object that has a `dispatch()` or `execute_remote()` method. This makes the remote call more explicit, though it does change the API ergonomics. The current approach is acceptable but worth noting. + +----- + +### General Recommendations + + * **Configuration**: Key values are hardcoded (e.g., `_EXTERNAL_DNS_SERVER`). A centralized configuration object or system would make the framework far more flexible for different deployment environments. + * **Logging**: There is very little logging. For a distributed system, comprehensive, structured logging is not a feature—it's a requirement for debuggability. I'd recommend adding logs for key lifecycle events: worker start/stop, task received, task success/failure, worker discovery events, etc. + * **Testing**: I assume a test suite exists, but I want to emphasize its importance. The `PredicatedQueue` and the entire task dispatch/execution flow require extensive testing, including failure scenarios (e.g., worker crashing mid-task). + +### Conclusion + +This is a high-quality codebase with a strong architectural foundation. My recommendations are focused on removing the few "brittle" spots (especially the monkey-patching) to ensure the framework is as robust and maintainable as it is clever. + +Excellent work. Let's discuss a path forward for refactoring the event emission logic. diff --git a/llm/guides/testguide.md b/llm/guides/testguide.md new file mode 100644 index 0000000..a3a0f1f --- /dev/null +++ b/llm/guides/testguide.md @@ -0,0 +1,241 @@ +# Test Rules + +Key words MUST, MUST NOT, SHOULD, and SHOULD NOT are interpreted as +described in RFC 2119. Examples use Python, but the rules apply +language-agnostically. + +## 1. Core Philosophy + +Test behavior, not implementation. + +## 2. File and Class Organization + +- Test files: `test_.py` — mirrors the module under test. +- Test classes mirror production classes: `class Test:` where + `` is the exact `__name__` of the class under test. +- Tests for class methods live in the corresponding test class. +- Tests for module-level functions are module-level test functions. +- Tests MUST be grouped by the behavior they exercise, not by + implementation detail. For example, property-based tests for a method + live alongside example-based tests for the same method in the same + class — do NOT create separate classes by test technique. +- Within a test class or module, order tests from foundational to + derived: test `__init__` before methods that require a constructed + instance, and test simple methods before methods that compose them. + This ensures `pytest -x` stops at the most fundamental failure first. + +## 3. Test Naming + +Test methods mirror the qualified name of their subject: + +``` +test__ +``` + +The scenario portion is derived from the Given and When — it describes +the condition and action, not the expected outcome: + +``` +test_dispatch_with_stopping_service +test_to_protobuf_with_unpicklable_callable +test___init___outside_task_context +``` + +Rules: +- `` MUST match the method's `__name__` exactly, including + dunder prefixes (e.g., `test___init___...`). +- `` SHOULD be 2-5 words in snake_case. +- Do NOT encode the expected outcome in the name — that belongs in the + `Then` section of the docstring. + +## 4. Docstrings (Given-When-Then) + +Scope: test functions and methods only — NOT fixtures or helpers. + +**Standard test:** + +```python +"""Test . + +Given: + +When: + +Then: + It should +""" +``` + +**Property-based test:** + +Same format. The `Given` describes the generated input space, not a +specific value: + +```python +"""Test . + +Given: + +When: + +Then: + +""" +``` + +Rules: +- First line MUST start with `Test` and describe what is being tested. +- Blank line after the first line. +- `Given:`, `When:`, `Then:` each on their own line, followed by a colon. +- Content under each section indented 4 spaces. +- Each section's content starts with a capital letter. +- `Then` content typically starts with "It should...". +- One `Given`/`When`/`Then` block per docstring (no bullet lists within). + +## 5. Test Body (AAA) + +Every test MUST use Arrange-Act-Assert phase comments. One behavior per +test. + +**Full 3-phase (default):** + +```python +def test__(self, ...): + """...""" + # Arrange + + + # Act + + + # Assert + +``` + +**Combined phases:** + +When phases are inseparable, combine the comments: + +*No arrangement needed:* + +```python +# Act +result = SomeClass() + +# Assert +assert result.field == expected +``` + +*Action and assertion are the same expression:* + +```python +# Arrange + + +# Act & Assert +with pytest.raises(SomeError, match="expected message"): + unit.method() +``` + +*Trivial one-liner:* + +```python +# Arrange & Act & Assert +async with SomePool(size=3) as pool: + assert pool is not None +``` + +The combined forms are acceptable but the full 3-phase form is preferred. +When in doubt, use the full form. + +## 6. Mocking + +- MUST use the `mocker` fixture (pytest-mock), not `unittest.mock` + directly. +- Mock at system boundaries only: external services, I/O, network. +- MUST NOT mock internal/private methods. +- MUST NOT mock the unit under test. + +## 7. Async Testing + +- Use `pytest-asyncio` with `@pytest.mark.asyncio`. +- Async mocks via `mocker.AsyncMock()`. + +```python +@pytest.mark.asyncio +async def test_fetch_data_with_valid_endpoint(self, mocker): + """...""" + # Arrange + mock_response = mocker.AsyncMock(return_value={"key": "value"}) + mocker.patch("module.http_client.get", mock_response) + + # Act + result = await unit.fetch_data("/endpoint") + + # Assert + assert result == {"key": "value"} +``` + +## 8. Fixtures + +- Use fixtures for shared setup that multiple tests need. +- Fixtures MUST NOT have Given-When-Then docstrings. Plain reST + docstrings are fine. +- Prefer narrowest scope: function > class > module > session. + +## 9. Property-Based Testing (Hypothesis) + +Property-based testing is a peer of example-based testing, not an +afterthought. Property tests live in the same class as example tests, +grouped by the method they exercise. + +MUST be used for: +- Invariants. +- Roundtrip/serialization. +- Edge-case discovery. +- Any function with a wide input domain. + +Rules: +- Use `@given` with appropriate strategies. +- Use `@settings` to control `max_examples` and suppress health checks + when justified. +- Property tests get Given-When-Then docstrings (the `Given` describes + the generated domain, not a specific value). + +```python +@given(st.binary()) +def test_roundtrip_with_arbitrary_payload(self, payload): + """Test serialization roundtrip with arbitrary payloads. + + Given: + Any binary payload. + When: + The payload is serialized and deserialized. + Then: + It should equal the original payload. + """ + # Act + result = deserialize(serialize(payload)) + + # Assert + assert result == payload +``` + +## 10. Test Independence + +- Tests MUST be independent. Each test sets up its own preconditions. +- When a foundation behavior breaks, many tests fail. The blast radius + IS the information — it tells you which behaviors depend on the broken + one. +- Use `pytest -x` (stop on first failure), `--maxfail=N`, or `-lf` + (last failed) to manage noise. +- Dependency markers (`@pytest.mark.dependency`) MUST NOT be used in + new tests. + +## 11. Verification Commands + +``` +uv run pytest -x +uv run pytest --maxfail=3 +uv run pytest -lf +``` diff --git a/llm/skills/commit.md b/llm/skills/commit.md new file mode 100644 index 0000000..506ba78 --- /dev/null +++ b/llm/skills/commit.md @@ -0,0 +1,283 @@ +--- +name: commit +description: > + Stages and commits uncommitted changes in a git repository using a disciplined, + atomic commit workflow. Use this skill whenever the user asks to "commit my changes", + "clean up my git state", "stage and commit", "make commits from my changes", or + anything similar. Also trigger when the user has described a body of work and says + something like "can you commit this properly" or "let's checkpoint this". The skill + creates a staging branch, analyzes the full diff, groups changes by logical kind, + and commits them sequentially with well-formed messages — without ever mixing + unrelated changes into a single commit. +--- + +The key words MUST, MUST NOT, SHALL, SHALL NOT, SHOULD, SHOULD NOT, +REQUIRED, RECOMMENDED, MAY, and OPTIONAL in this document are to be +interpreted as described in RFC 2119. + +# Commit Skill + +This skill transforms a messy working tree into a clean sequence of atomic, +well-described commits on a staging branch. It MUST NOT touch the original +branch until the user is satisfied. + +## Pipeline Context + +This skill is part of the development workflow pipeline: +`/issue` → `/pr` → `/implement` → `/commit` → `/pr` (update). +This skill is the **fourth** stage. + +## Workflow + +### 1. Verify git state + +```bash +git status +git diff HEAD +``` + +If the working tree is clean (nothing to commit), tell the user and stop. + +Untracked files SHOULD be checked for new source files, config files, etc. +The user MUST be asked before including untracked files unless their +inclusion is obvious. Build artifacts, cache directories, and anything in +.gitignore MUST be ignored. + +### 2. Create a staging branch + +Get the current branch name: + +```bash +git branch --show-current +``` + +Create and switch to a staging branch: + +```bash +git checkout -b {current-branch-name}-staging +``` + +If a staging branch already exists from a previous run, the user MUST be +asked whether to delete and recreate it or continue from where it left off. + +### 3. Analyze the full diff + +```bash +git diff HEAD +git diff HEAD --name-only +``` + +The diff MUST be read carefully. For each changed file, note: +- What kind of change is it (new functionality, bug fix, docs, style/formatting, + refactoring, performance, tests, build/tooling, CI, reversion)? +- Which other changed files is it logically related to? +- Does it mix concerns that should be separated? + +A commit plan MUST be built before touching anything. The goal is a sequence +of commits where each one represents exactly one logical change. + +### 4. Plan the commits + +Group files and hunks into commits. The categories to use as a guide: + +- **New functionality** — new features or capabilities, standalone +- **Bug fixes** — correcting incorrect behavior, isolate so they're cherry-pickable +- **Documentation** — docs, docstrings, comments; no behavior change +- **Style and formatting** — whitespace, formatting; MUST NOT be mixed with logic changes +- **Refactoring** — restructuring without behavior change +- **Performance** — speed/resource improvements, no other behavioral change +- **Tests** — adding or correcting tests +- **Build and tooling** — pyproject.toml, Makefile, dependencies, packaging +- **CI** — workflow files, pipeline config +- **Reversions** — reverting prior work, with clear reference to what and why + +Mixed changes in a single file are common and MUST be handled with partial +staging (see below). Splitting a file's changes across commits is exactly +the right thing to do when the changes are of different kinds. + +Logical ordering MUST be considered: if commit B depends on commit A, A +comes first. Generally: refactoring before feature work, build changes +before code that uses them, tests after (or alongside) the code they test. + +### 5. Stage and commit sequentially + +For each planned commit, stage exactly the right changes and commit. + +**Staging whole files:** +```bash +git add path/to/file.py +``` + +**Staging specific hunks from a file** (when a file mixes concerns): + +Use `git add -p path/to/file.py` in interactive mode to select only the relevant +hunks. Since this skill runs non-interactively, use the `-e` (edit) hunk approach +or use patch files instead: + +```bash +# Generate a patch for just the relevant hunks, then apply selectively +git diff HEAD -- path/to/file.py > /tmp/full.patch +# Edit /tmp/full.patch to keep only desired hunks, then: +git apply --cached /tmp/full.patch +``` + +Alternatively, for clean hunk boundaries, use: +```bash +git diff HEAD -U0 -- path/to/file.py +``` +...to see exact line numbers, then stage by line range using a patch file. + +After staging, the staged diff MUST be verified before committing: +```bash +git diff --cached +``` + +Then commit. The message MUST be written to a temp file to avoid shell +escaping issues: + +```bash +cat > /tmp/commit_msg.txt << 'EOF' +Subject line here + +Body here if needed. + +Footer here if needed. +EOF +git commit -F /tmp/commit_msg.txt +``` + +Repeat for each planned commit. + +### 6. Review and present + +After all commits: + +```bash +git log {original-branch}..HEAD --oneline +``` + +The resulting commit list MUST be shown to the user for approval. If they +want changes — different grouping, reworded messages, splitting or +squashing — use `git rebase -i HEAD~N` on the staging branch to make +adjustments. + +When the user is satisfied, remind them they can merge back: + +```bash +git checkout {original-branch} +git merge --ff-only {original-branch}-staging +git branch -d {original-branch}-staging +``` + +Or if they prefer to review as a PR first, they can push the staging branch +and open a pull request against the original branch. + +If the current branch is associated with a PR, the user SHOULD be +prompted with the next pipeline step: "Ready to update the PR? Run +`/pr ` to sync the PR description with the committed changes." + +--- + +## Commit Message Style Guide + +Every commit message MUST follow these rules. + +### Structure + +``` + + + + +