Skip to content
Draft
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
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"python-envs.pythonProjects": [
{
"path": ".",
"packageManager": "ms-python.python:pip",
"envManager": "ms-python.python:venv"
"packageManager": "ms-python.python:poetry",
"envManager": "ms-python.python:poetry"
}
],
"files.exclude": {
Expand Down
50 changes: 24 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Common Python tasks

This package is a collection of (very) opinionated [Poe the Poet Python tasks](https://poethepoet.natn.io/guides/packaged_tasks.html) for common Python development workflows.
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.

## Quick start

Expand All @@ -27,9 +27,9 @@ This will complete the following steps.
```toml
[project]
name = "my-awesome-project"
version = "0.0.1"
version = "0.0.2"
dependencies = [
"common-python-tasks==0.0.1", # Always pin to a specific version
"common-python-tasks==0.0.2", # Always pin to a specific version
]

[tool.poe]
Expand All @@ -54,19 +54,24 @@ This will complete the following steps.

Internal tasks are used by other tasks and are not meant to be run directly.

<!-- tasks-table -->
| Task | Description | Tags |
| - | - | - |
| `build` | Build the project; also builds container images when the `containers` tag is included | packaging, containers |
| `build-image` | Build a container image using the bundled Containerfile template | containers, build |
| `build` | Build the project and its containers (when `containers` tag is included) | packaging, containers |
| `build-image` | Build the container image for this project using the Containerfile template | containers, build |
| `build-package` | Build the package (wheel and sdist) | packaging, build |
| `bump-version` | Bump project version and create a git tag | packaging |
| `clean` | Remove build, cache, and coverage artifacts | clean |
| `bump-version` | Bump the project version | packaging |
| `clean` | Clean up temporary files and directories | clean |
| `container-shell` | Run the debug image with an interactive shell | containers, debug |
| `format` | Format code with autoflake, black, and isort | format |
| `lint` | Run autoflake, black, isort checks, and flake8 linting | lint |
| `publish-package` | Publish the package to PyPI via Poetry | packaging |
| `push-image` | Push container images to the configured registry | containers, packaging, release |
| `run-container` | Run the built container image with the selected tag | containers |
| `test` | Run tests with pytest and generate coverage reports | test |
| `lint` | Lint Python code with autoflake, black, isort, and flake8 | lint |
| `publish-package` | Publish the package to the PyPI server | packaging |
| `push-image` | Push the Docker image to the container registry | containers, packaging, release |
| `run-container` | Run the Docker image as a container | containers |
| `stack-down` | Bring down the development stack for the application | web |
| `stack-up` | Bring up the development stack for the application | web, containers |
| `test` | Run the test suite with coverage | test |
<!-- end-tasks-table -->

## How it works

Expand All @@ -76,7 +81,7 @@ Your project must meet the following requirements.

- Use Poetry for dependency management
- Have a `pyproject.toml` file at the root
- Have a package name (automatically inferred from `project.name` in `pyproject.toml`, or set via `PACKAGE_NAME` environment variable)
- Have a package name (automatically inferred from `project.name` in `pyproject.toml` or set via `PACKAGE_NAME` environment variable)

### Configuration precedence

Expand Down Expand Up @@ -198,13 +203,6 @@ git push --tags

## Troubleshooting

### "No tests were collected"

The `test` task exits with code 5 if no tests are found. You can address this in one of the following ways.

- Add tests to your `tests/` directory
- Exclude the `test` tag and simply do not run `poe test` with this configuration `include_script = "common_python_tasks:tasks(exclude_tags=['test', 'internal'])"`

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

Check your `[tool.poe]` configuration in `pyproject.toml`. Make sure you're using `include_script`, not `includes`.
Expand All @@ -224,7 +222,7 @@ includes = "common_python_tasks:tasks"
This is expected behavior. The `bump-version` task requires commits between the last tag and HEAD. You can resolve this in one of the following ways.

- Make changes and commit them first
- If you need to re-tag the same commit, delete the old tag (for example, `git tag -d v0.0.1`). This is not recommended. Versions should be immutable, and if you need to fix something, you should create a new patch version instead
- Delete the old tag (for example, `git tag -d v0.0.1`). This is not recommended. Versions should be immutable, and if you need to fix something, you should create a new patch version instead. Rarely do you want to pass off new code as an old version

### Config files not being used

Expand All @@ -239,24 +237,24 @@ COMMON_PYTHON_TASKS_LOG_LEVEL=DEBUG poe test
Make sure your `pyproject.toml` contains the following.

- A correct package name in `[project]`
- A package location defined with this configuration `[tool.poetry] packages = [{ include = "your_package", from = "src" }]`
- A package location defined with this configuration: `[tool.poetry] packages = [{ include = "your_package", from = "src" }]`

## Design choices

### Containerfile (see [src/common_python_tasks/data/Containerfile](src/common_python_tasks/data/Containerfile))

The standard Python Containerfile incorporates several intentional design choices.

- Multi-stage build: the build stage installs Poetry and builds a wheel while the runtime stage installs only the wheel to keep the final image slim and reproducible
- Cache-aware installs mean pip and Poetry cache mounts speed up iterative builds without bloating the final image
- Multi-stage build: The build stage installs Poetry and builds a wheel while the runtime stage installs only the wheel to keep the final image slim and reproducible
- Pip and Poetry cache mounts speed up iterative builds without bloating the final image
- Explicit inputs through build args (`PYTHON_VERSION`, `POETRY_VERSION`, `PACKAGE_NAME`, `AUTHORS`, `GIT_COMMIT`, `CUSTOM_ENTRYPOINT`) make image metadata and behavior predictable and auditable
- Optional debug stage exports and installs the `debug` dependency group only when present without failing otherwise and is not part of the default final image
- Stable package path creates symlinks to the installed package so entrypoints and consumers have a consistent `/pkg` and `/_$PACKAGE_NAME` path regardless of wheel layout, which ensures that the package can be reliably imported and executed from a known location, and allows for the less common use case of reading files directly from the package path
- Safe entrypoint selection means the default entrypoint resolves the console script matching the package name while `CUSTOM_ENTRYPOINT` allows overriding at build time while keeping runtime behavior predictable
- Minimal final image uses the slim Python base, cleans wheel artifacts and caches, and sets `runtime` as the explicit final target so the debug stage is opt-in
- Minimal final image uses the slim Python base by default, cleans wheel artifacts and caches, and sets `runtime` as the explicit final target so the debug stage is opt-in

## Notes

- This project dogfoods itself - it uses `common-python-tasks` for its own development
- Contributions welcome! Open an issue/discussion to discuss changes before submitting a PR. I do not claim to have all the answers, and you can help determine the future of low-code solutions for Python. I am very interested in your feedback as I don't want to work in a vacuum
- Alpha status: expect breaking changes between minor versions until 1.0.0
- Alpha status: Expect breaking changes between minor versions until 1.0.0
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"pytest-cov (>=7.0.0,<8.0.0)",
"pytest (>=9.0.1,<10.0.0)",
"tomlkit (>=0.13.3,<0.14.0)",
"jinja2 (>=3.1.6,<4.0.0)",
]
dynamic = ["version"]

Expand All @@ -36,7 +37,7 @@ 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=['containers'])"
include_script = "common_python_tasks:tasks()"

[tool.poetry.requires-plugins]
poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] }
Expand Down
2 changes: 1 addition & 1 deletion src/common_python_tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

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

return tasks(include_tags=include_tags, exclude_tags=exclude_tags)
8 changes: 8 additions & 0 deletions src/common_python_tasks/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import sys

if __name__ == "__main__":
print(
"common_python_tasks is not intended to be run as a standalone script. Invoke a task via poethepoet.",
file=sys.stderr,
)
sys.exit(1)
Empty file.
38 changes: 38 additions & 0 deletions src/common_python_tasks/data/fastapi/compose-base.yml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: ${PACKAGE_NAME}

x-common-environment: &common-environment
API_HOST: ${PACKAGE_NAME}-api
API_PORT: ${API_PORT}
SECRET_KEY: ${SECRET_KEY}
ENVIRONMENT: ${ENVIRONMENT}

x-common-build-args: &common-build-args
PACKAGE_NAME: ${PACKAGE_NAME}
PYTHON_VERSION: ${PYTHON_VERSION}
POETRY_VERSION: ${POETRY_VERSION}

services:
api:
user: "1000"
hostname: ${PACKAGE_NAME}-api
environment:
SERVER_HOST: 0.0.0.0
<<: *common-environment
build:
context: .
dockerfile: Containerfile
args: *common-build-args
target: runtime
image: ${PACKAGE_NAME}:${IMAGE_TAG}
networks:
- default
ports:
- mode: ingress
target: 8000
published: ${API_PORT}
protocol: tcp
restart: always

networks:
default:
name: ${PACKAGE_NAME}_default
45 changes: 45 additions & 0 deletions src/common_python_tasks/data/fastapi/compose-db-debug.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: ${PACKAGE_NAME}

services:
migrator:
image: ${PACKAGE_NAME}:debug
adminer:
image: adminer:latest
environment:
ADMINER_DEFAULT_DRIVER: pgsql
ADMINER_DEFAULT_SERVER: ${PACKAGE_NAME}-db
ADMINER_DEFAULT_DB: ${DB_BASE}
ADMINER_DEFAULT_USERNAME: ${DB_USER}
ADMINER_DEFAULT_PASSWORD: ${DB_PASS}
networks:
- default
ports:
- mode: ingress
target: 8080
published: ${ADMINER_PORT}
protocol: tcp
depends_on:
db:
condition: service_healthy
configs:
- source: adminer-index.php
target: /var/www/html/index.php
uid: "100"
gid: "101"

# This makes adminer open directly to an already filled-in login form
configs:
adminer-index.php:
content: |
<?php
if(!count($$_GET)) {
$$_POST['auth'] = [
'server' => $$_ENV['ADMINER_DEFAULT_SERVER'],
'username' => $$_ENV['ADMINER_DEFAULT_USERNAME'],
'password' => $$_ENV['ADMINER_DEFAULT_PASSWORD'],
'driver' => $$_ENV['ADMINER_DEFAULT_DRIVER'],
'db' => $$_ENV['ADMINER_DEFAULT_DB'],
];
}
include './adminer.php';
?>
79 changes: 79 additions & 0 deletions src/common_python_tasks/data/fastapi/compose-db.yml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
x-common-environment: &common-environment
DB_BASE: ${DB_BASE}
DB_HOST: ${PACKAGE_NAME}-db
DB_PORT: ${DB_PORT}
DB_PASS: ${DB_PASS}
DB_USER: ${DB_USER}

x-common-build-args: &common-build-args
PACKAGE_NAME: ${PACKAGE_NAME}
PYTHON_VERSION: ${PYTHON_VERSION}
POETRY_VERSION: ${POETRY_VERSION}

services:
api:
depends_on:
db:
condition: service_healthy
environment:
<<: *common-environment

db:
hostname: ${PACKAGE_NAME}-db
environment:
POSTGRES_DB: ${DB_BASE}
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_USER: ${DB_USER}
healthcheck:
test:
- CMD-SHELL
- pg_isready -U $$POSTGRES_USER
timeout: 3s
interval: 2s
retries: 40
image: postgres:${POSTGRES_VERSION}-trixie
networks:
- default
ports:
- mode: ingress
target: 5432
published: ${DB_PORT}
protocol: tcp
restart: always
volumes:
- type: volume
source: {{ PACKAGE_NAME }}-db-data
target: /var/lib/postgresql/data
volume: {}

migrator:
user: "1000"
entrypoint:
- alembic
command:
- upgrade
- head
depends_on:
db:
condition: service_healthy
environment:
<<: *common-environment
build:
context: .
dockerfile: Containerfile
args: *common-build-args
image: ${PACKAGE_NAME}:${IMAGE_TAG}
networks:
- default
restart: "no"
configs:
- source: alembic_config
target: ./alembic.ini

volumes:
{{ PACKAGE_NAME }}-db-data:
name: ${PACKAGE_NAME}-db-data

configs:
alembic_config:
file: ./alembic.ini
30 changes: 30 additions & 0 deletions src/common_python_tasks/data/fastapi/compose-debug.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: ${PACKAGE_NAME}

services:
api:
entrypoint: python
command:
- -Xfrozen_modules=off
- -m
- debugpy
- --listen
- 0.0.0.0:5678
- -m
- ${PACKAGE_UNDERSCORE_NAME}
environment:
ENVIRONMENT: dev
build:
target: debug
image: ${PACKAGE_NAME}:${IMAGE_TAG}
develop:
watch:
- action: sync+restart
path: ./src/${PACKAGE_NAME}
target: /${PACKAGE_NAME}
- action: rebuild
path: poetry.lock
ports:
- mode: ingress
target: 5678
published: ${DEBUG_PORT:-5678}
protocol: tcp
Loading