diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..bfe7fae --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +source = src/wool +omit = + src/wool/__init__.py + src/wool/protocol/*pb2* + +[report] +show_missing = true +precision = 2 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..654e0ff --- /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 . --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..adb87c5 --- /dev/null +++ b/.github/scripts/install-python-packages.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Install the root package +uv pip install --no-deps . 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..7d7538b --- /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: 'src/ 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-protocol] + 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-protocol] + 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-protocol] + 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..22466a4 --- /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: + - src/** + - 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..f0839e1 --- /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: + - src/** + - 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-protocol] + 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-protocol] + 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-protocol] + 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..3bd4976 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,55 @@ +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: uv pip install -e '.[dev]' + + - name: Run pyright + run: uv run pyright + + run-tests: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - '3.11' + - '3.12' + - '3.13' + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + - name: Install uv and prepare python + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install packages + run: | + uv pip install -e '.[dev]' + uv pip freeze + + - name: Run tests + run: 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..7ebceec --- /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: + - src/** + - 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..924731e --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# 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 protoc +*_pb2.py* +*_pb2_grpc.py + +# Claude context +CLAUDE.md +ROADMAP.md +STYLEGUIDE.md + +specs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..de24be4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + 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. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccf155d --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# wool-protocol + +Wool's wire protocol definitions — protobuf schemas, generated Python bindings, gRPC service wrappers, and the service registry. + +Importable as `wool.protocol`. + +## Installation + +### From PyPI + +```sh +pip install wool-protocol +``` + +### From source + +```sh +git clone https://github.com/wool-labs/wool-protocol.git +cd wool-protocol +pip install . +``` + +## Running tests + +```sh +git clone https://github.com/wool-labs/wool-protocol.git +cd wool-protocol +pip install '.[dev]' +pytest +``` + +## Wire protocol + +Wool uses a binary wire protocol built on Protocol Buffers and gRPC for all communication between clients and workers. + +### Dispatch sequence + +The `Worker.dispatch` RPC uses a server-streaming pattern. The client sends a single `Task` message and receives a stream of `Response` messages: + +```mermaid +sequenceDiagram + Client->>Worker: Task + alt accepted + Worker-->>Client: Response(Ack) + alt coroutine + Worker-->>Client: Response(Result) + else async generator + loop each iteration + Worker-->>Client: Response(Result) + end + else failure + Worker-->>Client: Response(Exception) + end + else rejected + Worker-->>Client: Response(Nack) + end +``` + +#### Task fields + +| # | Field | Type | Description | +|---|-------|------|-------------| +| 1 | `version` | `string` | Wire protocol version (envelope) | +| 2 | `id` | `string` | Unique task identifier (envelope) | +| 3 | `caller` | `string` | Identifier of the calling client (envelope) | +| 4 | `tag` | `string` | Routing tag for worker selection (envelope) | +| 5 | `proxy_id` | `string` | Unique identifier of the originating proxy | +| 6 | `proxy` | `bytes` | cloudpickle-serialized proxy reference for callbacks | +| 7 | `callable` | `bytes` | cloudpickle-serialized coroutine or async generator | +| 8 | `args` | `bytes` | cloudpickle-serialized positional arguments | +| 9 | `kwargs` | `bytes` | cloudpickle-serialized keyword arguments | +| 10 | `timeout` | `int32` | Execution timeout in seconds | + +#### Response types + +1. **Ack** — The worker accepted the task and started processing. Carries the worker's `version` string for observability. +2. **Nack** — The worker rejected the task. The `reason` field describes why (e.g., major version mismatch, unparseable version). No further responses follow a Nack. +3. **Result** — A cloudpickle-serialized return value. Coroutine tasks yield exactly one result; async generator tasks yield one per iteration. +4. **Exception** — A cloudpickle-serialized exception from the remote execution. Terminates the stream. + +### Serialization + +Wool uses a hybrid serialization approach: + +- **Protobuf envelope** — Structured metadata fields (`id`, `version`, `caller`, `timeout`, etc.) are native protobuf fields for efficient parsing and forward compatibility. +- **cloudpickle payloads** — The `callable`, `args`, `kwargs`, and `proxy` fields are serialized with cloudpickle and stored as `bytes` fields. This allows arbitrary Python objects to be transmitted without schema changes. +- **Results and exceptions** — `Result.dump` and `Exception.dump` are cloudpickle-serialized bytes. + +### Version compatibility + +The wire protocol follows [PEP 440](https://peps.python.org/pep-0440/) versioning. A servicer accepts tasks from any client whose protocol version is less than or equal to its own within the same major version. For example, a servicer running `1.3.0` will accept tasks from a client running `1.0.0`, but not from one running `1.4.0`. There is no forward compatibility guarantee from client to servicer. + +- **Minor and patch releases** are backwards compatible. New fields may be appended with new field numbers; existing field numbers and types never change within the same major version. +- **Major releases** may introduce breaking schema changes (field renumbering, type changes, removal). A major version mismatch between client and worker is treated as incompatible. +- **Fields 1–4 are the envelope.** The `Task` message reserves fields 1–4 (`version`, `id`, `caller`, `tag`) for lightweight metadata, enabling pre-deserialization extraction via `TaskEnvelope`. + +Implementations are responsible for enforcing version compatibility. 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..184ab2b --- /dev/null +++ b/build-hooks/build.py @@ -0,0 +1,35 @@ +import os +import pathlib +import re + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class EditableInstallHook(BuildHookInterface): + """Build hook that fixes generated gRPC import statements. + + Generated ``*_pb2_grpc.py`` files use absolute imports for + sibling ``*_pb2`` modules. This hook rewrites them as relative + imports so the package works regardless of its installation + path. + """ + + def initialize(self, version: str, build_data: dict) -> None: + for root, _, filenames in os.walk( + pathlib.Path(self.root) / "src" / "wool" / "protocol" + ): + 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) diff --git a/build-hooks/metadata.py b/build-hooks/metadata.py new file mode 100644 index 0000000..7d2ed31 --- /dev/null +++ b/build-hooks/metadata.py @@ -0,0 +1,16 @@ +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) + +import _git as _git +import _version +from hatchling.metadata.plugin.interface import MetadataHookInterface + + +class WoolMetadataHook(MetadataHookInterface): + PLUGIN_NAME = "wool-metadata" + + def update(self, metadata): + version = _version.PythonicVersion.parse.git() + metadata["version"] = str(version) diff --git a/llms/guides/testguide.md b/llms/guides/testguide.md new file mode 100644 index 0000000..a3a0f1f --- /dev/null +++ b/llms/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/llms/skills/commit.md b/llms/skills/commit.md new file mode 100644 index 0000000..506ba78 --- /dev/null +++ b/llms/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 + +``` + + + + +