Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 39 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,49 @@

This package is a collection of (very) opinionated [Poe the Poet](https://poethepoet.natn.io/guides/packaged_tasks.html) Python tasks for common Python development workflows.

Instead of writing your own tasks for formatting, linting, testing, packaging, and more, you can use these pre-built tasks that work out of the box with reasonable defaults and support configuration overrides when needed. In the past, I found myself copying and pasting the same task definitions across projects, and this package is my attempt to DRY up that workflow and provide a single source of truth for common Python development tasks. I hope you can use them too.

## Quick start

### Automated setup

You can add `common-python-tasks` to a new project by using the handy automated installation script.

```shell
curl -sSL https://api.github.com/repos/ci-sourcerer/common-python-tasks/contents/scripts/add-common-python-tasks.sh | jq -r '.content' | base64 -d | TAGS_TO_INCLUDE="format lint test" sh
curl -sSL https://api.github.com/repos/ci-sourcerer/common-python-tasks/contents/scripts/add-common-python-tasks.sh | jq -r '.content' | base64 -d | sh
```

To install a specific release, set the environment variable `COMMON_PYTHON_TASKS_VERSION`.

```shell
COMMON_PYTHON_TASKS_VERSION=__RELEASE_VERSION__ \
sh -c "$(curl -sSL https://api.github.com/repos/ci-sourcerer/common-python-tasks/contents/scripts/add-common-python-tasks.sh | jq -r '.content' | base64 -d)"
```

This will complete the following steps.

1. Add the latest version of `common-python-tasks` to your `pyproject.toml` dependencies
2. Configure Poe the Poet to include only the tasks with the specified tags
2. Configure Poe the Poet to expose the default common task set
3. Install the package using Poetry

**Always review scripts before running them!** Even though I believe I write good software, it's best practice to verify any script you download from the Internet.

### Manual setup

1. Add `common-python-tasks` to your `pyproject.toml` and configure Poe the Poet to include the desired tasks
There's no real reason to run the automated script; I just like automating everything. You can achieve the same result by following these steps.

1. Add `common-python-tasks` to your `pyproject.toml` and configure Poe the Poet

```toml
[project]
name = "my-awesome-project"
version = "0.0.2"
version = "__RELEASE_VERSION__"
dependencies = [
"common-python-tasks==0.0.2", # Always pin to a specific version
"common-python-tasks==__RELEASE_VERSION__", # Always pin to a specific version
]

[tool.poe]
include_script = "common_python_tasks:tasks(include_tags=['format', 'lint', 'test'])" # Include or exclude tasks by tags
include_script = "common_python_tasks:tasks()" # Uses the default `common` task set
```

2. Install the package
Expand All @@ -57,26 +68,26 @@ Internal tasks are used by other tasks and are not meant to be run directly.
<!-- tasks-table -->
| Task | Description | Tags |
| - | - | - |
| `build` | Build the project and its containers (when `containers` tag is included) | packaging, containers |
| `build` | Build the project and its containers (when `containers` tag is included) | common, containers, packaging |
| `build-image` | Build the container image for this project using the Dockerfile template (and configured extensions) | containers, build |
| `build-package` | Build the package (wheel and sdist) | packaging, build |
| `bump-version` | Bump the project version, defaulting to an inferred semantic bump from git history | packaging |
| `changelog` | Print the changelog for the current version based on git history | packaging, release |
| `clean` | Clean up temporary files and directories | clean |
| `build-package` | Build the package (wheel and sdist) | build, common, packaging |
| `bump-version` | Bump the project version, defaulting to an inferred semantic bump from git history | common, packaging |
| `changelog` | Print the changelog for the current version based on git history | common, packaging, release |
| `clean` | Clean up temporary files and directories | clean, common |
| `container-shell` | Run the debug image with an interactive shell | containers, debug |
| `db-shell` | Open a psql shell to the database container | web, containers, database |
| `format` | Format code with autoflake, black, and isort | format |
| `lint` | Lint Python code with autoflake, black, isort, and flake8 | lint |
| `publish-package` | Publish the package to the PyPI server | packaging |
| `publish-github-release` | Publish or update a GitHub Release and attach built distribution assets | packaging, release |
| `format` | Format code with autoflake, black, and isort | common, format |
| `lint` | Lint Python code with autoflake, black, isort, and flake8 | common, lint |
| `publish-package` | Publish the package to the PyPI server | common, packaging |
| `publish-github-release` | Publish or update a GitHub Release and attach built distribution assets | common, packaging, release |
| `push-image` | Push the Docker image to the container registry | containers, packaging, release |
| `release` | Run package release flow and publish containers when `containers` tag is included. Supports optional `RELEASE_PRE_SCRIPT` and `RELEASE_POST_SCRIPT` hooks. | packaging, release |
| `release` | Run a full release flow for package and containers. | common, containers, packaging, release |
| `reset-db` | Reset the database by deleting the database volume | web, containers, database |
| `run-container` | Run the Docker image as a container | containers |
| `run-db-migrations` | Run database migrations | web, containers, database |
| `stack-down` | Bring down the development stack for the application | web, containers |
| `stack-up` | Bring up the development stack for the application | web, containers |
| `test` | Run the test suite with coverage | test |
| `test` | Run the test suite with coverage | common, test |
<!-- end-tasks-table -->

## Docker Compose Development Stacks
Expand Down Expand Up @@ -235,32 +246,32 @@ The following environment variable enables debugging output.

### Usage examples

You can include or exclude tasks by tags in your `pyproject.toml`
By default, `tasks()` exposes the common task set. You can still include or exclude tags in your `pyproject.toml` when needed.

#### Minimal setup

```toml
[project]
name = "simple-cli-tool"
version = "0.0.1"
dependencies = ["common-python-tasks==0.0.1"]
version = "__RELEASE_VERSION__"
dependencies = ["common-python-tasks==__RELEASE_VERSION__"]

[tool.poe]
include_script = "common_python_tasks:tasks(include_tags=['format', 'lint'])"
include_script = "common_python_tasks:tasks()"
```

Available tasks: `format`, `lint`.
Available tasks: common defaults such as `format`, `lint`, `test`, and `build`.

#### Container-based project

```toml
[project]
name = "containerized-app"
version = "0.0.1"
dependencies = ["common-python-tasks==0.0.1"]
version = "__RELEASE_VERSION__"
dependencies = ["common-python-tasks==__RELEASE_VERSION__"]

[tool.poe]
include_script = "common_python_tasks:tasks(include_tags=['format', 'lint', 'test', 'containers'])"
include_script = "common_python_tasks:tasks(include_tags=['common', 'containers'])"

[tool.poe.env]
DOCKERHUB_USERNAME = "myusername"
Expand All @@ -274,11 +285,11 @@ Available tasks: All tasks including `build-image` and `push-image`.
```toml
[project]
name = "custom-test-setup"
dependencies = ["common-python-tasks==0.0.1"]
dependencies = ["common-python-tasks==__RELEASE_VERSION__"]
dynamic = ["version"]

[tool.poe]
include_script = "common_python_tasks:tasks(include_tags=['test'])"
include_script = "common_python_tasks:tasks()"

[tool.pytest.ini_options]
testpaths = ["tests", "integration"]
Expand All @@ -291,7 +302,7 @@ The `test` task will automatically use your `[tool.pytest.ini_options]` configur

### Tasks not showing up with `poe --help`

Check your `[tool.poe]` configuration in `pyproject.toml`. Make sure you're using `include_script`, not `includes`.
Check your `[tool.poe]` configuration in `pyproject.toml`. Make sure you're using `include_script`.

```toml
# Correct
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ classifiers = [
]
dependencies = [
"autoflake (>=2.3.1,<3.0.0)",
"black (>=25.11.0,<26.0.0)",
"black (>=26.3.1,<27.0.0)",
"dunamai (>=1.25.0,<2.0.0)",
"flake8 (>=7.3.0,<8.0.0)",
"isort (>=7.0.0,<8.0.0)",
"isort (>=8.0.1,<9.0.0)",
"poethepoet-tasks (>=0.3.0,<0.4.0)",
"pytest-cov (>=7.0.0,<8.0.0)",
"pytest (>=9.0.1,<10.0.0)",
Expand All @@ -37,7 +37,11 @@ Source = "http://github.com/ci-sourcerer/common-python-tasks.git"
Issues = "http://github.com/ci-sourcerer/common-python-tasks/issues"

[tool.poe]
include_script = "common_python_tasks:tasks(exclude_tags=['fastapi', 'containers'])"
include_script = "common_python_tasks:tasks()"

[tool.poe.env]
RELEASE_PRE_SCRIPT = "python scripts/release_script.py pre"
RELEASE_POST_SCRIPT = "python scripts/release_script.py post"

[tool.poetry.requires-plugins]
poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
filterwarnings =
error::DeprecationWarning
ignore:unclosed database in <sqlite3.Connection object at:ResourceWarning
pythonpath = src
14 changes: 9 additions & 5 deletions scripts/add-common-python-tasks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
set -e

pkg="common-python-tasks"
searchOutput=$(poetry search "$pkg")
if [ "$searchOutput" = "No matching packages were found." ]; then
printf 'Package %s not found\n' "$pkg" >&2
exit 1
if [ -n "$COMMON_PYTHON_TASKS_VERSION" ]; then
ver="$COMMON_PYTHON_TASKS_VERSION"
else
searchOutput=$(poetry search "$pkg")
if [ "$searchOutput" = "No matching packages were found." ]; then
printf 'Package %s not found\n' "$pkg" >&2
exit 1
fi
ver=$(printf "%s" "$searchOutput" | awk -v p="$pkg" '$1==p{print $2}' | tail -n1)
fi
ver=$(printf "%s" "$searchOutput" | awk -v p="$pkg" '$1==p{print $2}' | tail -n1)
if [ -n "$ver" ]; then
poetry add --group dev "$pkg==$ver" || exit 1
else
Expand Down
122 changes: 122 additions & 0 deletions scripts/release_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
import argparse
import os
import re
import subprocess
from enum import StrEnum, auto
from pathlib import Path

from common_python_tasks.__main__ import (
_get_task_docstring,
_get_task_tags,
get_available_tasks,
)

README_VERSION_PLACEHOLDER = "__RELEASE_VERSION__"
TASKS_TABLE_PATTERN = r"(?ms)<!-- tasks-table -->.*?<!-- end-tasks-table -->"


class ReleasePhase(StrEnum):
PRE = auto()
POST = auto()


def build_tasks_table() -> str:
"""Build the markdown task table for README insertion."""
lines = [
"<!-- tasks-table -->",
"| Task | Description | Tags |",
"| - | - | - |",
]

for task_name in get_available_tasks(internal=False):
doc = _get_task_docstring(task_name) or ""
description = " ".join(doc.splitlines()).strip()
tags = _get_task_tags(task_name) or []
lines.append(f"| `{task_name}` | {description} | {', '.join(tags)} |")

lines.append("<!-- end-tasks-table -->")
return "\n".join(lines)


def _update_readme_for_pre_release(readme_text: str, release_version: str) -> str:
if README_VERSION_PLACEHOLDER not in readme_text:
raise SystemExit(
"README.md is missing the release placeholder "
f"{README_VERSION_PLACEHOLDER!r}"
)

return re.sub(
TASKS_TABLE_PATTERN,
build_tasks_table(),
readme_text.replace(README_VERSION_PLACEHOLDER, release_version),
)


def _update_readme_for_post_release(readme_text: str, release_version: str) -> str:
if release_version not in readme_text:
raise SystemExit(
"README.md does not contain the release version "
f"{release_version!r} to reset"
)

return readme_text.replace(release_version, README_VERSION_PLACEHOLDER)


def _commit_readme_update(phase: str, release_version: str) -> None:
commit_message = (
f"chore(release): set README version {release_version}"
if phase == ReleasePhase.PRE
else "chore(release): reset README version placeholder"
)

subprocess.run(["git", "add", "README.md"], check=True)
subprocess.run(["git", "commit", "-m", commit_message], check=True)


def main(*, dry_run: bool = False, phase: ReleasePhase) -> None:
"""Update README placeholder content during pre/post release hooks.

Args:
dry_run: Print transformed README output instead of writing/committing.
phase: Release phase to determine transformation logic.

Raises:
SystemExit: If required placeholders are missing or if no changes were made.
"""
release_version = os.environ["RELEASE_VERSION"]

readme_path = Path("README.md")
readme_text = readme_path.read_text(encoding="utf-8")

updated_text = (
_update_readme_for_pre_release(readme_text, release_version)
if phase == ReleasePhase.PRE
else _update_readme_for_post_release(readme_text, release_version)
)

if updated_text == readme_text:
raise SystemExit("README.md was not changed")

if dry_run:
print(updated_text)
return

readme_path.write_text(updated_text, encoding="utf-8")
_commit_readme_update(phase, release_version)


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Prepare README for release hooks.")
parser.add_argument(
"phase",
choices=[ReleasePhase.PRE.name.lower(), ReleasePhase.POST.name.lower()],
help="Release phase: 'pre' or 'post'.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the updated README content instead of writing it.",
)
args = parser.parse_args()
main(dry_run=args.dry_run, phase=ReleasePhase(args.phase))
17 changes: 14 additions & 3 deletions src/common_python_tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@


def tasks(
include_tags: "Sequence[str]" = tuple(), exclude_tags: "Sequence[str]" = tuple()
include_tags: "Sequence[str] | None" = None,
exclude_tags: "Sequence[str]" = tuple(),
) -> dict:
from .tasks import tasks
from .tasks import tasks as task_collection

return tasks(include_tags=include_tags, exclude_tags=exclude_tags)
if include_tags is None and not exclude_tags:
include_tags = ("common",)
elif include_tags is None:
include_tags = tuple()

result = task_collection(include_tags=include_tags, exclude_tags=exclude_tags)
globals()["tasks"] = _TASKS_ENTRYPOINT
return result


_TASKS_ENTRYPOINT = tasks
Loading