diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..27f839e --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,33 @@ +name: docs-deploy + +on: + push: + branches: + - main + paths: + - '.github/workflows/docs.yml' + - '.github/workflows/docs-deploy.yml' + - 'docs/**' + - 'zensical.toml' + - 'pyproject.toml' + - 'src/common_python_tasks/tasks.py' + workflow_dispatch: + +concurrency: + group: github-pages + cancel-in-progress: false + +jobs: + docs: + uses: ./.github/workflows/docs.yml + with: + python_version: '3.14' + dependency_group: '' + locked: false + artifact_name: common-python-tasks-docs + generated_docs_check_task: check-docs-references + deploy_github_pages: true + permissions: + contents: read + pages: write + id-token: write diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml new file mode 100644 index 0000000..f0e40b0 --- /dev/null +++ b/.github/workflows/docs-preview-cleanup.yml @@ -0,0 +1,124 @@ +name: docs-preview-cleanup + +on: + pull_request: + types: + - closed + +jobs: + cleanup: + name: Remove Cloudflare Pages preview + if: vars.CLOUDFLARE_PAGES_PROJECT != '' + runs-on: ubuntu-latest + permissions: + contents: read + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_PROJECT_NAME: ci-sourcerer-common-python-tasks + CLOUDFLARE_PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_PREVIEW_DOMAIN: common-python-tasks.ci-sourcerer.com + CLOUDFLARE_PREVIEW_ZONE: ci-sourcerer.com + steps: + - name: Delete Cloudflare Pages preview deployments + run: | + if [[ -z "$CLOUDFLARE_ACCOUNT_ID" || -z "$CLOUDFLARE_API_TOKEN" ]]; then + echo "Cloudflare Pages cleanup skipped because credentials are unavailable." + exit 0 + fi + + project_url="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME" + project_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$project_url")" + case "$project_status" in + 200) + ;; + 404) + echo "Cloudflare Pages project does not exist; no preview deployments to remove." + exit 0 + ;; + *) + echo "Could not retrieve Cloudflare Pages project (HTTP $project_status)." >&2 + exit 1 + ;; + esac + + if [[ -n "$CLOUDFLARE_PREVIEW_DOMAIN" ]]; then + custom_domain="$CLOUDFLARE_PREVIEW_BRANCH.$CLOUDFLARE_PREVIEW_DOMAIN" + pages_target="$CLOUDFLARE_PREVIEW_BRANCH.$CLOUDFLARE_PROJECT_NAME.pages.dev" + if [[ -z "$CLOUDFLARE_PREVIEW_ZONE" ]]; then + CLOUDFLARE_PREVIEW_ZONE="$CLOUDFLARE_PREVIEW_DOMAIN" + fi + zone_response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones?name=$CLOUDFLARE_PREVIEW_ZONE")" + zone_id="$(jq --raw-output '.result[0].id // empty' <<< "$zone_response")" + if [[ -z "$zone_id" ]]; then + echo "No Cloudflare zone exists for $CLOUDFLARE_PREVIEW_ZONE." >&2 + exit 1 + fi + + record_response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=CNAME&name=$custom_domain")" + record_id="$(jq --raw-output --arg content "$pages_target" \ + '.result[] | select(.content | rtrimstr(".") == $content) | .id' \ + <<< "$record_response")" + if [[ -n "$record_id" ]]; then + curl --fail --silent --show-error \ + --request DELETE \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" > /dev/null + fi + + domain_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --request DELETE \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME/domains/$custom_domain")" + case "$domain_status" in + 200|202|204|404) + ;; + *) + echo "Could not remove Cloudflare Pages custom domain (HTTP $domain_status)." >&2 + exit 1 + ;; + esac + fi + + deployment_ids=() + page=1 + while true; do + response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$project_url/deployments?env=preview&page=$page&per_page=100")" + while IFS= read -r deployment_id; do + deployment_ids+=("$deployment_id") + done < <(jq --raw-output --arg branch "$CLOUDFLARE_PREVIEW_BRANCH" \ + '.result[] | select(.deployment_trigger.metadata.branch == $branch) | .id' \ + <<< "$response") + total_pages="$(jq --raw-output '.result_info.total_pages // 1' <<< "$response")" + if (( page >= total_pages )); then + break + fi + ((page++)) + done + + for deployment_id in "${deployment_ids[@]}"; do + delete_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --request DELETE \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$project_url/deployments/$deployment_id?force=true")" + case "$delete_status" in + 200|202|204|404) + echo "Removed Cloudflare Pages deployment $deployment_id." + ;; + 400) + echo "Cloudflare retained the latest Pages deployment for this branch." + ;; + *) + echo "Could not remove Cloudflare Pages deployment $deployment_id (HTTP $delete_status)." >&2 + exit 1 + ;; + esac + done diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 0000000..0b124ed --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,34 @@ +name: docs-preview + +on: + pull_request: + paths: + - '.github/workflows/docs.yml' + - '.github/workflows/docs-preview.yml' + - 'docs/**' + - 'zensical.toml' + - 'pyproject.toml' + - 'src/common_python_tasks/tasks.py' + workflow_dispatch: + +jobs: + docs: + uses: ./.github/workflows/docs.yml + with: + python_version: '3.14' + dependency_group: '' + locked: false + artifact_name: common-python-tasks-docs + generated_docs_check_task: check-docs-references + publish_cloudflare: ${{ github.event_name == 'pull_request' && vars.CLOUDFLARE_PAGES_PROJECT != '' }} + cloudflare_project_name: ci-sourcerer-common-python-tasks + cloudflare_preview_domain: common-python-tasks.ci-sourcerer.com + cloudflare_preview_zone: ci-sourcerer.com + permissions: + contents: read + deployments: write + pages: write + id-token: write + secrets: + cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..c21abe6 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,367 @@ +name: reusable-docs + +on: + workflow_call: + inputs: + python_version: + description: Python version used to build the documentation + required: true + type: string + dependency_group: + description: Optional uv dependency group installed before the build + required: false + default: dev + type: string + locked: + description: Require the checked-in uv lockfile to remain unchanged + required: false + default: true + type: boolean + generated_docs_check_task: + description: Optional client-owned Poe task that checks generated documentation + required: false + default: '' + type: string + site_path: + description: Directory containing the rendered static site + required: false + default: site + type: string + artifact_name: + description: Name of the uploaded documentation artifact + required: false + default: docs-site + type: string + deploy_github_pages: + description: Deploy the rendered site to GitHub Pages + required: false + default: false + type: boolean + publish_cloudflare: + description: Publish an internal pull request to Cloudflare Pages + required: false + default: false + type: boolean + cloudflare_project_name: + description: Cloudflare Pages Direct Upload project to create when absent and deploy previews to + required: false + default: '' + type: string + cloudflare_production_branch: + description: Production branch assigned if the Cloudflare Pages project is created + required: false + default: main + type: string + cloudflare_preview_domain: + description: Base custom domain for pull-request previews, such as preview.example.com + required: false + default: '' + type: string + cloudflare_preview_zone: + description: Cloudflare DNS zone containing the preview domain; defaults to the preview domain + required: false + default: '' + type: string + secrets: + cloudflare_account_id: + description: Cloudflare account containing the Pages project + required: false + cloudflare_api_token: + description: Cloudflare API token with Pages write access + required: false + outputs: + preview_url: + description: Stable Cloudflare Pages branch alias when a preview was published + value: ${{ jobs.build.outputs.preview_url }} + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + permissions: + contents: read + deployments: write + pages: write + outputs: + preview_url: ${{ steps.preview_url.outputs.url }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python ${{ inputs.python_version }} + uses: actions/setup-python@v7 + with: + python-version: ${{ inputs.python_version }} + + - name: Set up uv + uses: astral-sh/setup-uv@v10.0.1 + + - name: Install dependencies + env: + DEPENDENCY_GROUP: ${{ inputs.dependency_group }} + LOCKED: ${{ inputs.locked }} + run: | + args=(sync) + if [[ "$LOCKED" == "true" ]]; then + args+=(--locked) + fi + if [[ -n "$DEPENDENCY_GROUP" ]]; then + args+=(--group "$DEPENDENCY_GROUP") + fi + uv "${args[@]}" + + - name: Check generated documentation + if: inputs.generated_docs_check_task != '' + env: + GENERATED_DOCS_CHECK_TASK: ${{ inputs.generated_docs_check_task }} + run: | + if [[ ! "$GENERATED_DOCS_CHECK_TASK" =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "Invalid generated documentation check task: $GENERATED_DOCS_CHECK_TASK" >&2 + exit 2 + fi + uv run poe "$GENERATED_DOCS_CHECK_TASK" + + - name: Build documentation site + run: uv run poe docs-build + + - name: Upload documentation artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact_name }} + path: ${{ inputs.site_path }}/ + if-no-files-found: error + + - name: Validate Cloudflare preview configuration + if: inputs.publish_cloudflare + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.cloudflare_account_id }} + CLOUDFLARE_API_TOKEN: ${{ secrets.cloudflare_api_token }} + CLOUDFLARE_PROJECT_NAME: ${{ inputs.cloudflare_project_name }} + run: | + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "Cloudflare documentation previews require a pull request event." >&2 + exit 2 + fi + if [[ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then + echo "Cloudflare publication is unavailable for fork pull requests." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + if [[ -z "$CLOUDFLARE_PROJECT_NAME" || -z "$CLOUDFLARE_ACCOUNT_ID" || -z "$CLOUDFLARE_API_TOKEN" ]]; then + echo "Cloudflare project name, account ID, and API token are required." >&2 + exit 2 + fi + + - name: Ensure Cloudflare Pages project + if: >- + inputs.publish_cloudflare && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.cloudflare_account_id }} + CLOUDFLARE_API_TOKEN: ${{ secrets.cloudflare_api_token }} + CLOUDFLARE_PROJECT_NAME: ${{ inputs.cloudflare_project_name }} + CLOUDFLARE_PRODUCTION_BRANCH: ${{ inputs.cloudflare_production_branch }} + run: | + project_url="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME" + project_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$project_url")" + case "$project_status" in + 200) + echo "Cloudflare Pages project already exists." + ;; + 404) + create_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --request POST \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + --header 'Content-Type: application/json' \ + --data "$(jq --null-input \ + --arg name "$CLOUDFLARE_PROJECT_NAME" \ + --arg production_branch "$CLOUDFLARE_PRODUCTION_BRANCH" \ + '{name: $name, production_branch: $production_branch}')" \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects")" + case "$create_status" in + 200|201) + echo "Created Cloudflare Pages project." + ;; + 409) + project_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$project_url")" + if [[ "$project_status" != "200" ]]; then + echo "Cloudflare Pages project creation conflicted and the project is unavailable." >&2 + exit 1 + fi + echo "Cloudflare Pages project was created by another workflow run." + ;; + *) + echo "Could not create Cloudflare Pages project (HTTP $create_status)." >&2 + exit 1 + ;; + esac + ;; + *) + echo "Could not retrieve Cloudflare Pages project (HTTP $project_status)." >&2 + exit 1 + ;; + esac + + - name: Publish Cloudflare Pages preview + id: cloudflare + if: >- + inputs.publish_cloudflare && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + uses: cloudflare/wrangler-action@v4 + with: + accountId: ${{ secrets.cloudflare_account_id }} + apiToken: ${{ secrets.cloudflare_api_token }} + command: >- + pages deploy ${{ inputs.site_path }} + --project-name=${{ inputs.cloudflare_project_name }} + --branch=pr-${{ github.event.pull_request.number }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Cloudflare Pages custom preview domain + id: custom_preview_domain + if: >- + steps.cloudflare.outputs.pages-deployment-alias-url != '' && + inputs.cloudflare_preview_domain != '' + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.cloudflare_account_id }} + CLOUDFLARE_API_TOKEN: ${{ secrets.cloudflare_api_token }} + CLOUDFLARE_PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }} + CLOUDFLARE_PREVIEW_DOMAIN: ${{ inputs.cloudflare_preview_domain }} + CLOUDFLARE_PREVIEW_ZONE: ${{ inputs.cloudflare_preview_zone }} + CLOUDFLARE_PROJECT_NAME: ${{ inputs.cloudflare_project_name }} + run: | + custom_domain="$CLOUDFLARE_PREVIEW_BRANCH.$CLOUDFLARE_PREVIEW_DOMAIN" + pages_target="$CLOUDFLARE_PREVIEW_BRANCH.$CLOUDFLARE_PROJECT_NAME.pages.dev" + if [[ -z "$CLOUDFLARE_PREVIEW_ZONE" ]]; then + CLOUDFLARE_PREVIEW_ZONE="$CLOUDFLARE_PREVIEW_DOMAIN" + fi + zone_response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones?name=$CLOUDFLARE_PREVIEW_ZONE")" + zone_id="$(jq --raw-output '.result[0].id // empty' <<< "$zone_response")" + if [[ -z "$zone_id" ]]; then + echo "No Cloudflare zone exists for $CLOUDFLARE_PREVIEW_ZONE." >&2 + exit 1 + fi + + domain_url="https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME/domains/$custom_domain" + domain_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$domain_url")" + case "$domain_status" in + 200) + echo "Cloudflare Pages custom domain already exists." + ;; + 404) + create_status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --request POST \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + --header 'Content-Type: application/json' \ + --data "$(jq --null-input --arg name "$custom_domain" '{name: $name}')" \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME/domains")" + case "$create_status" in + 200|201) + echo "Added Cloudflare Pages custom domain." + ;; + 409) + echo "Cloudflare Pages custom domain was added concurrently." + ;; + *) + echo "Could not add Cloudflare Pages custom domain (HTTP $create_status)." >&2 + exit 1 + ;; + esac + ;; + *) + echo "Could not retrieve Cloudflare Pages custom domain (HTTP $domain_status)." >&2 + exit 1 + ;; + esac + + record_response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=CNAME&name=$custom_domain")" + record_id="$(jq --raw-output '.result[0].id // empty' <<< "$record_response")" + record_data="$(jq --null-input \ + --arg content "$pages_target" \ + --arg name "$custom_domain" \ + '{type: "CNAME", name: $name, content: $content, proxied: true}')" + if [[ -n "$record_id" ]]; then + curl --fail --silent --show-error \ + --request PUT \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + --header 'Content-Type: application/json' \ + --data "$record_data" \ + "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" > /dev/null + else + curl --fail --silent --show-error \ + --request POST \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + --header 'Content-Type: application/json' \ + --data "$record_data" \ + "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" > /dev/null + fi + + domain_response="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "$domain_url")" + if [[ "$(jq --raw-output '.result.status' <<< "$domain_response")" == "active" ]]; then + echo "url=https://$custom_domain" >> "$GITHUB_OUTPUT" + else + echo "Cloudflare custom domain is pending activation; using the Pages preview URL." + fi + + - name: Set Cloudflare preview URL + id: preview_url + if: steps.cloudflare.outputs.pages-deployment-alias-url != '' + env: + CUSTOM_PREVIEW_URL: ${{ steps.custom_preview_domain.outputs.url }} + PAGES_PREVIEW_URL: ${{ steps.cloudflare.outputs.pages-deployment-alias-url }} + run: | + if [[ -n "$CUSTOM_PREVIEW_URL" ]]; then + echo "url=$CUSTOM_PREVIEW_URL" >> "$GITHUB_OUTPUT" + else + echo "url=$PAGES_PREVIEW_URL" >> "$GITHUB_OUTPUT" + fi + + - name: Summarize Cloudflare preview + if: steps.preview_url.outputs.url != '' + env: + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + PREVIEW_URL: ${{ steps.preview_url.outputs.url }} + run: | + echo "## Documentation preview" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "[$PREVIEW_URL]($PREVIEW_URL) for commit \`$COMMIT_SHA\`." >> "$GITHUB_STEP_SUMMARY" + + - name: Configure GitHub Pages + if: inputs.deploy_github_pages + uses: actions/configure-pages@v6 + + - name: Upload GitHub Pages artifact + if: inputs.deploy_github_pages + uses: actions/upload-pages-artifact@v5 + with: + path: ${{ inputs.site_path }}/ + + deploy: + name: Deploy documentation + if: inputs.deploy_github_pages + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index 6746b78..083fac3 100644 --- a/.gitignore +++ b/.gitignore @@ -164,7 +164,7 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# Generated documentation site /site # mypy diff --git a/README.md b/README.md index dbb8619..13c0777 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,25 @@ # Common Python Tasks -`common-python-tasks` provides reusable, opinionated [Poe the Poet](https://poethepoet.natn.io/guides/packaged_tasks.html) tasks for common Python development workflows. +`common-python-tasks` provides reusable, opinionated [Poe the Poet](https://poethepoet.natn.io/guides/packaged_tasks.html) tasks for Python development, packaging, releases, containers, and documentation. -It supplies sensible defaults for formatting, linting, testing, packaging, releases, and container workflows while allowing projects to override configuration when needed. +Read the [complete documentation](https://ci-sourcerer.github.io/common-python-tasks/) for the task reference, configuration options, and deployment guidance. ## Quick start -### Manual setup - -Add `common-python-tasks` as a development dependency from your project root. +Add the package as a development dependency. ```shell uv add --dev common-python-tasks==0.10.3 ``` -Configure Poe the Poet to expose the default `common` task set. +Expose the default task set in `pyproject.toml`. ```toml [tool.poe] include_script = "common_python_tasks:tasks()" ``` -Run the common development tasks. +Run the everyday development tasks. ```shell poe format @@ -29,382 +27,35 @@ poe lint poe test ``` -### Automated setup - -The helper script performs the same development-dependency installation and Poe configuration. Download the script for the exact release you want, review it, then run it from your project root. - -```shell -curl --fail --silent --show-error --location \ - --output /tmp/add_common_python_tasks.py \ - https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.10.3/scripts/add_common_python_tasks.py -``` - -Review the downloaded script before executing it. - -```shell -less /tmp/add_common_python_tasks.py -``` - -Run the reviewed script with the same pinned package version. - -```shell -COMMON_PYTHON_TASKS_VERSION=0.10.3 python3 /tmp/add_common_python_tasks.py -``` - -To install another release, replace both occurrences of `0.10.3` with that release's version. - -## Available tasks - -The generated tables below list public tasks only. Tags identify which tasks are selected by `include_tags` and `exclude_tags`. - - - -### Daily development - -| Task | Description | Tags | -| --- | --- | --- | -| `test` | Run the test suite with coverage (if pytest-cov is installed). | common, test | -| `clean` | Clean up temporary files and directories. | clean, common | -| `format` | Fix import issues and format Python code with Ruff. | common, format | -| `lint` | Check Python lint and formatting with Ruff. | common, lint | - -### Packaging and releases - -| Task | Description | Tags | -| --- | --- | --- | -| `publish-package` | Publish the package to the PyPI server. | common, packaging | -| `publish-github-release` | Publish or update a GitHub Release for the current repository. | common, packaging, release | -| `update-dependencies` | Update project dependencies with uv. | common, packaging | -| `build-package` | Build the package (wheel and sdist). | build, common, packaging | -| `bump-version` | Bump the project version. | common, packaging | -| `changelog` | Print the changelog for the current version based on git history and git-cliff. | common, packaging, release | -| `release` | Run a full release flow for package and containers. | common, containers, packaging, release | - -### Container images - -| Task | Description | Tags | -| --- | --- | --- | -| `build-image` | Build the container image for this project using the Dockerfile template. | build, containers | -| `build-deps-image` | Build only the container dependency collector image for this project. | build, containers | -| `run-container` | Run the Docker image as a container for this project. By default, this will run the most-recently-built tag for the project's image. | containers | -| `push-image` | Push the Docker image for this project to the container registry. | containers, packaging, release | -| `build` | Build the project and its containers. | common, containers, packaging | -| `container-shell` | Run the debug image with an interactive shell. | containers, debug | - -### Development stacks - -| Task | Description | Tags | -| --- | --- | --- | -| `stack-up` | Bring up the development stack for the application. | containers, fastapi, web | -| `stack-down` | Bring down the development stack for the application. | containers, fastapi, web | -| `reset-db` | Reset the database by deleting the database volume. | containers, database, fastapi, web | -| `run-db-migrations` | Run database migrations. | containers, database, fastapi, web | -| `db-shell` | Open a psql shell to the database container. | containers, database, fastapi, web | - - - -## Configuration and requirements - -### Requirements - -Every project needs a `pyproject.toml` file at its root and Poe the Poet available in its development environment. - -Tasks that install, build, publish, or update dependencies require uv. Package and release tasks need a resolvable project version from `project.version` or Git tags. Dynamic versioning is supported but is not a baseline requirement. - -### Task selection - -Calling `tasks()` without arguments exposes the default `common` task set. - -```toml -[tool.poe] -include_script = "common_python_tasks:tasks()" -``` - -Select optional task groups with tags when your project needs them. - -```toml -[tool.poe] -include_script = "common_python_tasks:tasks(include_tags=['common', 'containers'])" -``` - -The `containers` tag also includes container-based development-stack tasks. Use the tags in the task tables to tailor a smaller task set. - -### Package management - -Packaging-related tasks, including `build-package`, `publish-package`, dependency updates, release version resolution, and container build metadata, use uv exclusively. The `uv` executable must be available on `PATH`. - -### Publish target selection - -The `publish-package` task resolves publish targets with this precedence. - -1. Explicit task arguments (`repository` or `repository_url`) -2. Environment-variable fallback -3. uv project configuration fallback from `[[tool.uv.index]]` -4. The `uv publish` default - -uv prefers named publish indexes. - -- `COMMON_PYTHON_TASKS_PUBLISH_REPOSITORY` -- `UV_PUBLISH_INDEX` -- `COMMON_PYTHON_TASKS_PUBLISH_URL` -- `UV_PUBLISH_URL` - -When using uv project configuration fallback, `[[tool.uv.index]]` must include both `name` and `publish-url` for publish selection. If multiple publishable indexes exist, set exactly one `default = true` or pass an explicit repository. - -### Configuration precedence - -For pytest and coverage, configuration resolves in the following order. - -1. Matching `pyproject.toml` sections such as `[tool.pytest.ini_options]` and `[tool.coverage]` -2. Environment variables that specify a configuration path -3. Local configuration files in the project root -4. Bundled defaults from [`src/common_python_tasks/data`](src/common_python_tasks/data) - -Ruff uses `RUFF_CONFIG` when set. Otherwise it discovers `.ruff.toml`, `ruff.toml`, or a `[tool.ruff]` section in `pyproject.toml`. When no project configuration is available, the tasks use the bundled Ruff defaults. Ruff configuration follows Ruff's native file precedence, so `.ruff.toml` takes precedence over `ruff.toml`, which takes precedence over `pyproject.toml` in the same directory. - -The `format` task first applies safe fixes for unused imports and import sorting, then runs the Ruff formatter. The `lint` task checks both Ruff lint rules and formatting without changing files. - -### Configuration examples - -After installing the package, a minimal project configuration looks like this. - -```toml -[project] -name = "simple-cli-tool" -version = "0.10.3" - -[tool.poe] -include_script = "common_python_tasks:tasks()" -``` - -A container-based project can select the container task group and set its image details. - -```toml -[tool.poe] -include_script = "common_python_tasks:tasks(include_tags=['common', 'containers'])" - -[tool.poe.env] -CONTAINER_REGISTRY_USERNAME = "myusername" -PACKAGE_NAME = "containerized-app" -``` - -The `test` task automatically uses pytest configuration in `pyproject.toml`. - -```toml -[tool.pytest.ini_options] -testpaths = ["tests", "integration"] -addopts = "-ra" -``` - -### Environment variables - -#### Task configuration files - -- `PYTEST_CONFIG`: Path to the pytest configuration file -- `COVERAGE_RCFILE`: Path to the coverage configuration file -- `RUFF_CONFIG`: Path to a Ruff TOML configuration file - -#### Project and package settings - -- `PACKAGE_NAME`: Overrides the package name inferred from `pyproject.toml` -- `COMMON_PYTHON_TASKS_PUBLISH_REPOSITORY`: Preferred publish repository/index name for `publish-package` -- `COMMON_PYTHON_TASKS_PUBLISH_URL`: Preferred uv publish upload URL for `publish-package` when no repository/index is selected -- `UV_PUBLISH_INDEX`: uv publish-index fallback for `publish-package` -- `UV_PUBLISH_URL`: uv publish upload-url fallback for `publish-package` - -#### Container image settings - -- `CONTAINER_REGISTRY_USERNAME`: Container-registry username for image tagging; the default is the current local user -- `CONTAINER_REGISTRY_URL`: Registry URL with a default of `docker.io/{username}` -- `CONTAINER_PYTHON_VARIANT`: Python base-image variant such as `slim`, `alpine`, etc. See for available options. Defaults to `slim`; set to empty string for no variant (e.g., `FROM python:3.11`). The value is passed as the `PYTHON_VARIANT` Docker build argument and recorded in the `org.opencontainers.image.python.variant` image label. -- `CONTAINER_DOCKER_BUILD_ARGS`: Additional arguments passed directly to `docker build`, parsed using shell quoting rules. Free arguments provided to the task after `--` take precedence. -- `CONTAINER_DOCKERFILE_HOOK_PATH`: Optional host path to an executable hook script that receives the generated Dockerfile path and can modify the file before `docker build` runs. -- `CONTAINER_APT_PACKAGES`: Space-delimited system packages installed in the generated image -- `CONTAINER_CUSTOM_ENTRYPOINT`: Custom container entrypoint script. The value must match a key in `[project].scripts`. -- `CONTAINER_DEPS_IMAGE`: Existing dependency image used when neither `CONTAINER_DEPS_CONTENT` nor `CONTAINER_DEPS_FILE` is configured -- `CONTAINER_EXTENSION_FILES`: Colon-delimited local extension Dockerfile paths. Escape literal colons as `\:` or quote the complete path. -- `CONTAINER_EXTENSIONS`: Colon-delimited extension-bundle names or parameterized values. Escape literal colons as `\:` or quote the complete value. -- `CONTAINER_ENV`: Colon-delimited `KEY=VALUE` declarations injected into both the builder and runtime stages. These variables are available to package build commands and runtime-stage build commands, and persist in the final and debug images for running containers. The `container_env` and `container_envfile` task arguments use the same behavior. Escape literal colons as `\:` or quote the complete value. Use `run-container --env` or `--envfile` to override values when starting a container. -- `.containerenv`: A project-root file that can supply the same declarations. It is loaded before the `container_envfile` task argument, `CONTAINER_ENV`, and the `container_env` task argument. -- `CONTAINER_PRUNE_KEEP`: Image-pruning policy after builds. `-1` keeps all images, `0` keeps only the latest, and `N` keeps the latest plus `N` prior images. -- `CONTAINER_DEPS_CONTENT`: Inline Dockerfile instructions for a dependency image that installs artifacts into `/tmp/deps` -- `CONTAINER_DEPS_FILE`: One or more dependency-image Dockerfiles. It accepts colon-delimited paths with literal colons escaped as `\:` and is used only when `CONTAINER_DEPS_CONTENT` is unset. -- `CONTAINER_DEPS_MAPPINGS`: Space-delimited `name:/target/path` entries for copying items from `/tmp/deps`. It is used only when no dependency move script is set. -- `CONTAINER_DEPS_MOVE_SCRIPT`: Raw executable script to run after `/tmp/deps` is copied into the image -- `CONTAINER_DEPS_MOVE_SCRIPT_PATH`: Host path to a dependency move script. This takes precedence over `CONTAINER_DEPS_MOVE_SCRIPT`. -- `UV_INDEX_{name}_USERNAME` and `UV_INDEX_{name}_PASSWORD`: Private Python index credentials consumed by uv during the Docker build. When any of these are set, the task automatically passes them as BuildKit secrets (`--secret id=uv_index_{name}_username,env=...`) and renders matching `--mount=type=secret` directives in the builder stage so uv can authenticate without baking credentials into the image. Multiple indices are supported; replace `{name}` with the uppercase index name (hyphens as underscores). These can be set in `tool.poe.env` for CI/CD or in the local environment for development. - -#### Release settings - -- `GITHUB_RELEASE_ASSETS`: Colon-delimited file paths or glob patterns to attach to a GitHub Release. The default is `dist/*`; escape literal colons as `\:` or quote the complete path. -- `SKIP_GITHUB_RELEASE`: Truthy value that skips GitHub Release publication -- `GITHUB_TOKEN` or `GH_TOKEN`: GitHub authentication token for releases and assets -- `GITHUB_REPOSITORY`: Optional repository-slug override for GitHub Release publication -- `GITHUB_API_URL` and `GITHUB_SERVER_URL`: GitHub Enterprise API-host settings -- `GITHUB_RELEASE_TAG`: Optional release tag name -- `GITHUB_RELEASE_NAME`: Optional release title -- `GITHUB_RELEASE_BODY`: Optional release body -- `RELEASE_UPDATE_CHANGELOG`: Truthy value that prepends `git-cliff --unreleased --tag "$RELEASE_TAG"` output to `CHANGELOG.md` before the release tag is created. It is enabled by default. -- `RELEASE_PRE_SCRIPT`: Optional shell command to run before release steps -- `RELEASE_POST_SCRIPT`: Optional shell command to run after release completion -- Release hooks receive `RELEASE_SCRIPT_PHASE`, `RELEASE_TAG`, `RELEASE_VERSION`, `RELEASE_STAGE`, `RELEASE_COMPONENT`, and `RELEASE_DRY_RUN`. - -#### Docker Compose settings - -- `COMPOSE_TYPE`: Application-stack type, such as `fastapi` -- `COMPOSE_ADDONS`: Colon-delimited services to include, such as `db` -- `COMPOSE_FILE`: Override for all compose files with colon-delimited paths -- `COMPOSE_OVERLAY_FILES`: Additional compose files to merge with colon-delimited paths -- `API_PORT`: API server port with a default of `8080` -- `SECRET_KEY`: Application secret key generated automatically when unset -- `ENVIRONMENT`: Environment name with a default of `production` -- `DEBUG_PORT`: Python debugger port in debug mode with a default of `5678` -- `DB_PORT`: Published host port for PostgreSQL, defaulting to `5432`. The API and migrator connect to the database container on port `5432` regardless of this setting. -- `DB_USER`: Database user with a default of the package name -- `DB_BASE`: Database name with a default of the package name -- `DB_PASS`: Database password generated automatically when unset -- `POSTGRES_VERSION`: PostgreSQL version with a default of `17` -- `ADMINER_PORT`: Adminer web UI port with a default of `8081` - -#### Debugging - -- `COMMON_PYTHON_TASKS_LOG_LEVEL`: Set to `DEBUG` to show detailed configuration resolution - -## Containers and development stacks - -Docker Compose development-stack tasks are available when the `containers` tag is selected. The current stack supports FastAPI applications and an optional PostgreSQL database. - -### Native Docker build arguments - -Arguments after the task's `--` separator are passed directly to `docker build`. Docker performs option validation, so any supported build option and its value can be used without a package-specific allowlist. - -```shell -poe build-image --single-arch -- \ - --secret id=pip_conf,env=PIP_CONF \ - --ssh default \ - --add-host example:127.0.0.1 -``` - -Set `CONTAINER_DOCKER_BUILD_ARGS` to provide the same arguments through the environment. The value uses shell quoting rules, and task arguments provided after `--` take precedence. - -Settings that affect generated Dockerfile content or dependency-image orchestration can be persisted directly in the project configuration. - -```toml -[tool.poe.env] -CONTAINER_APT_PACKAGES = "curl jq" -CONTAINER_CUSTOM_ENTRYPOINT = "serve" -CONTAINER_DEPS_IMAGE = "example/dependencies:latest" -``` - -### Stack configuration - -Set `COMPOSE_TYPE` to select the application stack. `fastapi` is currently supported and includes optional database support and Alembic migrations. - -```toml -[tool.poe.env] -COMPOSE_TYPE = "fastapi" -``` - -Set `COMPOSE_ADDONS` to select extra services. Addon names are colon-delimited, and `db` is the currently available addon. - -```toml -[tool.poe.env] -COMPOSE_ADDONS = "db" -``` - -### Compose-file customization - -The compose setup resolves files in this precedence order. - -1. **Environment override** uses `COMPOSE_FILE` with colon-delimited paths. -2. **Automatically loaded files** are based on `COMPOSE_TYPE` and `COMPOSE_ADDONS`. - - `compose-base.yml` provides the core application service. - - `compose-{addon}.yml` adds one file per addon, such as `compose-db.yml`. - - `compose-debug.yml` is used when the `--debug` flag is present. - - `compose-{addon}-debug.yml` provides debug overlays for addons. -3. **Additional overlays** use `COMPOSE_OVERLAY_FILES` with colon-delimited paths. +## Optional workflows -You can provide local compose files or allow the tasks to use bundled templates. - -### FastAPI stack - -The FastAPI stack uses the standard Dockerfile supplied by this package. Configure its ports, credentials, and database settings through the [Docker Compose settings](#docker-compose-settings) reference. - -## Troubleshooting - -### Tasks not showing up with `poe --help` - -Check the `[tool.poe]` configuration in `pyproject.toml` and use `include_script`. +Select additional task groups with `include_tags`. ```toml -# Correct -[tool.poe] -include_script = "common_python_tasks:tasks(exclude_tags=['internal'])" - -# Incorrect [tool.poe] -includes = "common_python_tasks:tasks" -``` - -### Config files not being used - -Review the [configuration precedence](#configuration-precedence) and enable debug logging to see the selected configuration. - -```shell -COMMON_PYTHON_TASKS_LOG_LEVEL=DEBUG poe test +include_script = "common_python_tasks:tasks(include_tags=['common', 'docs', 'containers'])" ``` -### GitHub Release assets not uploading +| Tag | Purpose | +| - | - | +| `common` | Formatting, linting, testing, packaging, and releases | +| `docs` | Build and serve Zensical documentation | +| `containers` | Build, run, inspect, and publish container images | +| `fastapi` | Run the FastAPI and PostgreSQL development stack | -Confirm that `dist/` contains the built wheels and source distributions. You can override the default asset selection with `GITHUB_RELEASE_ASSETS`. +Documentation projects can build locally or start a development server. ```shell -GITHUB_RELEASE_ASSETS="dist/*.whl:dist/*.tar.gz" poe publish-github-release +poe docs-build +poe docs-serve ``` -### Container build fails with "unable to find package" - -Check that `pyproject.toml` has a correct package name and the package-discovery settings required by its build backend. For a `src` layout built with Hatch, configure `[tool.hatch.build.targets.wheel] packages = ["src/your_package"]`. - -### Stack fails to start or services cannot connect - -Check the following conditions. - -- `COMPOSE_TYPE` is set and the required addon is selected. -- Default ports are available, including `8080` for the API, `5432` for PostgreSQL, and `8081` for Adminer. -- The Docker daemon is running, as verified with `docker info`. -- Service logs are available through `docker compose logs` in the project directory. - -### Database migrations fail - -Verify that the `db` addon is selected, Alembic is configured at the expected location, and its credentials match the generated `.env` values. Use `poe db-shell` to inspect the database manually. - -### Secrets are not generated - -Ensure the project-root `.env` file is writable and inspect its permissions with `ls -la .env`. You can also generate a value manually with `python -c "import secrets; print(secrets.token_hex(32))"`. - -## Design choices - -### Dockerfile design - -See the [standard Dockerfile template](src/common_python_tasks/data/generic/Dockerfile.j2) for the implementation. - -- Multi-stage build: The build stage installs uv and builds a wheel. The runtime stage installs only the wheel to keep the final image slim and reproducible. -- Cache mounts: Pip and uv cache mounts speed up iterative builds without bloating the final image. -- Explicit build metadata: `PYTHON_VERSION`, `UV_VERSION`, `PACKAGE_VERSION`, `PACKAGE_NAME`, `AUTHORS`, and `GIT_COMMIT` make image metadata predictable and auditable. -- Project-level build settings: `CONTAINER_APT_PACKAGES`, `CONTAINER_CUSTOM_ENTRYPOINT`, and `CONTAINER_DEPS_IMAGE` configure generated image behavior without being encoded as generic Docker arguments. -- Optional debug stage: The image exports and installs the `debug` dependency group only when present and does not include it in the default final image. -- Stable package path: Symlinks give entrypoints and consumers consistent `/pkg` and `/_$PACKAGE_NAME` paths regardless of wheel layout. -- Safe entrypoint selection: The default entrypoint resolves the console script matching the package name and falls back to `python`. `CONTAINER_CUSTOM_ENTRYPOINT` is validated against `[project].scripts`. -- Minimal final image: The standard slim Python base, cache cleanup, and explicit `runtime` final target keep the default image small. - -## Project notes +The [documentation workflow guide](https://ci-sourcerer.github.io/common-python-tasks/documentation-workflows/) covers GitHub Actions artifacts, optional Cloudflare Pages previews, and GitHub Pages deployment. -- This project dogfoods itself. Set `PYTHONPATH=src` when running its tasks locally so Poe uses the local package rather than the installed version. -- `RELEASE_UPDATE_CHANGELOG` is enabled by default and prepends the generated changelog section before the release tag is created. Set it to a falsy value to manage changelog commits yourself. -- `RELEASE_PRE_SCRIPT` and `RELEASE_POST_SCRIPT` are advanced hooks for release-specific work, such as updating a version reference in another file. -- The project is in alpha status, so breaking changes may occur between minor versions before 1.0.0. +## Learn more -## Contributing +- [Getting started](https://ci-sourcerer.github.io/common-python-tasks/getting-started/) +- [Task reference](https://ci-sourcerer.github.io/common-python-tasks/tasks/) +- [Configuration reference](https://ci-sourcerer.github.io/common-python-tasks/configuration/) -Contributions and feedback are welcome. Please open an issue or discussion to talk through a change before submitting a pull request. +The project is in alpha, so minor releases may contain breaking changes. It is distributed under the [MIT license](LICENSE). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..58c11c6 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,94 @@ +# Configuration + +Every consuming project needs `pyproject.toml` and Poe the Poet. Tasks that build, publish, install, or update packages use uv. + +## Configuration precedence + +Pytest and coverage configuration is resolved in this order. + +1. Matching `pyproject.toml` sections +2. Environment variables naming configuration files +3. Project-root configuration files +4. Defaults bundled with `common-python-tasks` + +Ruff uses `RUFF_CONFIG` when set. Otherwise, it discovers `.ruff.toml`, `ruff.toml`, or `[tool.ruff]` in `pyproject.toml`. The bundled configuration is used when the project has no Ruff configuration. + +## Project and publishing settings + +| Setting | Purpose | +| - | - | +| `PACKAGE_NAME` | Override the package name inferred from `pyproject.toml` | +| `COMMON_PYTHON_TASKS_PUBLISH_REPOSITORY` | Select a named uv publish index | +| `COMMON_PYTHON_TASKS_PUBLISH_URL` | Select a uv upload URL | +| `GITHUB_RELEASE_ASSETS` | Select GitHub Release asset paths or glob patterns | +| `SKIP_GITHUB_RELEASE` | Disable GitHub Release publication | +| `RELEASE_UPDATE_CHANGELOG` | Generate and commit release changelog content | +| `RELEASE_PRE_SCRIPT` | Run a command before the release steps | +| `RELEASE_POST_SCRIPT` | Run a command after the release steps | + +The package also recognizes uv's `UV_PUBLISH_INDEX` and `UV_PUBLISH_URL` settings. Explicit task arguments take precedence over environment values and project configuration. + +## Container settings + +Container variables are read by the host-side tasks unless their description explicitly says that they persist in the image. See [Container images](tasks/container-images.md) for the generated Dockerfile lifecycle, environment precedence, extensions, and dependency images. + +### Image and build settings + +| Setting | Purpose | +| - | - | +| `CONTAINER_REGISTRY_USERNAME` | Registry username used for image tags | +| `CONTAINER_REGISTRY_URL` | Registry hostname and optional namespace path; defaults to `docker.io/` | +| `CONTAINER_REGISTRY_NAMESPACE` | Namespace appended when the registry URL contains only a hostname | +| `CONTAINER_PYTHON_VARIANT` | Python base-image variant; defaults to `slim`, while an empty value disables the suffix | +| `WORKDIR_PATH` | Home and working directory for the non-root `py` user; defaults to `/workspace` | +| `CONTAINER_APT_PACKAGES` | Space-delimited APT packages installed in the runtime stage | +| `CONTAINER_CUSTOM_ENTRYPOINT` | `[project.scripts]` key selected as the image entrypoint command | +| `CONTAINER_DOCKER_BUILD_ARGS` | Shell-tokenized arguments passed directly to `docker build`; task arguments after `--` take precedence | +| `CONTAINER_DOCKERFILE_HOOK_PATH` | Executable host script that modifies the rendered Dockerfile before the build | +| `CONTAINER_PRUNE_KEEP` | Prior images kept after a build; `-1` disables pruning, `0` keeps only the latest, and `N` keeps the latest plus `N` prior images | + +### Environment and extension settings + +| Setting | Purpose | +| - | - | +| `CONTAINER_ENV` | Colon-delimited `KEY=VALUE` declarations persisted in the builder, runtime, final, and debug images | +| `.containerenv` | Project-root file containing the same declarations, at the lowest precedence | +| `UV_INDEX__USERNAME` | Private uv index username passed to BuildKit as a secret rather than persisted in the image | +| `UV_INDEX__PASSWORD` | Private uv index password passed to BuildKit as a secret rather than persisted in the image | +| `CONTAINER_EXTENSION_FILES` | Colon-delimited local Dockerfile fragments appended to the runtime stage | +| `CONTAINER_EXTENSIONS` | Colon-delimited installed extension bundles, optionally written as `bundle=value` | + +### Dependency-image settings + +| Setting | Purpose | +| - | - | +| `CONTAINER_DEPS_CONTENT` | Inline instructions for the bundled dependency-image Dockerfile; takes precedence over dependency files | +| `CONTAINER_DEPS_FILE` | Colon-delimited complete dependency Dockerfiles used when inline content is unset | +| `CONTAINER_DEPS_IMAGE` | Existing image that exports artifacts through `/tmp/deps` | +| `CONTAINER_DEPS_MAPPINGS` | Whitespace-delimited `name:/destination/path` moves for content copied from `/tmp/deps` | +| `CONTAINER_DEPS_MOVE_SCRIPT` | Inline script that distributes copied dependency artifacts and takes precedence over mappings | +| `CONTAINER_DEPS_MOVE_SCRIPT_PATH` | Host path to a dependency move script; takes precedence over the inline script and mappings | + +## Development-stack settings + +Set `COMPOSE_TYPE=fastapi` to use the bundled FastAPI stack. Add PostgreSQL with `COMPOSE_ADDONS=db`. + +```toml +[tool.poe.env] +COMPOSE_TYPE = "fastapi" +COMPOSE_ADDONS = "db" +API_PORT = "8080" +DB_PORT = "5432" +``` + +`COMPOSE_FILE` replaces automatic compose-file selection. `COMPOSE_OVERLAY_FILES` appends project-specific overlays to the automatically selected files. + +## Diagnostics + +Set `COMMON_PYTHON_TASKS_LOG_LEVEL=DEBUG` to show configuration resolution and subprocess commands. + +```shell +COMMON_PYTHON_TASKS_LOG_LEVEL=DEBUG poe test +``` + +The complete environment-variable inventory remains available in the repository [README](https://github.com/ci-sourcerer/common-python-tasks#environment-variables). diff --git a/docs/documentation-workflows.md b/docs/documentation-workflows.md new file mode 100644 index 0000000..e669d29 --- /dev/null +++ b/docs/documentation-workflows.md @@ -0,0 +1,104 @@ +# Documentation workflows + +Projects can use the packaged documentation tasks locally and the repository's reusable GitHub workflow in CI. The workflow builds the caller's repository, not `common-python-tasks`. + +## Configure a project + +Add a `zensical.toml` file and select the `docs` task group. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(include_tags=['common', 'docs'])" +``` + +Build or serve the site locally. + +```shell +poe docs-build +poe docs-serve +``` + +## Build pull-request artifacts + +Keep event triggers and path filters in the consuming repository, then call the reusable workflow from a job. + +```yaml +name: docs-preview + +on: + pull_request: + paths: + - 'docs/**' + - 'zensical.toml' + - 'pyproject.toml' + - 'uv.lock' + +jobs: + docs: + uses: ci-sourcerer/common-python-tasks/.github/workflows/docs.yml@v0.11.0 + with: + python_version: '3.14' + dependency_group: dev + permissions: + contents: read +``` + +The workflow uploads the rendered site as a GitHub Actions artifact by default. + +## Run a client-owned generated-document check + +Some projects generate additional documentation from application code. Keep that generator in the consuming project and pass its Poe check task to the workflow. + +```yaml +with: + python_version: '3.14' + dependency_group: dev + generated_docs_check_task: docs-cli-reference-check +``` + +Only task names containing letters, digits, underscores, and hyphens are accepted. + +## Enable Cloudflare Pages previews + +Cloudflare publication is optional. The workflow ensures that one Direct Upload Pages project exists for the consuming repository, then deploys previews to it with narrowly scoped credentials. + +```yaml +jobs: + docs: + uses: ci-sourcerer/common-python-tasks/.github/workflows/docs.yml@v0.11.0 + with: + python_version: '3.14' + dependency_group: dev + publish_cloudflare: true + cloudflare_project_name: stacksmith-docs + cloudflare_preview_domain: preview.example.com + cloudflare_preview_zone: example.com + permissions: + contents: read + deployments: write + secrets: + cloudflare_account_id: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + cloudflare_api_token: ${{ secrets.CLOUDFLARE_API_TOKEN }} +``` + +The project is created on the first trusted pull request, using `main` as its production branch by default. Set `cloudflare_production_branch` if the repository uses a different default branch. Set `cloudflare_preview_domain` to publish previews at `pr-42.preview.example.com`; leave it empty to use `pr-42.stacksmith-docs.pages.dev`. Set `cloudflare_preview_zone` when the preview domain is a subdomain of the DNS zone. The API token needs Pages Write, Zone Read, and Zone DNS Edit permissions when custom preview domains are enabled. Fork pull requests receive the build artifact but are not published because repository secrets are unavailable. A separate `pull_request`-closed workflow can remove the custom hostname, its matching DNS record, and older deployments for that preview branch while preserving the shared Pages project. Cloudflare retains the latest branch deployment. + +## Deploy production documentation to GitHub Pages + +Enable GitHub Actions as the repository's Pages source. A default-branch caller can then select production deployment. + +```yaml +jobs: + docs: + uses: ci-sourcerer/common-python-tasks/.github/workflows/docs.yml@v0.11.0 + with: + python_version: '3.14' + dependency_group: dev + deploy_github_pages: true + permissions: + contents: read + pages: write + id-token: write +``` + +Pin the workflow to the release that matches the installed package. Pin an exact commit SHA when an immutable workflow reference is required. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..540e7f8 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,72 @@ +# Getting started + +## Add the development dependency + +Add a released version of `common-python-tasks` from the root of a Python project. + +```shell +uv add --dev common-python-tasks==0.10.3 +``` + +Configure Poe to expose the standard task set. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks()" +``` + +The default selection provides formatting, linting, testing, package builds, dependency updates, and release tasks. + +```shell +poe format +poe lint +poe test +``` + +## Select optional task groups + +Pass `include_tags` when a project needs optional workflows. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(include_tags=['common', 'docs', 'containers'])" +``` + +Use `exclude_tags` when a project wants nearly every group. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(exclude_tags=['fastapi'])" +``` + +Calling `tasks()` with no arguments selects only `common`. Passing an explicit empty `include_tags` sequence selects every task unless an exclusion removes it. + +## Automated setup + +The release helper adds the dependency and configures Poe. Download the helper for the exact release being installed, review it, and run it with the same version. + +```shell +curl --fail --silent --show-error --location \ + --output /tmp/add_common_python_tasks.py \ + https://raw.githubusercontent.com/ci-sourcerer/common-python-tasks/v0.10.3/scripts/add_common_python_tasks.py +``` + +```shell +COMMON_PYTHON_TASKS_VERSION=0.10.3 python3 /tmp/add_common_python_tasks.py +``` + +## Inspect available tasks + +Poe displays the tasks selected by the project. + +```shell +poe --help +``` + +The package module also lists its public default tasks. Set the log level to include their descriptions. + +```shell +COMMON_PYTHON_TASKS_LOG_LEVEL=DEBUG python -m common_python_tasks +``` + +Continue with the [task reference](tasks/index.md) for task-specific behavior and requirements. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..65c1edb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +# Common Python Tasks + +`common-python-tasks` is a reusable collection of opinionated [Poe the Poet](https://poethepoet.natn.io/guides/packaged_tasks.html) tasks for Python projects. It gives projects one shared implementation for everyday development, packaging, releases, container images, development stacks, and documentation. + +## Choose where to begin + +- **Adding the package to a project?** Follow [Getting started](getting-started.md). +- **Looking for a command?** Browse the [task reference](tasks/index.md). +- **Customizing behavior?** Use the [configuration reference](configuration.md). +- **Publishing documentation like this?** See the [documentation workflows](documentation-workflows.md). + +## Task groups + +Tasks are organized by tags, so each project can select only the workflows it needs. + +| Group | Purpose | +| - | - | +| `common` | Formatting, linting, testing, packaging, and releases | +| `docs` | Build and locally serve Zensical documentation | +| `containers` | Build, run, inspect, and publish container images | +| `fastapi` | Run a local FastAPI and PostgreSQL development stack | + +The default task collection exposes the `common` group. Optional groups are enabled through the package's `include_script` expression. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(include_tags=['common', 'docs'])" +``` + +## Design approach + +The package supplies executable workflows and conservative defaults. Project-specific source files remain in the consuming repository, including application code, documentation content, Zensical configuration, Docker extensions, and environment settings. + +Commands run in the consuming project's environment, so the same task definitions work locally and in CI without hiding the underlying tools. diff --git a/docs/tasks/container-images.md b/docs/tasks/container-images.md new file mode 100644 index 0000000..49f2326 --- /dev/null +++ b/docs/tasks/container-images.md @@ -0,0 +1,354 @@ +# Container images + +Container tasks are selected with the optional `containers` tag. They generate a multi-stage Dockerfile from the project metadata and container settings, build the application wheel, and install it in a non-root runtime image. A consuming project does not need to maintain its own application Dockerfile. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(include_tags=['common', 'containers'])" +``` + +## How the generated Dockerfile works + +`build-image` renders a temporary Dockerfile and uses the project root as its build context. If the project does not provide `.dockerignore`, the task temporarily supplies a conservative default that includes `pyproject.toml`, `uv.lock`, `README.md`, `LICENSE`, and the `src/` tree. + +The generated Dockerfile contains the following stages. + +| Stage | Purpose | +| - | - | +| `builder` | Uses the host Python version, installs the installed version of uv, exports the optional `debug` dependency group, and builds the project wheel. | +| `runtime` | Uses the configured Python image variant, installs optional APT packages and dependency-image artifacts, creates the non-root `py` user, installs the wheel, creates the `/pkg` package link and entrypoint, and applies container extensions. | +| `debug` | Extends `runtime` with the project source and the `debug` dependency group. This stage exists only when `[dependency-groups].debug` is non-empty. | +| `final` | Extends `runtime` so tools that build the rendered Dockerfile without selecting a target still produce the normal runtime image. | + +The `build-image` task explicitly builds `runtime`, or `debug` when `--debug` is used. A debug build fails early when the project has no non-empty `debug` dependency group. + +The task supplies build arguments derived from the current environment and project metadata, including `PYTHON_VERSION`, `UV_VERSION`, `PACKAGE_VERSION`, `PACKAGE_NAME`, `AUTHORS`, `GIT_COMMIT`, `PYTHON_VARIANT`, and `WORKDIR_PATH`. It also creates short and fully qualified image tags for the package version and Git commit. A dirty working tree adds `-dirty` to the commit tag. + +### Generic template reference + +The complete templates below are copied from the package source during documentation generation. The preceding stage table explains how their major sections fit together. Run `poe update-docs-references` after changing either template, or `poe check-docs-references` to verify that the checked-in copies are current. + + + +### Application image template + +Source: [`Dockerfile.j2`](https://github.com/ci-sourcerer/common-python-tasks/blob/main/src/common_python_tasks/data/generic/Dockerfile.j2) + +```dockerfile +# syntax=docker/dockerfile:1 + +# It is wise to use the Python version you are developing with and not blindly choose +# the latest. The `build-image` task passes the version properly here +ARG PYTHON_VERSION=3 +# Variant for the runtime image, e.g. slim, alpine, etc. +# See https://hub.docker.com/_/python for available variants. Leave empty for no variant. +ARG PYTHON_VARIANT=slim + +FROM python:${PYTHON_VERSION} AS builder + +ARG PACKAGE_VERSION +ARG UV_VERSION + +{% if CONTAINER_ENV_VARS %} +# Make configured variables available to package build commands. +{% for env_var in CONTAINER_ENV_VARS -%} +ENV {{ env_var }} +{% endfor %} +{%- endif %} + +# Bypass VCS-based version detection: use the host-computed version directly. +# This avoids requiring a .git directory in the build context and prevents +# false dirty-version detection from a partial working tree. +ENV UV_DYNAMIC_VERSIONING_BYPASS=${PACKAGE_VERSION} + +# Install uv for package build and dependency export steps +RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} \ + sh -c 'if [ -n "${UV_VERSION:-}" ]; then \ + pip install --root-user-action=ignore "uv==$UV_VERSION"; \ + else \ + pip install --root-user-action=ignore uv; \ + fi' + +# Build package +WORKDIR /tmp/build +COPY . /tmp/build/ + +{% if HAS_DEBUG_DEPS %} +# Export debug requirements when the project defines a debug dependency group +RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \ + --mount={{ mount }}{% endfor %} \ + uv export --group debug --no-hashes --format requirements-txt --output-file requirements-debug.txt +{% endif %} + +# Build the wheel, caching the uv cache directory to speed up subsequent builds. +# If running this in a CI system, you must be using a persistent builder to take +# advantage of the cache mount, and you should configure your CI to persist the +# cache directory between builds +RUN --mount=type=cache,target=/root/.cache/uv,id=uv-cache{{ CACHE_ID_SUFFIX }}{% for mount in UV_INDEX_SECRET_MOUNTS %} \ + --mount={{ mount }}{% endfor %} \ + uv build --wheel + +FROM python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}} AS runtime +ARG PYTHON_VERSION +ARG PYTHON_VARIANT +ARG WORKDIR_PATH=/workspace + +LABEL org.opencontainers.image.base.name="python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}}" \ + org.opencontainers.image.python.variant="${PYTHON_VARIANT}" + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +ARG NONROOT_USERNAME=py +ARG NONROOT_UID=1000 +ARG NONROOT_GID=1000 + +{% if CONTAINER_ENV_VARS %} +# Persist the same variables for runtime build commands and running containers. +{% for env_var in CONTAINER_ENV_VARS -%} +ENV {{ env_var }} +{% endfor %} +{%- endif %} + +ENV DEBIAN_FRONTEND=noninteractive +{% if CONTAINER_APT_PACKAGES %} +# Install optional runtime apt packages selected by Python-side template rendering +RUN --mount=type=cache,target=/var/cache/apt,id=apt-cache{{ CACHE_ID_SUFFIX }} \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked,id=apt-lists{{ CACHE_ID_SUFFIX }} \ + apt-get update && apt-get install -y --no-install-recommends {{ CONTAINER_APT_PACKAGES }} +{% endif %} +{% if CONTAINER_DEPS_IMAGE %} + +# Pull external dependencies from the pre-built deps image +ARG CONTAINER_DEPS_IMAGE +COPY --from={{ CONTAINER_DEPS_IMAGE }} /tmp/deps /tmp/deps +{% endif %} +{% if CONTAINER_DEPS_MOVE_SCRIPT %} +RUN set -eux; \ + cat >/tmp/container-deps-move-script <<'SCRIPT' && \ + chmod +x /tmp/container-deps-move-script && \ + /tmp/container-deps-move-script +{{ CONTAINER_DEPS_MOVE_SCRIPT }} +SCRIPT +{% endif %} + +# Create a named non-root user with a writable home directory +# Written to cover all Python image variants +RUN set -eux; \ + if adduser --help 2>&1 | grep -q -- '--disabled-password'; then \ + addgroup --gid "${NONROOT_GID}" "${NONROOT_USERNAME}"; \ + adduser --uid "${NONROOT_UID}" --gid "${NONROOT_GID}" --home "${WORKDIR_PATH}" --shell /bin/sh --disabled-password --gecos '' "${NONROOT_USERNAME}"; \ + else \ + addgroup -g "${NONROOT_GID}" "${NONROOT_USERNAME}" && \ + adduser -D -u "${NONROOT_UID}" -G "${NONROOT_USERNAME}" -h "${WORKDIR_PATH}" -s /bin/sh "${NONROOT_USERNAME}"; \ + fi; \ + mkdir -p "${WORKDIR_PATH}"; \ + chown -R "${NONROOT_USERNAME}":"${NONROOT_USERNAME}" "${WORKDIR_PATH}" + +WORKDIR ${WORKDIR_PATH} + +# Grab package from builder image +COPY --from=builder /tmp/build/dist/*.whl /tmp/ + +# Install package +RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore /tmp/*.whl +# Create symlinks for the package +ARG PACKAGE_NAME +ENV PACKAGE_NAME=${PACKAGE_NAME} +RUN ln -s "$(python -c "import os; from importlib import resources; print(resources.files(os.environ['PACKAGE_NAME']))")" "/_$PACKAGE_NAME" \ + && ln -s "/_$PACKAGE_NAME" "/pkg" \ + && rm -rf "/_$PACKAGE_NAME/__pycache__" + +ENTRYPOINT ["/pkg/entrypoint.sh"] + +ARG AUTHORS +ARG GIT_COMMIT +LABEL org.opencontainers.image.authors=${AUTHORS} +LABEL git.commit=${GIT_COMMIT} + +# Set custom entrypoint if provided +# This entrypoint is deliberately not configurable via environment variables in order to +# ensure that the container always uses the entrypoint selected at build time. If the +# current package does not provide a console script, the entrypoint will default to `python` +RUN echo "#!/bin/sh + +{{ ENTRYPOINT_COMMAND|default('python') }} \"\$@\"" >/pkg/entrypoint.sh \ + && chmod +x /pkg/entrypoint.sh + +USER py + +{% if EXTENSION_CONTENT %} +{{ EXTENSION_CONTENT }} +{% endif %} + +{% if HAS_DEBUG_DEPS %} +# Optional debug stage: only installs debug deps if they were exported. This stage will not +# be built by default (the final stage below is the runtime image), and it will safely do +# nothing if there are no debug requirements +FROM runtime AS debug + +USER root + +COPY --from=builder /tmp/build /tmp/build + +RUN --mount=type=cache,target=/root/.cache/pip,id=pip-cache{{ CACHE_ID_SUFFIX }} pip install --root-user-action=ignore -r /tmp/build/requirements-debug.txt + +USER py +{% endif %} + +# Final (default) image: explicitly use runtime as the final target so debug is not used unless requested +FROM runtime AS final + +USER py +``` + +### Dependency image template + +Source: [`Dockerfile.deps.j2`](https://github.com/ci-sourcerer/common-python-tasks/blob/main/src/common_python_tasks/data/generic/Dockerfile.deps.j2) + +```dockerfile +# syntax=docker/dockerfile:1 + +# Dependency collector image: installs external dependencies into /tmp/deps +# so the main application Dockerfile can COPY --from this image. +ARG PYTHON_VERSION=3 +ARG PYTHON_VARIANT=slim + +FROM python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}} AS deps +ARG PYTHON_VERSION +ARG PYTHON_VARIANT + +LABEL org.opencontainers.image.base.name="python:${PYTHON_VERSION}${PYTHON_VARIANT:+-${PYTHON_VARIANT}}" \ + org.opencontainers.image.python.variant="${PYTHON_VARIANT}" + +ARG CACHE_ID_SUFFIX +RUN printf '%s' "$CACHE_ID_SUFFIX" >/tmp/.cache_id_suffix + +ENV DEBIAN_FRONTEND=noninteractive + +RUN mkdir -p /tmp/deps + +{{ DEPS_CONTENT }} +``` + + + +### Runtime defaults + +The runtime image uses `python:-slim` by default. Set `CONTAINER_PYTHON_VARIANT` to another official Python image variant, or to an empty string to use `python:` without a suffix. `CONTAINER_APT_PACKAGES` is rendered as a space-delimited package list in an `apt-get install` command, so it should only be used with Debian-based variants. + +The image runs as the non-root `py` user with `/workspace` as both its home and working directory. `WORKDIR_PATH` changes that directory. The package is available through `/pkg`, regardless of the installed wheel layout. + +The generated entrypoint selects a console script in the following order. + +1. The `[project.scripts]` key named by `CONTAINER_CUSTOM_ENTRYPOINT` +2. A script matching the underscore-normalized package name +3. A script matching the hyphenated package name +4. `python` + +An explicit `CONTAINER_CUSTOM_ENTRYPOINT` must match a key in `[project.scripts]`. The selected command is fixed at build time and receives the container command as its arguments. + +## Container environment declarations + +Most `CONTAINER_*` variables are host-side task settings. They control Dockerfile rendering, naming, or build orchestration and are not automatically exposed inside the image. + +`CONTAINER_ENV` is different. It accepts `KEY=VALUE` declarations that are rendered as `ENV` instructions in both `builder` and `runtime`. These values are therefore available while the package is built, during later runtime-stage build instructions, and whenever a container starts from the resulting image. + +Declarations are accumulated from lowest to highest precedence. + +1. The project-root `.containerenv` file +2. Files passed through `--container-envfile` +3. `CONTAINER_ENV` +4. Values passed through `--container-env` + +Later declarations with the same key override earlier declarations. Files may use one declaration per line, and inline settings may be colon-delimited. Every declaration must contain `KEY=VALUE`. Quote a value or escape a literal colon as `\:` when it contains a colon. + +```text title=".containerenv" +APP_ENV=production +LOG_LEVEL=info +PUBLIC_API_URL="https://api.example.com" +``` + +```shell +poe build-image --container-env LOG_LEVEL=debug +``` + +Do not use these declarations for secrets because their values persist in the image configuration. Host variables matching `UV_INDEX__USERNAME` or `UV_INDEX__PASSWORD` are handled separately as BuildKit secrets for uv operations and are not rendered as image environment variables. + +The `--env` and `--envfile` options on `run-container` and `container-shell` have ordinary `docker run` semantics. They affect only that container invocation and can override environment values embedded during the build. + +## Extending the runtime image + +Use `CONTAINER_EXTENSION_FILES` to append project-owned Dockerfile fragments without replacing the generic Dockerfile. Its value is a colon-delimited list of paths, such as `Dockerfile.system:docker/Dockerfile.browser`. + +```dockerfile title="Dockerfile.system" +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +USER py +``` + +Extension files are concatenated in their configured order and inserted near the end of `runtime`, after the entrypoint is created and after `USER py`. An extension that needs elevated permissions must switch to `USER root`; it should normally restore `USER py` for the instructions that follow. `COPY` paths remain relative to the project-root build context. + +Extension content is treated as raw Dockerfile syntax, not as a Jinja template. This keeps project extensions independent of private template variables used by `common-python-tasks`. + +`CONTAINER_EXTENSIONS` selects extension bundles shipped in the installed package's `data/dockerfile_extensions/` directory. Bundle names are colon-delimited and are applied after local extension files. A bundle may accept one value with `bundle=value`; that value is passed to the first `ARG` declared by the bundle that has not already been assigned to another extension. Arguments are ignored with a warning when the bundle declares no `ARG`. + +Use an extension for additive runtime instructions. Use `CONTAINER_DOCKERFILE_HOOK_PATH` only when a change must rewrite another part of the generated Dockerfile. The hook must be an executable host-side script; it receives the generated Dockerfile path as its first argument and must edit that file in place. The hook also receives the following context variables. + +| Variable | Meaning | +| - | - | +| `COMMON_PYTHON_TASKS_DOCKERFILE_PATH` | Generated Dockerfile path, matching the first script argument | +| `COMMON_PYTHON_TASKS_DOCKER_CONTEXT` | Docker build context path | +| `COMMON_PYTHON_TASKS_DOCKER_DEBUG` | `1` for a debug build, otherwise `0` | +| `COMMON_PYTHON_TASKS_DOCKER_NO_CACHE` | `1` when `--no-cache` is active, otherwise `0` | +| `COMMON_PYTHON_TASKS_DOCKER_PLAIN` | `1` when plain progress output is active, otherwise `0` | +| `COMMON_PYTHON_TASKS_DOCKER_SINGLE_ARCH` | `1` for a single-architecture build, otherwise `0` | + +## Supplying external dependencies + +Dependency images support artifacts that should be built separately from the application wheel, such as compiled tools or browser binaries. The dependency image must place its exported content under `/tmp/deps`; the generated application Dockerfile copies that directory into its `runtime` stage. + +There are three ways to select the dependency image. + +| Setting | Behavior | +| - | - | +| `CONTAINER_DEPS_CONTENT` | Supplies inline instructions appended to the bundled dependency-image Dockerfile. The bundle already defines the base image and creates `/tmp/deps`. | +| `CONTAINER_DEPS_FILE` | Supplies one or more colon-delimited paths to complete dependency Dockerfiles. Multiple files are concatenated in order. | +| `CONTAINER_DEPS_IMAGE` | Reuses an existing image whose artifacts are already in `/tmp/deps`. | + +Inline content takes precedence over dependency files. Either local source takes precedence over an existing `CONTAINER_DEPS_IMAGE` because `build-image` builds and tags a content-addressed dependency image first. `build-deps-image` can build that image independently. + +After copying `/tmp/deps`, the runtime build can distribute its contents in one of two ways. `CONTAINER_DEPS_MAPPINGS` accepts whitespace-delimited `name:/destination/path` pairs and moves `/tmp/deps/` to each destination. For more control, use an inline `CONTAINER_DEPS_MOVE_SCRIPT` or a file named by `CONTAINER_DEPS_MOVE_SCRIPT_PATH`. A script path takes precedence over an inline script, and either script takes precedence over mappings. + +```toml +[tool.poe.env] +CONTAINER_DEPS_IMAGE = "example/toolchain:2026.09" +CONTAINER_DEPS_MAPPINGS = "bin/tool:/usr/local/bin/tool share/tool:/usr/local/share/tool" +``` + +## Passing Docker build options + +Arguments following Poe's `--` separator are passed directly to `docker build` when the task accepts positional Docker arguments. + +```shell +poe build-image --single-arch -- --secret id=pip_conf,env=PIP_CONF +``` + +Set `CONTAINER_DOCKER_BUILD_ARGS` to persist the same options using shell quoting rules. Arguments supplied after `--` replace this setting for that invocation. Managed arguments are emitted before these native Docker arguments, so an explicit option such as `--build-arg WORKDIR_PATH=/app` can override its managed value. + +See [Container settings](../configuration.md#container-settings) for the complete settings reference. + + + +| Task | Description | +| - | - | +| [`build-image`](reference/build-image.md) | Build the container image for this project using the Dockerfile template. | +| [`build-deps-image`](reference/build-deps-image.md) | Build only the container dependency collector image for this project. | +| [`run-container`](reference/run-container.md) | Run the Docker image as a container for this project. | +| [`push-image`](reference/push-image.md) | Push the Docker image for this project to the container registry. | +| [`build`](reference/build.md) | Build the project and its containers. | +| [`container-shell`](reference/container-shell.md) | Run the debug image with an interactive shell. | + + diff --git a/docs/tasks/daily-development.md b/docs/tasks/daily-development.md new file mode 100644 index 0000000..a8b38e9 --- /dev/null +++ b/docs/tasks/daily-development.md @@ -0,0 +1,24 @@ +# Daily development + +These tasks form the short feedback loop for ordinary Python changes. They are part of the default `common` task selection and use project-local Ruff, pytest, and coverage configuration before falling back to the package defaults. + +Run formatting before linting so the checks operate on normalized source. + +```shell +poe format +poe lint +poe test +``` + +See [Configuration](../configuration.md#configuration-precedence) for configuration discovery and diagnostic logging. + + + +| Task | Description | +| - | - | +| [`test`](reference/test.md) | Run the test suite with coverage (if pytest-cov is installed). | +| [`clean`](reference/clean.md) | Clean up temporary files and directories. | +| [`format`](reference/format.md) | Fix import issues and format Python code with Ruff. | +| [`lint`](reference/lint.md) | Check Python lint and formatting with Ruff. | + + diff --git a/docs/tasks/development-stacks.md b/docs/tasks/development-stacks.md new file mode 100644 index 0000000..2a446c1 --- /dev/null +++ b/docs/tasks/development-stacks.md @@ -0,0 +1,25 @@ +# Development stacks + +Development-stack tasks combine the optional `fastapi` and `containers` tags. The bundled stack can run an application, debugger, PostgreSQL, Adminer, and Alembic migrator through Docker Compose. + +Select the database addon before using database-specific tasks. + +```toml +[tool.poe.env] +COMPOSE_TYPE = "fastapi" +COMPOSE_ADDONS = "db" +``` + +See [Development-stack settings](../configuration.md#development-stack-settings) for ports, compose overlays, and service configuration. + + + +| Task | Description | +| - | - | +| [`stack-up`](reference/stack-up.md) | Bring up the development stack for the application. | +| [`stack-down`](reference/stack-down.md) | Bring down the development stack for the application. | +| [`reset-db`](reference/reset-db.md) | Reset the database by deleting the database volume. | +| [`run-db-migrations`](reference/run-db-migrations.md) | Run database migrations. | +| [`db-shell`](reference/db-shell.md) | Open a psql shell to the database container. | + + diff --git a/docs/tasks/documentation.md b/docs/tasks/documentation.md new file mode 100644 index 0000000..d15b8be --- /dev/null +++ b/docs/tasks/documentation.md @@ -0,0 +1,20 @@ +# Documentation + +Documentation tasks are selected with the optional `docs` tag and require a project-owned `zensical.toml` and documentation source directory. Production builds always use Zensical's strict mode so warnings fail CI. + +The local server watches documentation and configuration changes until interrupted. + +```shell +poe docs-serve --open-browser +``` + +See [Documentation workflows](../documentation-workflows.md) for artifact uploads, pull-request previews, and production deployment. + + + +| Task | Description | +| - | - | +| [`docs-build`](reference/docs-build.md) | Build the Zensical documentation site in strict mode. | +| [`docs-serve`](reference/docs-serve.md) | Serve the Zensical documentation site for local preview. | + + diff --git a/docs/tasks/index.md b/docs/tasks/index.md new file mode 100644 index 0000000..d1fde31 --- /dev/null +++ b/docs/tasks/index.md @@ -0,0 +1,37 @@ +# Task reference + +The task reference is divided by workflow. Each category page includes a table linking to individual task pages with complete command syntax, tags, accepted arguments, defaults, and descriptions taken directly from the packaged Poe configuration and Python task docstrings. + +## Browse by category + +| Section | Purpose | +| - | - | +| [Daily development](daily-development.md) | Format, lint, test, and clean a project | +| [Documentation](documentation.md) | Build and locally serve a Zensical site | +| [Packaging and releases](packaging-and-releases.md) | Build, version, publish, and release packages | +| [Container images](container-images.md) | Build, run, inspect, and publish images | +| [Development stacks](development-stacks.md) | Operate the FastAPI and PostgreSQL Compose stack | + +Individual task reference pages are also available in the sidebar under the "reference" section. + +## Configuration + +The `common` tag is selected when `tasks()` is called without arguments. Enable optional workflows by passing their tags to `include_tags`. + +```toml +[tool.poe] +include_script = "common_python_tasks:tasks(include_tags=['common', 'docs', 'containers'])" +``` + +Run `poe --help TASK_NAME` in a consuming project to inspect the installed release's command-line help. + +## Generated documentation references + +Task sections and the container Dockerfile templates are generated from the current package source. After changing task metadata, arguments, or a template, refresh the pages and verify the result. + +```shell +poe update-docs-references +poe check-docs-references +``` + +The documentation workflows run the check task before building the site, so stale references fail CI. diff --git a/docs/tasks/packaging-and-releases.md b/docs/tasks/packaging-and-releases.md new file mode 100644 index 0000000..76e451a --- /dev/null +++ b/docs/tasks/packaging-and-releases.md @@ -0,0 +1,21 @@ +# Packaging and releases + +Packaging tasks use uv for dependency updates, builds, and publication. Release tasks derive versions from project metadata and Git history, can update the changelog, and can publish GitHub Releases. + +Release operations expect a clean working tree and the repository's default branch unless their documented options explicitly allow otherwise. Use dry-run modes before changing tags or publishing artifacts. + +Publish targets and release hooks are described in [Configuration](../configuration.md#project-and-publishing-settings). + + + +| Task | Description | +| - | - | +| [`publish-package`](reference/publish-package.md) | Publish the package to the PyPI server. | +| [`publish-github-release`](reference/publish-github-release.md) | Publish or update a GitHub Release for the current repository. | +| [`update-dependencies`](reference/update-dependencies.md) | Update project dependencies with uv. | +| [`build-package`](reference/build-package.md) | Build the package (wheel and sdist). | +| [`bump-version`](reference/bump-version.md) | Bump the project version. | +| [`changelog`](reference/changelog.md) | Print the changelog for the current version based on git history and git-cliff. | +| [`release`](reference/release.md) | Run a full release flow for package and containers. | + + diff --git a/docs/tasks/reference/build-deps-image.md b/docs/tasks/reference/build-deps-image.md new file mode 100644 index 0000000..3647c38 --- /dev/null +++ b/docs/tasks/reference/build-deps-image.md @@ -0,0 +1,22 @@ +# `poe build-deps-image` + +Build only the container dependency collector image for this project. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `build`, `containers` + +## Usage + +```shell +poe build-deps-image [DOCKER_BUILD_ARGS...] [--no-cache] [--plain] [--single-arch] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `docker_build_args...` | `string, repeatable` | Additional arguments passed directly to `docker build`. Provide them after the task's `--` separator. Overrides `CONTAINER_DOCKER_BUILD_ARGS` when provided. | — | +| `--no-cache` | `boolean` | Do not use cache when building the deps image. | `false` | +| `--plain` | `boolean` | Do not pretty-print output. | `false` | +| `--single-arch` | `boolean` | Build images for a single architecture. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/build-image.md b/docs/tasks/reference/build-image.md new file mode 100644 index 0000000..a0f1d94 --- /dev/null +++ b/docs/tasks/reference/build-image.md @@ -0,0 +1,26 @@ +# `poe build-image` + +Build the container image for this project using the Dockerfile template. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `build`, `containers` + +## Usage + +```shell +poe build-image [DOCKER_BUILD_ARGS...] [--debug] [--no-cache] [--plain] [--single-arch] [--dockerfile-hook-path DOCKERFILE_HOOK_PATH] [--container-env CONTAINER_ENV...] [--container-envfile CONTAINER_ENVFILE...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `docker_build_args...` | `string, repeatable` | Additional arguments passed directly to `docker build`. Provide them after the task's `--` separator. Overrides `CONTAINER_DOCKER_BUILD_ARGS` when provided. | — | +| `--debug` | `boolean` | Build the debug image. | `false` | +| `--no-cache` | `boolean` | Do not use cache when building the image. | `false` | +| `--plain` | `boolean` | Do not pretty-print output. | `false` | +| `--single-arch` | `boolean` | Build images for a single architecture. | `false` | +| `--dockerfile-hook-path` | `string` | Optional executable script path that can mutate the generated Dockerfile before build. Overrides CONTAINER_DOCKERFILE_HOOK_PATH if provided. | — | +| `--container-env` | `string, repeatable` | Builder and runtime environment declarations as repeated KEY=VALUE values. | — | +| `--container-envfile` | `string, repeatable` | Optional repeated list of files containing builder and runtime environment declarations. | — | \ No newline at end of file diff --git a/docs/tasks/reference/build-package.md b/docs/tasks/reference/build-package.md new file mode 100644 index 0000000..7f91dcd --- /dev/null +++ b/docs/tasks/reference/build-package.md @@ -0,0 +1,20 @@ +# `poe build-package` + +Build the package (wheel and sdist). + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `build`, `common`, `packaging` + +## Usage + +```shell +poe build-package [--wheel-only] [--clean-dist] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--wheel-only` | `boolean` | Whether to build only the wheel artifact. | `false` | +| `--clean-dist` | `boolean` | Whether to remove existing distribution artifacts first. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/build.md b/docs/tasks/reference/build.md new file mode 100644 index 0000000..fe29e17 --- /dev/null +++ b/docs/tasks/reference/build.md @@ -0,0 +1,24 @@ +# `poe build` + +Build the project and its containers. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `common`, `containers`, `packaging` + +## Usage + +```shell +poe build [--debug] [--no-cache] [--plain] [--single-arch] [--container-env CONTAINER_ENV...] [--container-envfile CONTAINER_ENVFILE...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--debug` | `boolean` | Build the debug image. | `false` | +| `--no-cache` | `boolean` | Do not use cache when building the image. | `false` | +| `--plain` | `boolean` | Do not pretty-print output. | `false` | +| `--single-arch` | `boolean` | Build images for a single architecture. | `false` | +| `--container-env` | `string, repeatable` | Inline container environment variables as repeated KEY=VALUE values. | — | +| `--container-envfile` | `string, repeatable` | Repeated list of container environment files. | — | \ No newline at end of file diff --git a/docs/tasks/reference/bump-version.md b/docs/tasks/reference/bump-version.md new file mode 100644 index 0000000..d6f36dc --- /dev/null +++ b/docs/tasks/reference/bump-version.md @@ -0,0 +1,22 @@ +# `poe bump-version` + +Bump the project version. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `packaging` + +## Usage + +```shell +poe bump-version [--component COMPONENT] [--stage STAGE] [--dry-run] [--allow-dirty] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--component` | `string` | The version component to bump: major, minor, patch, or auto to infer the bump from git history using git-cliff. | `auto` | +| `--stage` | `string` | Optional pre-release stage to apply: alpha, beta, or rc. | — | +| `--dry-run` | `boolean` | Print what would happen without making changes. | `false` | +| `--allow-dirty` | `boolean` | Allow version bumping with uncommitted changes. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/changelog.md b/docs/tasks/reference/changelog.md new file mode 100644 index 0000000..2965c46 --- /dev/null +++ b/docs/tasks/reference/changelog.md @@ -0,0 +1,13 @@ +# `poe changelog` + +Print the changelog for the current version based on git history and git-cliff. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `packaging`, `release` + +## Usage + +```shell +poe changelog +``` \ No newline at end of file diff --git a/docs/tasks/reference/clean.md b/docs/tasks/reference/clean.md new file mode 100644 index 0000000..a20a9a7 --- /dev/null +++ b/docs/tasks/reference/clean.md @@ -0,0 +1,19 @@ +# `poe clean` + +Clean up temporary files and directories. + +**Category:** [`daily-development`](../daily-development.md) + +**Tags:** `clean`, `common` + +## Usage + +```shell +poe clean [--dist-only] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--dist-only` | `boolean` | Only clean the dist directory (and related build artifacts) | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/container-shell.md b/docs/tasks/reference/container-shell.md new file mode 100644 index 0000000..6739413 --- /dev/null +++ b/docs/tasks/reference/container-shell.md @@ -0,0 +1,26 @@ +# `poe container-shell` + +Run the debug image with an interactive shell. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `containers`, `debug` + +## Usage + +```shell +poe container-shell [--tag TAG] [--shell SHELL] [--root] [--no-echo-env] [--env ENV...] [--envfile ENVFILE...] [--privileged] [--volumes VOLUMES...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--tag` | `string` | Image tag to use. Default: Use the most-recently-built tag. | — | +| `--shell` | `string` | Preferred shell name or path. Default: Use the first available from zsh, fish, ksh, bash, and sh. | — | +| `--root` | `boolean` | Whether to run the shell as root. | `false` | +| `--no-echo-env` | `boolean` | Whether to suppress printing environment variables on startup for debugging. | `false` | +| `--env` | `string, repeatable` | Repeated KEY=VALUE or KEY values to pass with -e. | — | +| `--envfile` | `string, repeatable` | Repeated envfile paths to pass with --env-file. | — | +| `--privileged` | `boolean` | Whether to run the container with --privileged. | `false` | +| `--volumes` | `string, repeatable` | Repeated volume mounts to pass with -v. | — | \ No newline at end of file diff --git a/docs/tasks/reference/db-shell.md b/docs/tasks/reference/db-shell.md new file mode 100644 index 0000000..83d0e5e --- /dev/null +++ b/docs/tasks/reference/db-shell.md @@ -0,0 +1,13 @@ +# `poe db-shell` + +Open a psql shell to the database container. + +**Category:** [`development-stacks`](../development-stacks.md) + +**Tags:** `containers`, `database`, `fastapi`, `web` + +## Usage + +```shell +poe db-shell +``` \ No newline at end of file diff --git a/docs/tasks/reference/docs-build.md b/docs/tasks/reference/docs-build.md new file mode 100644 index 0000000..cfee4ac --- /dev/null +++ b/docs/tasks/reference/docs-build.md @@ -0,0 +1,20 @@ +# `poe docs-build` + +Build the Zensical documentation site in strict mode. + +**Category:** [`documentation`](../documentation.md) + +**Tags:** `docs` + +## Usage + +```shell +poe docs-build [--config-file CONFIG_FILE] [--clean] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--config-file` | `string` | Optional path to a Zensical configuration file. | — | +| `--clean` | `boolean` | Remove cached files before building the site. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/docs-serve.md b/docs/tasks/reference/docs-serve.md new file mode 100644 index 0000000..563118e --- /dev/null +++ b/docs/tasks/reference/docs-serve.md @@ -0,0 +1,21 @@ +# `poe docs-serve` + +Serve the Zensical documentation site for local preview. + +**Category:** [`documentation`](../documentation.md) + +**Tags:** `docs` + +## Usage + +```shell +poe docs-serve [--config-file CONFIG_FILE] [--dev-addr DEV_ADDR] [--open-browser] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--config-file` | `string` | Optional path to a Zensical configuration file. | — | +| `--dev-addr` | `string` | Optional development server address in `IP:PORT` form. | — | +| `--open-browser` | `boolean` | Open the preview in the default browser. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/format.md b/docs/tasks/reference/format.md new file mode 100644 index 0000000..0376f08 --- /dev/null +++ b/docs/tasks/reference/format.md @@ -0,0 +1,13 @@ +# `poe format` + +Fix import issues and format Python code with Ruff. + +**Category:** [`daily-development`](../daily-development.md) + +**Tags:** `common`, `format` + +## Usage + +```shell +poe format +``` \ No newline at end of file diff --git a/docs/tasks/reference/lint.md b/docs/tasks/reference/lint.md new file mode 100644 index 0000000..1946040 --- /dev/null +++ b/docs/tasks/reference/lint.md @@ -0,0 +1,13 @@ +# `poe lint` + +Check Python lint and formatting with Ruff. + +**Category:** [`daily-development`](../daily-development.md) + +**Tags:** `common`, `lint` + +## Usage + +```shell +poe lint +``` \ No newline at end of file diff --git a/docs/tasks/reference/publish-github-release.md b/docs/tasks/reference/publish-github-release.md new file mode 100644 index 0000000..8eddd26 --- /dev/null +++ b/docs/tasks/reference/publish-github-release.md @@ -0,0 +1,24 @@ +# `poe publish-github-release` + +Publish or update a GitHub Release for the current repository. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `packaging`, `release` + +## Usage + +```shell +poe publish-github-release [--tag-name TAG_NAME] [--release-name RELEASE_NAME] [--body BODY] [--prerelease] [--draft] [--assets ASSETS...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--tag-name` | `string` | Optional release tag to publish. | — | +| `--release-name` | `string` | Optional display name for the release. | — | +| `--body` | `string` | Optional release notes body. | — | +| `--prerelease` | `boolean` | Whether to mark the release as a pre-release. | `false` | +| `--draft` | `boolean` | Whether to create the release as a draft. | `false` | +| `--assets` | `string, repeatable` | Optional release asset paths or glob patterns. | — | \ No newline at end of file diff --git a/docs/tasks/reference/publish-package.md b/docs/tasks/reference/publish-package.md new file mode 100644 index 0000000..ac04a0c --- /dev/null +++ b/docs/tasks/reference/publish-package.md @@ -0,0 +1,21 @@ +# `poe publish-package` + +Publish the package to the PyPI server. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `packaging` + +## Usage + +```shell +poe publish-package [--build-first] [--repository REPOSITORY] [--repository-url REPOSITORY_URL] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--build-first` | `boolean` | Build the package before publishing. | `true` | +| `--repository` | `string` | Optional configured repository name to publish to. | — | +| `--repository-url` | `string` | Optional repository upload URL to publish to. | — | \ No newline at end of file diff --git a/docs/tasks/reference/push-image.md b/docs/tasks/reference/push-image.md new file mode 100644 index 0000000..595363d --- /dev/null +++ b/docs/tasks/reference/push-image.md @@ -0,0 +1,19 @@ +# `poe push-image` + +Push the Docker image for this project to the container registry. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `containers`, `packaging`, `release` + +## Usage + +```shell +poe push-image [--debug] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--debug` | `boolean` | Push the debug image. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/release.md b/docs/tasks/reference/release.md new file mode 100644 index 0000000..c298cba --- /dev/null +++ b/docs/tasks/reference/release.md @@ -0,0 +1,32 @@ +# `poe release` + +Run a full release flow for package and containers. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `containers`, `packaging`, `release` + +## Usage + +```shell +poe release [--component COMPONENT] [--stage STAGE] [--dry-run] [--debug] [--no-cache] [--plain] [--single-arch] [--container-env CONTAINER_ENV...] [--container-envfile CONTAINER_ENVFILE...] [--assets ASSETS...] [--repository REPOSITORY] [--repository-url REPOSITORY_URL] [--pre-script PRE_SCRIPT] [--post-script POST_SCRIPT] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--component` | `string` | The version component to bump: major, minor, or patch. | `auto` | +| `--stage` | `string` | Optional pre-release stage to apply: alpha, beta, or rc. | — | +| `--dry-run` | `boolean` | Only perform a dry-run version bump. | `false` | +| `--debug` | `boolean` | Build/push debug container image tags when releasing containers. | `false` | +| `--no-cache` | `boolean` | Do not use cache when building container images. | `false` | +| `--plain` | `boolean` | Do not pretty-print container build output. | `false` | +| `--single-arch` | `boolean` | Build container image for a single architecture. | `false` | +| `--container-env` | `string, repeatable` | Inline container environment variables as repeated KEY=VALUE values. | — | +| `--container-envfile` | `string, repeatable` | Repeated list of container environment files. | — | +| `--assets` | `string, repeatable` | Optional repeated list of release asset patterns or paths. | — | +| `--repository` | `string` | Optional configured repository name to publish to. | — | +| `--repository-url` | `string` | Optional repository upload URL to publish to. | — | +| `--pre-script` | `string` | Optional shell command to run before the release steps. | — | +| `--post-script` | `string` | Optional shell command to run after the release completes. | — | \ No newline at end of file diff --git a/docs/tasks/reference/reset-db.md b/docs/tasks/reference/reset-db.md new file mode 100644 index 0000000..395c5f8 --- /dev/null +++ b/docs/tasks/reference/reset-db.md @@ -0,0 +1,13 @@ +# `poe reset-db` + +Reset the database by deleting the database volume. + +**Category:** [`development-stacks`](../development-stacks.md) + +**Tags:** `containers`, `database`, `fastapi`, `web` + +## Usage + +```shell +poe reset-db +``` \ No newline at end of file diff --git a/docs/tasks/reference/run-container.md b/docs/tasks/reference/run-container.md new file mode 100644 index 0000000..4596db9 --- /dev/null +++ b/docs/tasks/reference/run-container.md @@ -0,0 +1,27 @@ +# `poe run-container` + +Run the Docker image as a container for this project. + +**Category:** [`container-images`](../container-images.md) + +**Tags:** `containers` + +## Usage + +```shell +poe run-container [--tag TAG] [--entrypoint ENTRYPOINT] [--command COMMAND] [--root] [--echo-env] [--env ENV...] [--envfile ENVFILE...] [--privileged] [--volumes VOLUMES...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--tag` | `string` | Image tag to run. Default: Use the most-recently-built tag. | — | +| `--entrypoint` | `string` | Optional entrypoint override. | — | +| `--command` | `string` | Optional command to pass to the entrypoint. | — | +| `--root` | `boolean` | Whether to run as root (only relevant with a shell entrypoint). | `false` | +| `--echo-env` | `boolean` | Whether to prepend an env dump to the command. | `false` | +| `--env` | `string, repeatable` | Repeated KEY=VALUE or KEY values to pass with -e. | — | +| `--envfile` | `string, repeatable` | Repeated envfile paths to pass with --env-file. | — | +| `--privileged` | `boolean` | Whether to run the container with --privileged. | `false` | +| `--volumes` | `string, repeatable` | Repeated volume mounts to pass with -v. | — | \ No newline at end of file diff --git a/docs/tasks/reference/run-db-migrations.md b/docs/tasks/reference/run-db-migrations.md new file mode 100644 index 0000000..2f718cf --- /dev/null +++ b/docs/tasks/reference/run-db-migrations.md @@ -0,0 +1,13 @@ +# `poe run-db-migrations` + +Run database migrations. + +**Category:** [`development-stacks`](../development-stacks.md) + +**Tags:** `containers`, `database`, `fastapi`, `web` + +## Usage + +```shell +poe run-db-migrations +``` \ No newline at end of file diff --git a/docs/tasks/reference/stack-down.md b/docs/tasks/reference/stack-down.md new file mode 100644 index 0000000..10c3eed --- /dev/null +++ b/docs/tasks/reference/stack-down.md @@ -0,0 +1,13 @@ +# `poe stack-down` + +Bring down the development stack for the application. + +**Category:** [`development-stacks`](../development-stacks.md) + +**Tags:** `containers`, `fastapi`, `web` + +## Usage + +```shell +poe stack-down +``` \ No newline at end of file diff --git a/docs/tasks/reference/stack-up.md b/docs/tasks/reference/stack-up.md new file mode 100644 index 0000000..7e7c8b3 --- /dev/null +++ b/docs/tasks/reference/stack-up.md @@ -0,0 +1,24 @@ +# `poe stack-up` + +Bring up the development stack for the application. + +**Category:** [`development-stacks`](../development-stacks.md) + +**Tags:** `containers`, `fastapi`, `web` + +## Usage + +```shell +poe stack-up [--debug] [--no-cache] [--detach] [--services SERVICES...] [--container-env CONTAINER_ENV...] [--container-envfile CONTAINER_ENVFILE...] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `--debug` | `boolean` | Enable debug mode (auto-loads all *-debug.yml compose files). | `false` | +| `--no-cache` | `boolean` | Do not use cache when building the image. | `false` | +| `--detach` | `boolean` | Run the stack in detached mode. | `false` | +| `--services` | `string, repeatable` | Optional repeated list of services to start. If not provided, all services will be started. | — | +| `--container-env` | `string, repeatable` | Inline container environment variables as repeated KEY=VALUE values. | — | +| `--container-envfile` | `string, repeatable` | Repeated list of container environment files. | — | \ No newline at end of file diff --git a/docs/tasks/reference/test.md b/docs/tasks/reference/test.md new file mode 100644 index 0000000..a91bed8 --- /dev/null +++ b/docs/tasks/reference/test.md @@ -0,0 +1,20 @@ +# `poe test` + +Run the test suite with coverage (if pytest-cov is installed). + +**Category:** [`daily-development`](../daily-development.md) + +**Tags:** `common`, `test` + +## Usage + +```shell +poe test [PATHS...] [--quiet] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `paths...` | `string, repeatable` | Optional test file paths or directories to pass through to pytest. | — | +| `--quiet` | `boolean` | Run tests in a quieter mode. | `false` | \ No newline at end of file diff --git a/docs/tasks/reference/update-dependencies.md b/docs/tasks/reference/update-dependencies.md new file mode 100644 index 0000000..923a8c8 --- /dev/null +++ b/docs/tasks/reference/update-dependencies.md @@ -0,0 +1,25 @@ +# `poe update-dependencies` + +Update project dependencies with uv. + +**Category:** [`packaging-and-releases`](../packaging-and-releases.md) + +**Tags:** `common`, `packaging` + +## Usage + +```shell +poe update-dependencies [DEPENDENCIES...] [--branch] [--branch-name BRANCH_NAME] [--commit] [--pr] [--draft] [--run-tests] +``` + +## Arguments + +| Argument | Type | Description | Default | +| - | - | - | - | +| `dependencies...` | `string, repeatable` | Optional dependency names to update. When omitted, update all dependencies. | — | +| `--branch` | `boolean` | Create a dependency update branch after updating. | `false` | +| `--branch-name` | `string` | Optional branch name to use instead of generating one. | — | +| `--commit` | `boolean` | Commit the dependency update files. | `false` | +| `--pr` | `boolean` | Create and push a branch, commit the changes, and open a GitHub pull request. | `false` | +| `--draft` | `boolean` | Create the pull request as a draft. | `false` | +| `--run-tests` | `boolean` | Run the test task before committing. | `false` | \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 43b1ffa..5b95c8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "git-cliff (>=2.12.0,<3.0.0)", "packaging (>=26.2,<27.0)", "requests (>=2.34.2,<3.0.0)", + "zensical (>=0.0.59,<0.1.0)", ] dynamic = ["version"] @@ -39,15 +40,18 @@ Issues = "http://github.com/ci-sourcerer/common-python-tasks/issues" [tool.poe] include_script = "common_python_tasks:tasks(exclude_tags=['containers', 'fastapi'])" +[tool.poe.tasks] +update-docs-references = { cmd = "python -m scripts.update_docs_tasks_reference" } +check-docs-references = { cmd = "python -m scripts.update_docs_tasks_reference --check" } +update-docs-tasks-reference = { cmd = "python -m scripts.update_docs_tasks_reference" } +check-docs-tasks-reference = { cmd = "python -m scripts.update_docs_tasks_reference --check" } + [tool.poe.env] PYTHONPATH = "src" RELEASE_UPDATE_CHANGELOG = "1" RELEASE_PRE_SCRIPT = "RELEASE_SCRIPT_PHASE=pre python -m scripts.release_script" CONTAINER_APT_PACKAGES = "jq" -[tool.poe.tasks] -update-readme-tasks-table = { cmd = "python -m scripts.update_readme_tasks_table" } - [tool.hatch.version] source = "uv-dynamic-versioning" @@ -55,7 +59,14 @@ source = "uv-dynamic-versioning" packages = ["src/common_python_tasks"] [tool.hatch.build.targets.sdist] -include = ["src/common_python_tasks", "scripts", "README.md", "LICENSE"] +include = [ + "src/common_python_tasks", + "scripts", + "docs", + "zensical.toml", + "README.md", + "LICENSE", +] [dependency-groups] debug = ["debugpy (>=1.8.16,<2.0.0)"] diff --git a/scripts/docs_tasks_reference.py b/scripts/docs_tasks_reference.py new file mode 100644 index 0000000..f5a7e86 --- /dev/null +++ b/scripts/docs_tasks_reference.py @@ -0,0 +1,325 @@ +import importlib +import inspect +import re +from pathlib import Path + +from common_python_tasks.__main__ import get_available_tasks, get_task_tags +from common_python_tasks.tasks import tasks + +TASK_REFERENCE_PATTERN = re.compile( + r"(?ms).*?" +) +DOCKERFILE_REFERENCE_PATTERN = re.compile( + r"(?ms).*?" +) +TASK_CATEGORY_FILES = { + "daily-development": Path("docs/tasks/daily-development.md"), + "documentation": Path("docs/tasks/documentation.md"), + "packaging-and-releases": Path("docs/tasks/packaging-and-releases.md"), + "container-images": Path("docs/tasks/container-images.md"), + "development-stacks": Path("docs/tasks/development-stacks.md"), +} +DOCKERFILE_REFERENCE_FILE = Path("docs/tasks/container-images.md") +DOCKERFILE_TEMPLATE_FILES = ( + ( + "Application image template", + Path("src/common_python_tasks/data/generic/Dockerfile.j2"), + ), + ( + "Dependency image template", + Path("src/common_python_tasks/data/generic/Dockerfile.deps.j2"), + ), +) + + +def _get_task_category(task_name: str) -> str: + tags = get_task_tags(task_name) or [] + if "docs" in tags: + return "documentation" + if "web" in tags or "database" in tags: + return "development-stacks" + if task_name == "release": + return "packaging-and-releases" + if "containers" in tags: + return "container-images" + if "packaging" in tags or "release" in tags: + return "packaging-and-releases" + return "daily-development" + + +def _get_task_config(task_name: str) -> dict: + return tasks()["tasks"][task_name] + + +def _get_docstring_argument_help(task_config: dict) -> dict[str, str]: + module_name, function_name = task_config["script"].split(":", 1) + docstring = inspect.getdoc( + getattr(importlib.import_module(module_name), function_name) + ) + if not docstring or "\nArgs:\n" not in docstring: + return {} + + arguments: dict[str, str] = {} + current_name: str | None = None + for line in docstring.split("\nArgs:\n", 1)[1].splitlines(): + if line and not line.startswith(" "): + break + if match := re.match(r"^\s{4}\*{0,2}([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line): + current_name = match.group(1) + arguments[current_name] = match.group(2) + elif current_name and line.strip(): + arguments[current_name] = f"{arguments[current_name]} {line.strip()}" + return arguments + + +def _format_argument_name(argument: dict) -> str: + if argument.get("positional"): + suffix = "..." if argument.get("multiple") else "" + return f"`{argument['name']}{suffix}`" + return ", ".join(f"`{option}`" for option in argument.get("options", [])) + + +def _format_argument_type(argument: dict) -> str: + repeatable = ", repeatable" if argument.get("multiple") else "" + return f"`{argument.get('type', 'string')}{repeatable}`" + + +def _format_default(argument: dict) -> str: + if argument.get("required"): + return "Required" + if "default" not in argument: + return "—" + return f"`{str(argument['default']).lower()}`" + + +def _format_usage_argument(argument: dict) -> str: + if argument.get("positional"): + value = argument["name"].upper() + if argument.get("multiple"): + value = f"{value}..." + return f"<{value}>" if argument.get("required") else f"[{value}]" + + option = argument.get("options", [f"--{argument['name'].replace('_', '-')}"])[0] + if argument.get("type") != "boolean": + option = f"{option} {argument['name'].upper()}" + if argument.get("multiple"): + option = f"{option}..." + return option if argument.get("required") else f"[{option}]" + + +def _escape_table_text(value: str) -> str: + return value.replace("|", "\\|") + + +def _render_task_full(task_name: str) -> list[str]: + task_config = _get_task_config(task_name) + arguments = task_config.get("args", []) + lines = [ + f"## `{task_name}`", + "", + task_config.get("help", "No description is available."), + "", + f"**Tags:** {', '.join(f'`{tag}`' for tag in get_task_tags(task_name) or [])}", + "", + "```shell", + " ".join( + [f"poe {task_name}", *[_format_usage_argument(arg) for arg in arguments]] + ), + "```", + ] + if not arguments: + return lines + + docstring_help = _get_docstring_argument_help(task_config) + lines.extend( + [ + "", + "### Arguments", + "", + "| Argument | Type | Description | Default |", + "| - | - | - | - |", + ] + ) + lines.extend( + "| " + f"{_format_argument_name(argument)} | " + f"{_format_argument_type(argument)} | " + f"{_escape_table_text(argument.get('help') or docstring_help.get(argument['name'], 'Additional value passed to the task.'))} | " + f"{_format_default(argument)} |" + for argument in arguments + ) + return lines + + +def build_task_reference(category: str) -> str: + """Build the generated Markdown reference for a task category (link table). + + Args: + category: Category identifier from `TASK_CATEGORY_FILES`. + + Returns: + The generated Markdown block with links to individual task pages. + + Raises: + ValueError: If the category is unknown. + """ + if category not in TASK_CATEGORY_FILES: + raise ValueError(f"Unknown task category: {category}") + + task_names = [ + task_name + for task_name in get_available_tasks() + if _get_task_category(task_name) == category + ] + + lines = [""] + if task_names: + lines.extend( + [ + "", + "| Task | Description |", + "| - | - |", + ] + ) + for task_name in task_names: + task_config = _get_task_config(task_name) + description = _escape_table_text( + task_config.get("help", "No description is available.") + ) + lines.append( + f"| [`{task_name}`](reference/{task_name}.md) | {description} |" + ) + lines.extend(["", ""]) + return "\n".join(lines) + + +def replace_task_reference(text: str, category: str) -> str: + """Replace a generated task-reference block. + + Args: + text: Markdown containing the task-reference markers. + category: Category identifier from `TASK_CATEGORY_FILES`. + + Returns: + Markdown containing the current generated reference. + + Raises: + ValueError: If the Markdown does not contain exactly one generated block. + """ + if len(TASK_REFERENCE_PATTERN.findall(text)) != 1: + raise ValueError("Expected exactly one generated task-reference block") + return TASK_REFERENCE_PATTERN.sub(build_task_reference(category), text) + + +def _build_individual_task_page(task_name: str) -> str: + """Build the Markdown content for an individual task reference page. + + Args: + task_name: The task name. + + Returns: + The complete Markdown content for the task page. + """ + task_config = _get_task_config(task_name) + arguments = task_config.get("args", []) + category = _get_task_category(task_name) + + lines = [ + f"# `poe {task_name}`", + "", + task_config.get("help", "No description is available."), + "", + f"**Category:** [`{category}`](../{category}.md)", + "", + f"**Tags:** {', '.join(f'`{tag}`' for tag in get_task_tags(task_name) or [])}", + "", + "## Usage", + "", + "```shell", + " ".join( + [f"poe {task_name}", *[_format_usage_argument(arg) for arg in arguments]] + ), + "```", + ] + + if arguments: + docstring_help = _get_docstring_argument_help(task_config) + lines.extend( + [ + "", + "## Arguments", + "", + "| Argument | Type | Description | Default |", + "| - | - | - | - |", + ] + ) + lines.extend( + "| " + f"{_format_argument_name(argument)} | " + f"{_format_argument_type(argument)} | " + f"{_escape_table_text(argument.get('help') or docstring_help.get(argument['name'], 'Additional value passed to the task.'))} | " + f"{_format_default(argument)} |" + for argument in arguments + ) + + return "\n".join(lines) + + +def generate_task_reference_files() -> list[Path]: + """Generate individual task reference pages in docs/tasks/reference/. + + Returns: + List of paths to generated files. + """ + reference_dir = Path("docs/tasks/reference") + reference_dir.mkdir(parents=True, exist_ok=True) + + generated_files = [] + for task_name in get_available_tasks(): + file_path = reference_dir / f"{task_name}.md" + content = _build_individual_task_page(task_name) + file_path.write_text(content, encoding="utf-8") + generated_files.append(file_path) + + return generated_files + + +def build_dockerfile_reference() -> str: + """Build the generated Dockerfile-template documentation block. + + Returns: + Markdown containing the current bundled Dockerfile templates. + """ + lines = [""] + for heading, path in DOCKERFILE_TEMPLATE_FILES: + lines.extend( + [ + "", + f"### {heading}", + "", + f"Source: [`{path.name}`](https://github.com/ci-sourcerer/common-python-tasks/blob/main/{path})", + "", + "```dockerfile", + path.read_text(encoding="utf-8").rstrip(), + "```", + ] + ) + lines.extend(["", ""]) + return "\n".join(lines) + + +def replace_dockerfile_reference(text: str) -> str: + """Replace the Dockerfile-template documentation block. + + Args: + text: Markdown containing the generated Dockerfile-reference markers. + + Returns: + Markdown containing the current bundled Dockerfile templates. + + Raises: + ValueError: If the Markdown does not contain exactly one generated block. + """ + if len(DOCKERFILE_REFERENCE_PATTERN.findall(text)) != 1: + raise ValueError("Expected exactly one generated Dockerfile-reference block") + return DOCKERFILE_REFERENCE_PATTERN.sub(build_dockerfile_reference(), text) diff --git a/scripts/readme_tasks_table.py b/scripts/readme_tasks_table.py deleted file mode 100644 index 4d19105..0000000 --- a/scripts/readme_tasks_table.py +++ /dev/null @@ -1,78 +0,0 @@ -import re - -from common_python_tasks.__main__ import ( - _get_task_docstring, - get_available_tasks, - get_task_tags, -) - -TASKS_TABLE_PATTERN = r"(?ms).*?" -TASK_CATEGORY_ORDER = ( - "Daily development", - "Packaging and releases", - "Container images", - "Development stacks", -) - - -def _get_task_category(task_name: str) -> str: - tags = get_task_tags(task_name) or [] - if "web" in tags or "database" in tags: - return "Development stacks" - if task_name == "release": - return "Packaging and releases" - if "containers" in tags: - return "Container images" - if "packaging" in tags or "release" in tags: - return "Packaging and releases" - return "Daily development" - - -def _get_task_description(task_name: str) -> str: - return " ".join( - line.strip() - for line in (_get_task_docstring(task_name) or "").splitlines() - if line.strip() - ) - - -def build_tasks_table() -> str: - """Build the markdown task table for README insertion.""" - tasks_by_category = {category: [] for category in TASK_CATEGORY_ORDER} - for task_name in get_available_tasks(internal=False): - tasks_by_category[_get_task_category(task_name)].append(task_name) - - lines = [""] - for category in TASK_CATEGORY_ORDER: - if not tasks_by_category[category]: - continue - - lines.extend( - [ - "", - f"### {category}", - "", - "| Task | Description | Tags |", - "| --- | --- | --- |", - ] - ) - lines.extend( - f"| `{task_name}` | {_get_task_description(task_name)} | " - f"{', '.join(get_task_tags(task_name) or [])} |" - for task_name in tasks_by_category[category] - ) - - lines.extend(["", ""]) - return "\n".join(lines) - - -def replace_tasks_table(readme_text: str) -> str: - """Replace the task table in the README text with the generated task table. - - Args: - readme_text: The text of the README.md file to update. - - Returns: - The updated README.md text with the task table replaced. - """ - return re.sub(TASKS_TABLE_PATTERN, build_tasks_table(), readme_text) diff --git a/scripts/release_script.py b/scripts/release_script.py index 44253b2..1211e76 100755 --- a/scripts/release_script.py +++ b/scripts/release_script.py @@ -6,7 +6,6 @@ from enum import StrEnum from pathlib import Path -from .readme_tasks_table import replace_tasks_table from .utils import commit_readme_update, configure_logger, get_logger, log_dry_run LOGGER = get_logger(__name__) @@ -62,7 +61,7 @@ def _update_readme_for_pre_release( f"{current_version!r} to update" ) - return replace_tasks_table(readme_text.replace(current_version, release_version)) + return readme_text.replace(current_version, release_version) def main() -> None: diff --git a/scripts/update_docs_tasks_reference.py b/scripts/update_docs_tasks_reference.py new file mode 100644 index 0000000..c1822fb --- /dev/null +++ b/scripts/update_docs_tasks_reference.py @@ -0,0 +1,61 @@ +import argparse +import sys + +from .docs_tasks_reference import ( + DOCKERFILE_REFERENCE_FILE, + TASK_CATEGORY_FILES, + generate_task_reference_files, + replace_dockerfile_reference, + replace_task_reference, +) + + +def update_docs_references(check: bool = False) -> None: + """Update or verify generated task and Dockerfile-reference documentation. + + Args: + check: Verify files without modifying them. + """ + outdated_files = [] + + # Generate individual task reference files + generate_task_reference_files() + + for category, path in TASK_CATEGORY_FILES.items(): + current_text = path.read_text(encoding="utf-8") + updated_text = replace_task_reference(current_text, category) + if updated_text == current_text: + continue + if check: + outdated_files.append(path) + else: + path.write_text(updated_text, encoding="utf-8") + + current_text = DOCKERFILE_REFERENCE_FILE.read_text(encoding="utf-8") + updated_text = replace_dockerfile_reference(current_text) + if updated_text != current_text: + if check: + outdated_files.append(DOCKERFILE_REFERENCE_FILE) + else: + DOCKERFILE_REFERENCE_FILE.write_text(updated_text, encoding="utf-8") + + if outdated_files: + sys.exit( + "Generated documentation is outdated: " + + ", ".join(str(path) for path in outdated_files) + ) + + +def main() -> None: + """Parse arguments and update generated documentation.""" + parser = argparse.ArgumentParser(description="Update generated documentation") + parser.add_argument( + "--check", + action="store_true", + help="Fail if generated documentation is outdated.", + ) + update_docs_references(check=parser.parse_args().check) + + +if __name__ == "__main__": + main() diff --git a/scripts/update_readme_tasks_table.py b/scripts/update_readme_tasks_table.py deleted file mode 100644 index f2d9443..0000000 --- a/scripts/update_readme_tasks_table.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -import sys -from pathlib import Path - -from .readme_tasks_table import replace_tasks_table -from .utils import commit_readme_update, configure_logger, get_logger, log_dry_run - -LOGGER = get_logger(__name__) - - -def main() -> None: - """Update the README task table from the current task definitions.""" - configure_logger(LOGGER) - - readme_path = Path("README.md") - readme_text = readme_path.read_text(encoding="utf-8") - - if os.environ.get("RELEASE_SCRIPT_DRY_RUN") == "1": - updated_text = replace_tasks_table(readme_text) - if updated_text == readme_text: - log_dry_run(LOGGER, "README.md task table is already up to date") - else: - log_dry_run(LOGGER, "Would update README.md task table") - return - - updated_text = replace_tasks_table(readme_text) - if updated_text == readme_text: - sys.exit("README.md task table was not changed") - - readme_path.write_text(updated_text, encoding="utf-8") - commit_readme_update("chore(docs): refresh README task table") - - -if __name__ == "__main__": - main() diff --git a/src/common_python_tasks/tasks.py b/src/common_python_tasks/tasks.py index 81c1651..a82d1a2 100644 --- a/src/common_python_tasks/tasks.py +++ b/src/common_python_tasks/tasks.py @@ -406,6 +406,58 @@ def lint_all() -> None: run_command(_get_ruff_command("format", "--check", Path("."))) +@tasks.script(task_name="docs-build", tags=["docs"]) +def docs_build(config_file: str | None = None, clean: bool = False) -> None: + """Build the Zensical documentation site in strict mode. + + Args: + config_file: Optional path to a Zensical configuration file. + clean: Remove cached files before building the site. + """ + from .utils import require_package, run_command + + require_package("zensical") + run_command( + [ + "zensical", + "build", + "--strict", + "--clean" if clean else None, + "--config-file" if config_file else None, + config_file, + ] + ) + + +@tasks.script(task_name="docs-serve", tags=["docs"]) +def docs_serve( + config_file: str | None = None, + dev_addr: str | None = None, + open_browser: bool = False, +) -> None: + """Serve the Zensical documentation site for local preview. + + Args: + config_file: Optional path to a Zensical configuration file. + dev_addr: Optional development server address in `IP:PORT` form. + open_browser: Open the preview in the default browser. + """ + from .utils import require_package, run_command + + require_package("zensical") + run_command( + [ + "zensical", + "serve", + "--config-file" if config_file else None, + config_file, + "--dev-addr" if dev_addr else None, + dev_addr, + "--open" if open_browser else None, + ] + ) + + @tasks.script(tags=["containers", "build"]) def build_image( *docker_build_args: str, diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..80aa4ff --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,85 @@ +import tomllib +from pathlib import Path + + +def test_zensical_site_has_expected_pages(): + with Path("zensical.toml").open("rb") as config_file: + zensical_config = tomllib.load(config_file) + + assert zensical_config["project"]["site_name"] == "Common Python Tasks" + # nav is auto-generated from the file structure, so we don't check it explicitly + assert all( + Path("docs", page).is_file() + for page in [ + "index.md", + "getting-started.md", + "tasks/index.md", + "tasks/daily-development.md", + "tasks/documentation.md", + "tasks/packaging-and-releases.md", + "tasks/container-images.md", + "tasks/development-stacks.md", + "configuration.md", + "documentation-workflows.md", + ] + ) + # Verify reference directory and some individual task pages exist + assert Path("docs/tasks/reference").is_dir() + assert Path("docs/tasks/reference/test.md").is_file() + assert Path("docs/tasks/reference/build-image.md").is_file() + + +def test_reusable_docs_workflow_supports_artifacts_and_publishers(): + workflow = Path(".github/workflows/docs.yml").read_text(encoding="utf-8") + + assert "workflow_call:" in workflow + assert "generated_docs_check_task:" in workflow + assert "actions/upload-artifact@v7" in workflow + assert "if-no-files-found: error" in workflow + assert "cloudflare/wrangler-action@v4" in workflow + assert "--branch=pr-${{ github.event.pull_request.number }}" in workflow + assert "Ensure Cloudflare Pages project" in workflow + assert ( + "github.event.pull_request.head.repo.full_name == github.repository" in workflow + ) + assert "actions/upload-pages-artifact@v5" in workflow + assert "actions/deploy-pages@v5" in workflow + + +def test_repository_uses_reusable_docs_workflow(): + preview_workflow = Path(".github/workflows/docs-preview.yml").read_text( + encoding="utf-8" + ) + deploy_workflow = Path(".github/workflows/docs-deploy.yml").read_text( + encoding="utf-8" + ) + + assert "uses: ./.github/workflows/docs.yml" in preview_workflow + assert "publish_cloudflare:" in preview_workflow + assert ( + "cloudflare_project_name: ci-sourcerer-common-python-tasks" in preview_workflow + ) + assert ( + "cloudflare_preview_domain: common-python-tasks.ci-sourcerer.com" + in preview_workflow + ) + assert "cloudflare_preview_zone: ci-sourcerer.com" in preview_workflow + assert "generated_docs_check_task: check-docs-references" in preview_workflow + assert "uses: ./.github/workflows/docs.yml" in deploy_workflow + assert "deploy_github_pages: true" in deploy_workflow + assert "generated_docs_check_task: check-docs-references" in deploy_workflow + cleanup_workflow = Path(".github/workflows/docs-preview-cleanup.yml").read_text( + encoding="utf-8" + ) + assert "types:" in cleanup_workflow + assert "- closed" in cleanup_workflow + assert ( + "CLOUDFLARE_PREVIEW_BRANCH: pr-${{ github.event.pull_request.number }}" + in cleanup_workflow + ) + assert '"$project_url/deployments/$deployment_id?force=true"' in cleanup_workflow + assert ( + "CLOUDFLARE_PREVIEW_DOMAIN: common-python-tasks.ci-sourcerer.com" + in cleanup_workflow + ) + assert "CLOUDFLARE_PREVIEW_ZONE: ci-sourcerer.com" in cleanup_workflow diff --git a/tests/test_docs_tasks_reference.py b/tests/test_docs_tasks_reference.py new file mode 100644 index 0000000..5ce7404 --- /dev/null +++ b/tests/test_docs_tasks_reference.py @@ -0,0 +1,75 @@ +import pytest + +from scripts.docs_tasks_reference import ( + DOCKERFILE_REFERENCE_FILE, + TASK_CATEGORY_FILES, + _build_individual_task_page, + build_dockerfile_reference, + build_task_reference, + replace_dockerfile_reference, + replace_task_reference, +) + + +def test_generated_task_reference_is_current(): + for category, path in TASK_CATEGORY_FILES.items(): + current_text = path.read_text(encoding="utf-8") + + assert replace_task_reference(current_text, category) == current_text + + +def test_generated_dockerfile_reference_is_current(): + current_text = DOCKERFILE_REFERENCE_FILE.read_text(encoding="utf-8") + + assert replace_dockerfile_reference(current_text) == current_text + + +def test_generated_task_reference_includes_usage_and_arguments(): + reference = build_task_reference("documentation") + + # Category pages now show a link table instead of full task details + assert "[`docs-build`](reference/docs-build.md)" in reference + assert "[`docs-serve`](reference/docs-serve.md)" in reference + assert "Build the Zensical documentation site in strict mode." in reference + assert "Serve the Zensical documentation site for local preview." in reference + + +def test_replace_task_reference_requires_one_generated_block(): + with pytest.raises(ValueError, match="exactly one"): + replace_task_reference("# Documentation\n", "documentation") + + +def test_dockerfile_reference_includes_template_sources(): + reference = build_dockerfile_reference() + + assert "### Application image template" in reference + assert "### Dependency image template" in reference + assert "# syntax=docker/dockerfile:1" in reference + + +def test_replace_dockerfile_reference_requires_one_generated_block(): + with pytest.raises(ValueError, match="exactly one"): + replace_dockerfile_reference("# Container images\n") + + +def test_individual_task_page_includes_usage_and_arguments(): + task_page = _build_individual_task_page("docs-build") + + assert "# `poe docs-build`" in task_page + assert "**Category:** [`documentation`](../documentation.md)" in task_page + assert "**Tags:** `docs`" in task_page + assert "poe docs-build [--config-file CONFIG_FILE] [--clean]" in task_page + assert "| `--config-file` | `string` |" in task_page + assert "| `--clean` | `boolean` |" in task_page + + +def test_individual_task_page_without_arguments(): + task_page = _build_individual_task_page("format") + + assert "# `poe format`" in task_page + assert "**Category:** [`daily-development`](../daily-development.md)" in task_page + assert "**Tags:**" in task_page + assert "`format`" in task_page + assert "`common`" in task_page + # Should not have an Arguments section for tasks without args + assert "## Arguments" not in task_page diff --git a/tests/test_readme_tasks_table.py b/tests/test_readme_tasks_table.py deleted file mode 100644 index a42efd8..0000000 --- a/tests/test_readme_tasks_table.py +++ /dev/null @@ -1,132 +0,0 @@ -from pathlib import Path -from unittest.mock import patch - -from scripts import readme_tasks_table - -SCRIPT_PATH = ( - Path(__file__).resolve().parents[1] / "scripts" / "update_readme_tasks_table.py" -) - - -def test_build_tasks_table_groups_tasks_by_category(): - task_tags = { - "format": ["common", "format"], - "release": ["packaging", "release", "containers"], - "build-image": ["build", "containers"], - "stack-up": ["containers", "fastapi", "web"], - } - - with ( - patch.object( - readme_tasks_table, - "get_available_tasks", - return_value=list(task_tags), - ), - patch.object( - readme_tasks_table, - "_get_task_docstring", - side_effect=lambda task_name: f"Run {task_name}", - ), - patch.object(readme_tasks_table, "get_task_tags", side_effect=task_tags.get), - ): - table = readme_tasks_table.build_tasks_table() - - assert table.index("### Daily development") < table.index( - "### Packaging and releases" - ) - assert table.index("### Packaging and releases") < table.index( - "### Container images" - ) - assert table.index("### Container images") < table.index("### Development stacks") - assert table.index("| `release` |") < table.index("### Container images") - - -def test_replace_tasks_table_updates_existing_block(): - with ( - patch.object( - readme_tasks_table, "get_available_tasks", return_value=["format"] - ), - patch.object( - readme_tasks_table, "_get_task_docstring", return_value="Format code" - ), - patch.object( - readme_tasks_table, - "get_task_tags", - return_value=["common", "format"], - ), - ): - updated_text = readme_tasks_table.replace_tasks_table( - "Version: 1.2.3\n\nold\n\n" - ) - - assert "Version: 1.2.3" in updated_text - assert "### Daily development" in updated_text - assert "| `format` | Format code | common, format |" in updated_text - - -def test_update_readme_tasks_table_script_main_updates_table_and_commits( - tmp_path, monkeypatch -): - readme_path = tmp_path / "README.md" - readme_path.write_text( - "Version: 1.2.3\n\nold\n\n", - encoding="utf-8", - ) - - monkeypatch.chdir(tmp_path) - - from scripts import update_readme_tasks_table as script_module - - with ( - patch.object( - readme_tasks_table, "get_available_tasks", return_value=["format"] - ), - patch.object( - readme_tasks_table, "_get_task_docstring", return_value="Format code" - ), - patch.object( - readme_tasks_table, - "get_task_tags", - return_value=["common", "format"], - ), - patch.object(script_module, "commit_readme_update") as mock_commit, - ): - script_module.main() - - updated_text = readme_path.read_text(encoding="utf-8") - assert "### Daily development" in updated_text - assert "| `format` | Format code | common, format |" in updated_text - mock_commit.assert_called_once_with("chore(docs): refresh README task table") - - -def test_update_readme_tasks_table_script_dry_run_leaves_file_unchanged( - tmp_path, monkeypatch, capsys -): - readme_path = tmp_path / "README.md" - original_text = ( - "Version: 1.2.3\n\nold\n\n" - ) - readme_path.write_text(original_text, encoding="utf-8") - - monkeypatch.chdir(tmp_path) - monkeypatch.setenv("RELEASE_SCRIPT_DRY_RUN", "1") - - from scripts import update_readme_tasks_table as script_module - - with ( - patch.object( - readme_tasks_table, "get_available_tasks", return_value=["format"] - ), - patch.object( - readme_tasks_table, "_get_task_docstring", return_value="Format code" - ), - patch.object( - readme_tasks_table, - "get_task_tags", - return_value=["common", "format"], - ), - ): - script_module.main() - - assert readme_path.read_text(encoding="utf-8") == original_text - assert "Would update README.md task table" in capsys.readouterr().err diff --git a/tests/test_release_script.py b/tests/test_release_script.py index 3afbf63..e176b62 100644 --- a/tests/test_release_script.py +++ b/tests/test_release_script.py @@ -3,37 +3,20 @@ import pytest -from scripts import readme_tasks_table, release_script +from scripts import release_script SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "release_script.py" -README_PATH = SCRIPT_PATH.parents[1] / "README.md" -def test_main_pre_phase_replaces_latest_tagged_version_and_rebuilds_table( - tmp_path, monkeypatch -): +def test_main_pre_phase_replaces_latest_tagged_version(tmp_path, monkeypatch): readme_path = tmp_path / "README.md" - readme_path.write_text( - "Version: 1.2.2\n\nold\n\n", - encoding="utf-8", - ) + readme_path.write_text("Version: 1.2.2\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setenv("RELEASE_VERSION", "1.2.3") monkeypatch.setenv("RELEASE_SCRIPT_PHASE", "pre") with ( - patch.object( - readme_tasks_table, "get_available_tasks", return_value=["format"] - ), - patch.object( - readme_tasks_table, "_get_task_docstring", return_value="Format code" - ), - patch.object( - readme_tasks_table, - "get_task_tags", - return_value=["common", "format"], - ), patch.object( release_script.subprocess, "run", @@ -51,8 +34,6 @@ def test_main_pre_phase_replaces_latest_tagged_version_and_rebuilds_table( updated_text = readme_path.read_text(encoding="utf-8") assert "Version: 1.2.3" in updated_text - assert "### Daily development" in updated_text - assert "| `format` | Format code | common, format |" in updated_text mock_run.assert_called_once_with( ["git", "tag", "--sort=-version:refname"], capture_output=True, @@ -62,30 +43,11 @@ def test_main_pre_phase_replaces_latest_tagged_version_and_rebuilds_table( mock_commit.assert_called_once_with("chore(release): set README version 1.2.3") -def test_release_script_uses_shared_readme_tasks_table_renderer(): - assert release_script.replace_tasks_table is readme_tasks_table.replace_tasks_table - - -def test_readme_task_table_matches_generated_table(): - readme_text = README_PATH.read_text(encoding="utf-8") - table_start = readme_text.index("") - table_end = readme_text.index("", table_start) - - assert ( - readme_text[table_start : table_end + len("")] - == readme_tasks_table.build_tasks_table() - ) - - def test_main_fails_for_invalid_release_phase_without_running_git( tmp_path, monkeypatch ): readme_path = tmp_path / "README.md" - readme_path.write_text( - "Version: 1.2.3\n" - "\n| keep | this | table |\n\n", - encoding="utf-8", - ) + readme_path.write_text("Version: 1.2.3\n", encoding="utf-8") monkeypatch.chdir(tmp_path) monkeypatch.setenv("RELEASE_VERSION", "1.2.3") @@ -102,7 +64,6 @@ def test_main_fails_for_invalid_release_phase_without_running_git( updated_text = readme_path.read_text(encoding="utf-8") assert "Version: 1.2.3" in updated_text - assert "| keep | this | table |" in updated_text mock_run.assert_not_called() @@ -110,9 +71,7 @@ def test_main_dry_run_is_controlled_by_release_script_dry_run_env_pre_phase( tmp_path, monkeypatch, capsys ): readme_path = tmp_path / "README.md" - readme_text = ( - "Version: 8.8.8\n\nold\n\n" - ) + readme_text = "Version: 8.8.8\n" readme_path.write_text(readme_text, encoding="utf-8") monkeypatch.chdir(tmp_path) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 63c0520..25137e0 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -72,6 +72,77 @@ def test_lint_all_checks_linting_and_formatting(): ] +def test_docs_build_uses_strict_mode_and_optional_settings(): + from common_python_tasks.tasks import docs_build + + with ( + patch("common_python_tasks.utils.require_package") as mock_require_package, + patch("common_python_tasks.utils.run_command") as mock_run_command, + ): + docs_build(config_file="docs.toml", clean=True) + + mock_require_package.assert_called_once_with("zensical") + mock_run_command.assert_called_once_with( + [ + "zensical", + "build", + "--strict", + "--clean", + "--config-file", + "docs.toml", + ] + ) + + +def test_docs_build_uses_zensical_defaults(): + from common_python_tasks.tasks import docs_build + + with patch("common_python_tasks.utils.run_command") as mock_run_command: + docs_build() + + mock_run_command.assert_called_once_with( + ["zensical", "build", "--strict", None, None, None] + ) + + +def test_docs_serve_supports_preview_options(): + from common_python_tasks.tasks import docs_serve + + with ( + patch("common_python_tasks.utils.require_package") as mock_require_package, + patch("common_python_tasks.utils.run_command") as mock_run_command, + ): + docs_serve( + config_file="docs.toml", + dev_addr="127.0.0.1:9000", + open_browser=True, + ) + + mock_require_package.assert_called_once_with("zensical") + mock_run_command.assert_called_once_with( + [ + "zensical", + "serve", + "--config-file", + "docs.toml", + "--dev-addr", + "127.0.0.1:9000", + "--open", + ] + ) + + +def test_docs_serve_uses_zensical_defaults(): + from common_python_tasks.tasks import docs_serve + + with patch("common_python_tasks.utils.run_command") as mock_run_command: + docs_serve() + + mock_run_command.assert_called_once_with( + ["zensical", "serve", None, None, None, None, None] + ) + + def test_format_all_fails_when_ruff_is_not_installed(mock_find_spec): from common_python_tasks.tasks import format_all from common_python_tasks.utils import is_package_installed @@ -414,6 +485,7 @@ def test_public_tasks_defaults_to_common_profile(): assert "format" in task_map assert "build-image" not in task_map assert "stack-up" not in task_map + assert "docs-build" not in task_map assert task_map["release"]["script"].endswith(":release_without_containers") @@ -425,6 +497,18 @@ def test_public_tasks_supports_explicit_empty_include_for_all_tasks(): assert "build-image" in task_map assert "stack-up" in task_map + assert "docs-build" in task_map + + +def test_public_tasks_supports_docs_profile(): + import common_python_tasks + + common_python_tasks = importlib.reload(common_python_tasks) + + assert set(common_python_tasks.tasks(include_tags=["docs"])["tasks"]) == { + "docs-build", + "docs-serve", + } def test_task_decorator_does_not_log_top_level_task(caplog): diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..f1a02f2 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,35 @@ +[project] +site_name = "Common Python Tasks" +site_description = "Opinionated Poe the Poet tasks for Python package development" +site_url = "https://ci-sourcerer.github.io/common-python-tasks/" +repo_name = "ci-sourcerer/common-python-tasks" +repo_url = "https://github.com/ci-sourcerer/common-python-tasks" + +[project.theme] +features = [ + "navigation.sections", + "navigation.expand", + "navigation.top", + "content.code.copy", + "toc.follow", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +primary = "blue grey" +accent = "cyan" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +primary = "blue grey" +accent = "cyan" + +[project.markdown_extensions] +admonition = {} +attr_list = {} +md_in_html = {} +pymdownx.highlight = {} +pymdownx.inlinehilite = {} +pymdownx.superfences = {}