From 7d510a73174303df365807c79a9f085edf06be18 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Fri, 11 Jul 2025 10:33:35 -0300 Subject: [PATCH 01/19] copier --- .copier/.coper-answers.yml.jinja | 1 + .copier/update_dotenv.py | 26 ++++++++++++++++++++++++++ copier.yml | 25 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .copier/.coper-answers.yml.jinja create mode 100644 .copier/update_dotenv.py create mode 100644 copier.yml diff --git a/.copier/.coper-answers.yml.jinja b/.copier/.coper-answers.yml.jinja new file mode 100644 index 0000000..0028a23 --- /dev/null +++ b/.copier/.coper-answers.yml.jinja @@ -0,0 +1 @@ +{{ _copier_answers|to_json -}} diff --git a/.copier/update_dotenv.py b/.copier/update_dotenv.py new file mode 100644 index 0000000..35aa854 --- /dev/null +++ b/.copier/update_dotenv.py @@ -0,0 +1,26 @@ +import json +from pathlib import Path + +# Update the .env file with the answers from the .copier-answers.yml file +# without using Jinja2 templates in the .env file, this way the code works as is +# without needing Copier, but if Copier is used, the .env file will be updated +root_path = Path(__file__).parent.parent +answers_path = Path(__file__).parent / ".copier-answers.yml" +answers = json.loads(answers_path.read_text()) +env_path = root_path / ".env" +env_content = env_path.read_text() +lines = [] +for line in env_content.splitlines(): + for key, value in answers.items(): + upper_key = key.upper() + if line.startswith(f"{upper_key}="): + if " " in value: + content = f"{upper_key}={value!r}" + else: + content = f"{upper_key}={value}" + new_line = line.replace(line, content) + lines.append(new_line) + break + else: + lines.append(line) +env_path.write_text("\n".join(lines)) diff --git a/copier.yml b/copier.yml new file mode 100644 index 0000000..1cb2de4 --- /dev/null +++ b/copier.yml @@ -0,0 +1,25 @@ +project_name: + type: str + help: The name of the project, shown to API users (in .env) + default: python-template + +_exclude: +# Global + - .github + - .vscode + - .mypy_cache + # Python + - __pycache__ + - .mypy_cache + - .cache + - .docs + - .devcontainer + - .venv + - .pyproject.toml + - README.md + + + +_answers_file: .copier/.copier-answers.yml +_tasks: + - ["{{ _copier_python }}", .copier/update_dotenv.py] From 56787d15b950d50877781d6720db59c2676d7a08 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Fri, 11 Jul 2025 10:36:28 -0300 Subject: [PATCH 02/19] fix --- copier.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/copier.yml b/copier.yml index 1cb2de4..244babc 100644 --- a/copier.yml +++ b/copier.yml @@ -17,9 +17,11 @@ _exclude: - .venv - .pyproject.toml - README.md + - .copier-answers.yml _answers_file: .copier/.copier-answers.yml + _tasks: - ["{{ _copier_python }}", .copier/update_dotenv.py] From 579ed5d87db6e069b792be86d122a37a11f4f29d Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Fri, 11 Jul 2025 11:47:14 -0300 Subject: [PATCH 03/19] fixes --- ...r-answers.yml.jinja => .copier-answers.yml.jinja} | 0 .copier/update_dotenv.py | 12 ++++++++---- copier.yml | 6 ++---- 3 files changed, 10 insertions(+), 8 deletions(-) rename .copier/{.coper-answers.yml.jinja => .copier-answers.yml.jinja} (100%) diff --git a/.copier/.coper-answers.yml.jinja b/.copier/.copier-answers.yml.jinja similarity index 100% rename from .copier/.coper-answers.yml.jinja rename to .copier/.copier-answers.yml.jinja diff --git a/.copier/update_dotenv.py b/.copier/update_dotenv.py index 35aa854..b827f32 100644 --- a/.copier/update_dotenv.py +++ b/.copier/update_dotenv.py @@ -1,5 +1,5 @@ -import json from pathlib import Path +import json # Update the .env file with the answers from the .copier-answers.yml file # without using Jinja2 templates in the .env file, this way the code works as is @@ -7,8 +7,8 @@ root_path = Path(__file__).parent.parent answers_path = Path(__file__).parent / ".copier-answers.yml" answers = json.loads(answers_path.read_text()) -env_path = root_path / ".env" -env_content = env_path.read_text() +env_example_path = root_path / ".env.example" +env_content = env_example_path.read_text() lines = [] for line in env_content.splitlines(): for key, value in answers.items(): @@ -23,4 +23,8 @@ break else: lines.append(line) -env_path.write_text("\n".join(lines)) +env_example_path.write_text("\n".join(lines)) + +# Also create a .env file with the same content as the .env.example file +env_file_path = root_path / ".env" +env_file_path.write_text(env_example_path.read_text()) \ No newline at end of file diff --git a/copier.yml b/copier.yml index 244babc..d1536c3 100644 --- a/copier.yml +++ b/copier.yml @@ -1,6 +1,6 @@ project_name: type: str - help: The name of the project, shown to API users (in .env) + help: The name of the project default: python-template _exclude: @@ -17,11 +17,9 @@ _exclude: - .venv - .pyproject.toml - README.md - - .copier-answers.yml - _answers_file: .copier/.copier-answers.yml _tasks: - - ["{{ _copier_python }}", .copier/update_dotenv.py] + - ["{{ _copier_python }}", .copier/update_dotenv.py] \ No newline at end of file From 75f7fcfc8124bf6839c7ab925b26f4891bc21250 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Fri, 11 Jul 2025 13:34:45 -0300 Subject: [PATCH 04/19] remove some files from exclude --- .copier/update_dotenv.py | 4 +--- copier.yml | 9 +-------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.copier/update_dotenv.py b/.copier/update_dotenv.py index b827f32..ca24ba0 100644 --- a/.copier/update_dotenv.py +++ b/.copier/update_dotenv.py @@ -2,8 +2,6 @@ import json # Update the .env file with the answers from the .copier-answers.yml file -# without using Jinja2 templates in the .env file, this way the code works as is -# without needing Copier, but if Copier is used, the .env file will be updated root_path = Path(__file__).parent.parent answers_path = Path(__file__).parent / ".copier-answers.yml" answers = json.loads(answers_path.read_text()) @@ -25,6 +23,6 @@ lines.append(line) env_example_path.write_text("\n".join(lines)) -# Also create a .env file with the same content as the .env.example file +# this creates a .env file with the same content as the .env.example file. env_file_path = root_path / ".env" env_file_path.write_text(env_example_path.read_text()) \ No newline at end of file diff --git a/copier.yml b/copier.yml index d1536c3..c37549e 100644 --- a/copier.yml +++ b/copier.yml @@ -4,19 +4,12 @@ project_name: default: python-template _exclude: -# Global - - .github - - .vscode - - .mypy_cache # Python - __pycache__ - .mypy_cache - .cache - - .docs - - .devcontainer - .venv - - .pyproject.toml - - README.md + - uv.lock _answers_file: .copier/.copier-answers.yml From 2abc506f6647a392e0dc2469a5c22c6134386024 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Fri, 11 Jul 2025 15:36:45 -0300 Subject: [PATCH 05/19] added project decription --- README.md | 22 ++++++++++------------ copier.yml | 7 ++++++- pyproject.toml => pyproject.toml.jinja | 6 +++--- 3 files changed, 19 insertions(+), 16 deletions(-) rename pyproject.toml => pyproject.toml.jinja (97%) diff --git a/README.md b/README.md index 66988e3..ef70054 100755 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ - Rest API built with FastAPI and SQLAlchemy - PostgreSQL database +## Clone the project using Copier + +Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. + +Go to the directory that will be the parent of your project, and run the command with your project's name: + +`copier copy --vcs-ref main git@github.com:xmartlabs/python-template.git my-awesome-project --trust` + +Note the `--trust` option is necessary to be able to execute a post-creation script that updates your `.env` files. + ## Project setup The only things you need are [Docker](https://docs.docker.com/engine/install/), [Docker Compose](https://docs.docker.com/compose/install/), and a code editor with devcontainer support like [Visual Studio Code](https://code.visualstudio.com/download). Once you open the template with VS Code, it will recommend that you install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) if you don’t have it already. Then, a pop-up will appear to reopen the template in the devcontainer, or you can use `Ctrl / Cmd + shift + P` -> `Dev Containers: Open Folder in Container…`. Remember to add the `.env` file at the root folder; you can use `.env.example` as a reference. @@ -28,18 +38,6 @@ Alternatively, you must have: For making code changes, installing `pre-commit` is necessary (see section [Code tools: pre-commit](#pre-commit)) -### Customization - -The project's name (`python-template`) can be edited following next steps: - -1. Edit project's name in the [pyproject.toml](pyproject.toml) file -2. Set `PROJECT_NAME` env variable to be exactly the same as project's name in pyproject.toml. Ensure VSCode has this -variable loaded, otherwise the dev container might fail or not work as expected. You can open VScode with from cmd with: - -```bash -PROJECT_NAME=your-awesome-project code -``` - ## Migrations We use Alembic as database migration tool. You can run migration commands directly inside the dev container or use the provided shortcut in the `exec.sh` script. diff --git a/copier.yml b/copier.yml index c37549e..948c1da 100644 --- a/copier.yml +++ b/copier.yml @@ -3,6 +3,11 @@ project_name: help: The name of the project default: python-template +project_description: + type: str + help: The description of the project + default: Xmartlabs' Python project template + _exclude: # Python - __pycache__ @@ -15,4 +20,4 @@ _exclude: _answers_file: .copier/.copier-answers.yml _tasks: - - ["{{ _copier_python }}", .copier/update_dotenv.py] \ No newline at end of file + - ["{{ _copier_python }}", .copier/update_dotenv.py] diff --git a/pyproject.toml b/pyproject.toml.jinja similarity index 97% rename from pyproject.toml rename to pyproject.toml.jinja index c442c8f..c8d98bf 100644 --- a/pyproject.toml +++ b/pyproject.toml.jinja @@ -1,8 +1,8 @@ [project] # Project's name must be the same as PROJECT_NAME environment variable used elsewhere. -name = "python-template" -version = "0.1.0" -description = "Xmartlabs' Python project template" +name = "{{ project_name }}" +version = "0.0.1" +description = "{{ project_description }}" authors = [{ name = "Xmartlabs", email = "getintouch@xmartlabs.com" }] requires-python = ">=3.13.0,<4.0.0" readme = "README.md" From ebf720f546fd441317ea5f1a07f7bd77c42134c6 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Mon, 14 Jul 2025 13:34:55 -0300 Subject: [PATCH 06/19] more jinja files added --- .devcontainer/devcontainer.json | 193 ++++++++++++------------ .devcontainer/devcontainer.json.jinja | 103 +++++++++++++ .devcontainer/docker-compose.yaml.jinja | 64 ++++++++ Dockerfile.jinja | 100 ++++++++++++ pyproject.toml | 130 ++++++++++++++++ 5 files changed, 494 insertions(+), 96 deletions(-) create mode 100644 .devcontainer/devcontainer.json.jinja create mode 100644 .devcontainer/docker-compose.yaml.jinja create mode 100644 Dockerfile.jinja create mode 100644 pyproject.toml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index db024b2..3068b0a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,103 +1,104 @@ { - "name": "Python Template", - "dockerComposeFile": "docker-compose.yaml", - "service": "devcontainer", - "runServices": [ - "devcontainer", - "postgres", - "rabbitmq", - "redis", - "celery_worker", - "otel-collector" - ], - "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:python-template}", - "containerEnv": { - "PROJECT": "${localEnv:PROJECT_NAME:python-template}", - "USER": "${localEnv:USER}" - }, - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {}, - "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { - "plugins": "git" - } - }, - "remoteUser": "${localEnv:USER}", - "customizations": { - "vscode": { - "extensions": [ - "charliermarsh.ruff", - "ms-azuretools.vscode-docker", - "ms-python.python", - "ms-python.vscode-pylance", - "tamasfe.even-better-toml" - ], - "settings": { - "editor.formatOnSave": true, - "editor.rulers": [ - 130 - ], - "editor.tabSize": 4, - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "files.trimTrailingWhitespace": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, - "python.editor.defaultFormatter": "charliermarsh.ruff", - "python.testing.unittestEnabled": false, - "python.testing.pytestArgs": [ - "src/tests" - ], - "python.testing.pytestEnabled": true, - "python.analysis.typeCheckingMode": "standard", - "python.analysis.autoImportCompletions": true - } - }, - "black-formatter.args": [ - "--line-length=120" + "name": "Python Template", + "dockerComposeFile": "docker-compose.yaml", + "service": "devcontainer", + "runServices": [ + "devcontainer", + "postgres", + "rabbitmq", + "redis", + "celery_worker", + "otel-collector" ], - "unwantedRecommendations": [] - }, - "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-create.sh", - "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-start.sh", - "forwardPorts": [ - // Backend API: - // localhost:8000 for accessing on your host - "devcontainer:8000", - // Postgres: - // localhost:5432 for accessing postgres via local dbeaver/psql client - "postgres:5432", - // Redis: - // localhost:6379 for accessing redis via local redis-cli - "redis:6379", - // RabbitMQ: - // localhost:5672 for accessing rabbitmq via local rabbitmq-cli - "rabbitmq:5672", - // OpenTelemetry Collector: - // localhost:4317 for accessing opentelemetry collector via local otel-cli - "otel-collector:4317" - ], - "portsAttributes": { - "5432": { - "label": "Postgres", - "onAutoForward": "notify" + "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:python-template}", + "containerEnv": { + "PROJECT": "${localEnv:PROJECT_NAME:python-template}", + "USER": "${localEnv:USER}" }, - "8000": { - "label": "Backend API", - "onAutoForward": "notify" - }, - "6379": { - "label": "Redis", - "onAutoForward": "silent" + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { + "plugins": "git" + } }, - "5672": { - "label": "RabbitMQ", - "onAutoForward": "silent" + "remoteUser": "${localEnv:USER}", + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-azuretools.vscode-docker", + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" + ], + "settings": { + "editor.formatOnSave": true, + "editor.rulers": [ + 130 + ], + "editor.tabSize": 4, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "python.editor.defaultFormatter": "charliermarsh.ruff", + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "src/tests" + ], + "python.testing.pytestEnabled": true, + "python.analysis.typeCheckingMode": "standard", + "python.analysis.autoImportCompletions": true + } + }, + "black-formatter.args": [ + "--line-length=120" + ], + "unwantedRecommendations": [] }, - "4317": { - "label": "OpenTelemetry Collector", - "onAutoForward": "silent" + "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-create.sh", + "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-start.sh", + "forwardPorts": [ + // Backend API: + // localhost:8000 for accessing on your host + "devcontainer:8000", + // Postgres: + // localhost:5432 for accessing postgres via local dbeaver/psql client + "postgres:5432", + // Redis: + // localhost:6379 for accessing redis via local redis-cli + "redis:6379", + // RabbitMQ: + // localhost:5672 for accessing rabbitmq via local rabbitmq-cli + "rabbitmq:5672", + // OpenTelemetry Collector: + // localhost:4317 for accessing opentelemetry collector via local otel-cli + "otel-collector:4317" + ], + "portsAttributes": { + "5432": { + "label": "Postgres", + "onAutoForward": "notify" + }, + "8000": { + "label": "Backend API", + "onAutoForward": "notify" + }, + "6379": { + "label": "Redis", + "onAutoForward": "silent" + }, + "5672": { + "label": "RabbitMQ", + "onAutoForward": "silent" + }, + "4317": { + "label": "OpenTelemetry Collector", + "onAutoForward": "silent" + } } } -} + \ No newline at end of file diff --git a/.devcontainer/devcontainer.json.jinja b/.devcontainer/devcontainer.json.jinja new file mode 100644 index 0000000..65250e2 --- /dev/null +++ b/.devcontainer/devcontainer.json.jinja @@ -0,0 +1,103 @@ +{ + "name": "{{ project_name }}", + "dockerComposeFile": "docker-compose.yaml", + "service": "devcontainer", + "runServices": [ + "devcontainer", + "postgres", + "rabbitmq", + "redis", + "celery_worker", + "otel-collector" + ], + "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:{{ project_name }}}", + "containerEnv": { + "PROJECT": "${localEnv:PROJECT_NAME:{{ project_name }}}", + "USER": "${localEnv:USER}" + }, + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { + "plugins": "git" + } + }, + "remoteUser": "${localEnv:USER}", + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-azuretools.vscode-docker", + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" + ], + "settings": { + "editor.formatOnSave": true, + "editor.rulers": [ + 130 + ], + "editor.tabSize": 4, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "python.editor.defaultFormatter": "charliermarsh.ruff", + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "src/tests" + ], + "python.testing.pytestEnabled": true, + "python.analysis.typeCheckingMode": "standard", + "python.analysis.autoImportCompletions": true + } + }, + "black-formatter.args": [ + "--line-length=120" + ], + "unwantedRecommendations": [] + }, + "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:{{ project_name }}}/.devcontainer/devcontainer-create.sh", + "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:{{ project_name }}}/.devcontainer/devcontainer-start.sh", + "forwardPorts": [ + // Backend API: + // localhost:8000 for accessing on your host + "devcontainer:8000", + // Postgres: + // localhost:5432 for accessing postgres via local dbeaver/psql client + "postgres:5432", + // Redis: + // localhost:6379 for accessing redis via local redis-cli + "redis:6379", + // RabbitMQ: + // localhost:5672 for accessing rabbitmq via local rabbitmq-cli + "rabbitmq:5672", + // OpenTelemetry Collector: + // localhost:4317 for accessing opentelemetry collector via local otel-cli + "otel-collector:4317" + ], + "portsAttributes": { + "5432": { + "label": "Postgres", + "onAutoForward": "notify" + }, + "8000": { + "label": "Backend API", + "onAutoForward": "notify" + }, + "6379": { + "label": "Redis", + "onAutoForward": "silent" + }, + "5672": { + "label": "RabbitMQ", + "onAutoForward": "silent" + }, + "4317": { + "label": "OpenTelemetry Collector", + "onAutoForward": "silent" + } + } +} diff --git a/.devcontainer/docker-compose.yaml.jinja b/.devcontainer/docker-compose.yaml.jinja new file mode 100644 index 0000000..e242cb7 --- /dev/null +++ b/.devcontainer/docker-compose.yaml.jinja @@ -0,0 +1,64 @@ +services: + devcontainer: + build: + context: ../ + target: devcontainer + args: + PROJECT_NAME: ${PROJECT_NAME:-{{ project_name }}} + USER: ${USER} + ports: + - '8000:8000' + volumes: + - source: .. + target: /opt/app/${PROJECT_NAME:-{{ project_name }}} + type: bind + - source: ./commandhistory + target: /home/${USER}/.commandhistory + type: bind + - source: cache + target: /home/${USER}/.cache + type: volume + env_file: ../.env + + postgres: + image: postgres:16 + pull_policy: always + restart: unless-stopped + environment: + POSTGRES_USER: dev + POSTGRES_DB: dev + POSTGRES_PASSWORD: dev + volumes: + - source: postgres + target: /var/lib/postgresql/data + type: volume + + redis: + image: redis:8.0-M04-alpine + ports: + - '6379:6379' + + rabbitmq: + image: rabbitmq:4.1-rc-management-alpine + ports: + - '5672:5672' + - '15672:15672' + + celery_worker: + build: + context: ../ + dockerfile: Dockerfile + target: celery_worker + env_file: ../.env + depends_on: + - rabbitmq + - redis + + otel-collector: + image: otel/opentelemetry-collector:latest + ports: + - "4317:4317" + +volumes: + postgres: {} + cache: {} diff --git a/Dockerfile.jinja b/Dockerfile.jinja new file mode 100644 index 0000000..162c4f1 --- /dev/null +++ b/Dockerfile.jinja @@ -0,0 +1,100 @@ +# ---- +# Base image install all the tools needed to build the project +FROM python:3.13-slim-bookworm AS base + +ARG PROJECT_NAME={{ project_name }} +ARG USER=appuser + +ENV RUNTIME_PACKAGES=libpq-dev +# These packages will be deleted from the final image, after the application is packaged +ENV BUILD_PACKAGES=gcc + +RUN apt-get update \ + && apt-get install -y ${BUILD_PACKAGES} ${RUNTIME_PACKAGES} \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/app/${PROJECT_NAME} + +# Never run as root and prefer fixed IDs above 10000 to prevent conflicts with host users. +RUN groupadd -g 10001 ${USER} \ + && useradd -u 10000 -g ${USER} --create-home ${USER} \ + && chown -R ${USER}:${USER} /opt/app + +USER ${USER} + +ENV UV_VERSION=0.7.18 + +# uv is installed in user's home directory, which is not in PATH by default. +ENV PATH="$PATH:/home/${USER}/.local/bin" +ENV PYTHONPATH=/opt/app/${PROJECT_NAME} + +RUN pip install --upgrade pip \ + && pip install --user uv==${UV_VERSION} + +WORKDIR /opt/app/${PROJECT_NAME} +COPY --chown=${USER}:${USER} . . + +RUN uv sync --frozen --no-cache --no-install-project --no-default-groups + + +# ---- +# Devcontainer adds extra tools for development +FROM base AS devcontainer + +USER root + +# Add any other tool usefull during development to the following list, this won't be included +# in the deployment image. +ENV DEV_TOOLS="sudo curl nano postgresql-client" +RUN apt-get update \ + && apt-get install -y ${DEV_TOOLS} + +# To run chsh without password +RUN echo "auth sufficient pam_shells.so" > /etc/pam.d/chsh + +# Adding sudo in development stage is fine – it's like leaving your front door open during construction. +# Move this to production, and we’ll personally revoke your coffee privileges +RUN adduser ${USER} sudo +RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + +USER ${USER} + +RUN mkdir -p /home/${USER}/.cache +RUN uv sync --frozen --no-cache --no-install-project --all-groups + +CMD ["sleep", "infinity"] + +# ---- +# Celery worker stage +FROM base AS celery_worker + +CMD ["uv", "run", "celery", "-A", "src.task_queue.celery_worker", "worker", "--loglevel=info"] + +# ---- +# Builder will package the app for deployment +FROM base AS builder + +RUN uv build --wheel + +# ---- +# Deployment stage to run in cloud environments. This must be the last stage, which is used to run the application by default +FROM base AS deployment + +# root is needed to remove build dependencies +USER root +RUN apt-get purge -y ${BUILD_PACKAGES} \ + && apt-get autoremove -y \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +USER ${USER} + +# TODO(remer): wheel version has to match what is set in pyproject.toml +COPY --from=builder /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl + +RUN uv run pip install --no-deps dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl + +EXPOSE 8000 + +ENTRYPOINT ["uv", "run", "python", "-m", "uvicorn", "src.main:app"] + +CMD ["--host", "0.0.0.0", "--port", "8000"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c442c8f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,130 @@ +[project] +# Project's name must be the same as PROJECT_NAME environment variable used elsewhere. +name = "python-template" +version = "0.1.0" +description = "Xmartlabs' Python project template" +authors = [{ name = "Xmartlabs", email = "getintouch@xmartlabs.com" }] +requires-python = ">=3.13.0,<4.0.0" +readme = "README.md" +dependencies = [ + "alembic>=1.15.1,<2", + "asyncpg>=0.30.0,<0.31", + "bcrypt==4.3.0", + "email-validator>=2.2.0,<3", + "celery>=5.4.0,<6", + "fastapi>=0.115.11,<0.116", + "fastapi-pagination>=0.12.26,<0.13", + "httpx>=0.28.1,<0.29", + "itsdangerous>=2.2.0,<3", + "opentelemetry-api>=1.31.1,<2", + "opentelemetry-sdk>=1.31.1,<2", + "opentelemetry-instrumentation-fastapi>=0.52b1,<0.53", + "opentelemetry-instrumentation-sqlalchemy>=0.52b1,<0.53", + "opentelemetry-exporter-otlp>=1.31.1,<2", + "passlib>=1.7.4,<2", + "psycopg2>=2.9.9,<3", + "ptpython>=3.0.29,<4", + "pydantic>=2.10.6,<3", + "pydantic-settings>=2.8.1,<3", + "python-jose>=3.4.0,<4", + "redis>=5.2.1,<6", + "sqladmin>=0.20.1,<0.21", + "sqlalchemy>=2.0.39,<3", + "structlog>=25.3.0,<26", + "uvicorn>=0.34.0,<0.35", +] + +[dependency-groups] +dev = [ + "coverage>=7.7.1,<8", + "flower>=2.0.1,<3", + "mock>=5.2.0,<6", + "pyright>=1.1.402,<2", + "pytest>=8.3.5,<9", + "pytest-asyncio==0.26.0", + "pre-commit>=4.2.0,<5", + "ruff>=0.11.3,<0.12", +] +types = [ + "celery-types>=0.23.0,<0.24", + "types-passlib>=1.7.7.20241221,<2", + "types-pyasn1>=0.6.0.20250208,<0.7", + "types-python-jose>=3.4.0.20250224,<4", + "types-mock>=5.2.0.20250306,<6", + "typing-inspect>=0.9.0,<0.10", + "typing-extensions>=4.12.2,<5", +] + +[tool.uv] +default-groups = ["dev", "types"] + +[tool.hatch.build.targets.sdist] +include = ["src/*"] + +[tool.hatch.build.targets.wheel] +include = ["src/*"] + +[tool.hatch.build.targets.wheel.sources] +"src/*" = "*" + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 130 +force-exclude = true # Ensure exclusions are respected by the pre-commit hook +extend-exclude = ["src/alembic/versions", "__pycache__", "scripts"] + +[tool.ruff.lint] +extend-select = [ # Defaults: [ "E4", "E7", "E9", "F" ] (https://docs.astral.sh/ruff/rules/#error-e) + "E501", # line-too-long + "I001", # unsorted-imports + "I002", # missing-required-import +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] + +[tool.ruff.lint.isort] +known-first-party = ["src"] +known-third-party = ["fastapi", "sqlalchemy", "pydantic"] +force-single-line = false +combine-as-imports = true + +[tool.pyright] +include = ["src"] +exclude = ["src/alembic/versions", "**/__pycache__", "scripts"] +reportMissingImports = true +reportMissingTypeStubs = false +pythonVersion = "3.13" +typeCheckingMode = "standard" +useLibraryCodeForTypes = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true +analyzeUnannotatedFunctions = true +strictParameterNoneValue = true +enableTypeIgnoreComments = true +reportGeneralTypeIssues = true +reportOptionalSubscript = true +reportOptionalMemberAccess = true +reportOptionalCall = true +reportOptionalIterable = true +reportOptionalContextManager = true +reportOptionalOperand = true +reportTypedDictNotRequiredAccess = false +reportPrivateUsage = false +reportUnknownArgumentType = false +reportUnknownLambdaType = false +reportUnknownMemberType = false +reportUnknownParameterType = false +reportUnknownVariableType = false +reportUnnecessaryIsInstance = false +reportUnnecessaryCast = false +reportUnnecessaryComparison = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" From a8ebbf0fc4bf078802d3fb1f58a7915dd0b1fd5b Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Mon, 14 Jul 2025 13:45:36 -0300 Subject: [PATCH 07/19] do not exclude uvlock --- copier.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/copier.yml b/copier.yml index 948c1da..0223642 100644 --- a/copier.yml +++ b/copier.yml @@ -14,7 +14,6 @@ _exclude: - .mypy_cache - .cache - .venv - - uv.lock _answers_file: .copier/.copier-answers.yml From 796f1ce4d8bff6ab843ae02fbd7b8b6cda3a9b23 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Mon, 14 Jul 2025 13:51:16 -0300 Subject: [PATCH 08/19] exclude uv.lock --- copier.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/copier.yml b/copier.yml index 0223642..2c12636 100644 --- a/copier.yml +++ b/copier.yml @@ -14,7 +14,7 @@ _exclude: - .mypy_cache - .cache - .venv - + - uv.lock _answers_file: .copier/.copier-answers.yml From 4d05b304130fea44d9afb4348c30938001ade565 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Mon, 14 Jul 2025 14:29:51 -0300 Subject: [PATCH 09/19] modify dockerfile --- .devcontainer/devcontainer-start.sh | 1 + Dockerfile | 1 + Dockerfile.jinja | 1 + 3 files changed, 3 insertions(+) diff --git a/.devcontainer/devcontainer-start.sh b/.devcontainer/devcontainer-start.sh index b68e305..c490af4 100755 --- a/.devcontainer/devcontainer-start.sh +++ b/.devcontainer/devcontainer-start.sh @@ -4,4 +4,5 @@ set -xeo pipefail +uv lock uv sync --frozen --no-install-project --all-groups diff --git a/Dockerfile b/Dockerfile index 9474b39..963f63c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ RUN pip install --upgrade pip \ WORKDIR /opt/app/${PROJECT_NAME} COPY --chown=${USER}:${USER} . . +RUN uv lock RUN uv sync --frozen --no-cache --no-install-project --no-default-groups diff --git a/Dockerfile.jinja b/Dockerfile.jinja index 162c4f1..7bc7b66 100644 --- a/Dockerfile.jinja +++ b/Dockerfile.jinja @@ -34,6 +34,7 @@ RUN pip install --upgrade pip \ WORKDIR /opt/app/${PROJECT_NAME} COPY --chown=${USER}:${USER} . . +RUN uv lock RUN uv sync --frozen --no-cache --no-install-project --no-default-groups From e101ef9808640b527ebb00bf3e4a59ba9e18a450 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Mon, 14 Jul 2025 14:44:39 -0300 Subject: [PATCH 10/19] format json --- .devcontainer/devcontainer.json | 193 ++++++++++++++++---------------- 1 file changed, 96 insertions(+), 97 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3068b0a..1a74987 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,104 +1,103 @@ { - "name": "Python Template", - "dockerComposeFile": "docker-compose.yaml", - "service": "devcontainer", - "runServices": [ - "devcontainer", - "postgres", - "rabbitmq", - "redis", - "celery_worker", - "otel-collector" + "name": "Python Template", + "dockerComposeFile": "docker-compose.yaml", + "service": "devcontainer", + "runServices": [ + "devcontainer", + "postgres", + "rabbitmq", + "redis", + "celery_worker", + "otel-collector" + ], + "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:python-template}", + "containerEnv": { + "PROJECT": "${localEnv:PROJECT_NAME:python-template}", + "USER": "${localEnv:USER}" + }, + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/sshd:1": {}, + "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { + "plugins": "git" + } + }, + "remoteUser": "${localEnv:USER}", + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-azuretools.vscode-docker", + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" + ], + "settings": { + "editor.formatOnSave": true, + "editor.rulers": [ + 130 + ], + "editor.tabSize": 4, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "python.editor.defaultFormatter": "charliermarsh.ruff", + "python.testing.unittestEnabled": false, + "python.testing.pytestArgs": [ + "src/tests" + ], + "python.testing.pytestEnabled": true, + "python.analysis.typeCheckingMode": "standard", + "python.analysis.autoImportCompletions": true + } + }, + "black-formatter.args": [ + "--line-length=120" ], - "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:python-template}", - "containerEnv": { - "PROJECT": "${localEnv:PROJECT_NAME:python-template}", - "USER": "${localEnv:USER}" + "unwantedRecommendations": [] + }, + "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-create.sh", + "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-start.sh", + "forwardPorts": [ + // Backend API: + // localhost:8000 for accessing on your host + "devcontainer:8000", + // Postgres: + // localhost:5432 for accessing postgres via local dbeaver/psql client + "postgres:5432", + // Redis: + // localhost:6379 for accessing redis via local redis-cli + "redis:6379", + // RabbitMQ: + // localhost:5672 for accessing rabbitmq via local rabbitmq-cli + "rabbitmq:5672", + // OpenTelemetry Collector: + // localhost:4317 for accessing opentelemetry collector via local otel-cli + "otel-collector:4317" + ], + "portsAttributes": { + "5432": { + "label": "Postgres", + "onAutoForward": "notify" }, - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {}, - "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { - "plugins": "git" - } + "8000": { + "label": "Backend API", + "onAutoForward": "notify" }, - "remoteUser": "${localEnv:USER}", - "customizations": { - "vscode": { - "extensions": [ - "charliermarsh.ruff", - "ms-azuretools.vscode-docker", - "ms-python.python", - "ms-python.vscode-pylance", - "tamasfe.even-better-toml" - ], - "settings": { - "editor.formatOnSave": true, - "editor.rulers": [ - 130 - ], - "editor.tabSize": 4, - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "files.trimTrailingWhitespace": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, - "python.editor.defaultFormatter": "charliermarsh.ruff", - "python.testing.unittestEnabled": false, - "python.testing.pytestArgs": [ - "src/tests" - ], - "python.testing.pytestEnabled": true, - "python.analysis.typeCheckingMode": "standard", - "python.analysis.autoImportCompletions": true - } - }, - "black-formatter.args": [ - "--line-length=120" - ], - "unwantedRecommendations": [] + "6379": { + "label": "Redis", + "onAutoForward": "silent" }, - "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-create.sh", - "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-start.sh", - "forwardPorts": [ - // Backend API: - // localhost:8000 for accessing on your host - "devcontainer:8000", - // Postgres: - // localhost:5432 for accessing postgres via local dbeaver/psql client - "postgres:5432", - // Redis: - // localhost:6379 for accessing redis via local redis-cli - "redis:6379", - // RabbitMQ: - // localhost:5672 for accessing rabbitmq via local rabbitmq-cli - "rabbitmq:5672", - // OpenTelemetry Collector: - // localhost:4317 for accessing opentelemetry collector via local otel-cli - "otel-collector:4317" - ], - "portsAttributes": { - "5432": { - "label": "Postgres", - "onAutoForward": "notify" - }, - "8000": { - "label": "Backend API", - "onAutoForward": "notify" - }, - "6379": { - "label": "Redis", - "onAutoForward": "silent" - }, - "5672": { - "label": "RabbitMQ", - "onAutoForward": "silent" - }, - "4317": { - "label": "OpenTelemetry Collector", - "onAutoForward": "silent" - } + "5672": { + "label": "RabbitMQ", + "onAutoForward": "silent" + }, + "4317": { + "label": "OpenTelemetry Collector", + "onAutoForward": "silent" } } - \ No newline at end of file +} \ No newline at end of file From 4f969588988bc5fe3fdff71ddac0a199a3b44210 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:06:36 -0300 Subject: [PATCH 11/19] let me try something --- .copier/update_dotenv.py | 28 ---- .devcontainer/devcontainer.json | 103 -------------- .devcontainer/docker-compose.yaml | 64 --------- .env.jinja | 17 +++ .../{python-app.yml => python-app.yml.jinja} | 2 +- Dockerfile | 101 -------------- copier.yml | 2 +- pyproject.toml | 130 ------------------ 8 files changed, 19 insertions(+), 428 deletions(-) delete mode 100644 .copier/update_dotenv.py delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .devcontainer/docker-compose.yaml create mode 100644 .env.jinja rename .github/workflows/{python-app.yml => python-app.yml.jinja} (98%) delete mode 100644 Dockerfile delete mode 100644 pyproject.toml diff --git a/.copier/update_dotenv.py b/.copier/update_dotenv.py deleted file mode 100644 index ca24ba0..0000000 --- a/.copier/update_dotenv.py +++ /dev/null @@ -1,28 +0,0 @@ -from pathlib import Path -import json - -# Update the .env file with the answers from the .copier-answers.yml file -root_path = Path(__file__).parent.parent -answers_path = Path(__file__).parent / ".copier-answers.yml" -answers = json.loads(answers_path.read_text()) -env_example_path = root_path / ".env.example" -env_content = env_example_path.read_text() -lines = [] -for line in env_content.splitlines(): - for key, value in answers.items(): - upper_key = key.upper() - if line.startswith(f"{upper_key}="): - if " " in value: - content = f"{upper_key}={value!r}" - else: - content = f"{upper_key}={value}" - new_line = line.replace(line, content) - lines.append(new_line) - break - else: - lines.append(line) -env_example_path.write_text("\n".join(lines)) - -# this creates a .env file with the same content as the .env.example file. -env_file_path = root_path / ".env" -env_file_path.write_text(env_example_path.read_text()) \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 1a74987..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "name": "Python Template", - "dockerComposeFile": "docker-compose.yaml", - "service": "devcontainer", - "runServices": [ - "devcontainer", - "postgres", - "rabbitmq", - "redis", - "celery_worker", - "otel-collector" - ], - "workspaceFolder": "/opt/app/${localEnv:PROJECT_NAME:python-template}", - "containerEnv": { - "PROJECT": "${localEnv:PROJECT_NAME:python-template}", - "USER": "${localEnv:USER}" - }, - "features": { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {}, - "ghcr.io/nils-geistmann/devcontainers-features/zsh:0": { - "plugins": "git" - } - }, - "remoteUser": "${localEnv:USER}", - "customizations": { - "vscode": { - "extensions": [ - "charliermarsh.ruff", - "ms-azuretools.vscode-docker", - "ms-python.python", - "ms-python.vscode-pylance", - "tamasfe.even-better-toml" - ], - "settings": { - "editor.formatOnSave": true, - "editor.rulers": [ - 130 - ], - "editor.tabSize": 4, - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "files.trimTrailingWhitespace": true, - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, - "python.editor.defaultFormatter": "charliermarsh.ruff", - "python.testing.unittestEnabled": false, - "python.testing.pytestArgs": [ - "src/tests" - ], - "python.testing.pytestEnabled": true, - "python.analysis.typeCheckingMode": "standard", - "python.analysis.autoImportCompletions": true - } - }, - "black-formatter.args": [ - "--line-length=120" - ], - "unwantedRecommendations": [] - }, - "postCreateCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-create.sh", - "postStartCommand": "/opt/app/${localEnv:PROJECT_NAME:python-template}/.devcontainer/devcontainer-start.sh", - "forwardPorts": [ - // Backend API: - // localhost:8000 for accessing on your host - "devcontainer:8000", - // Postgres: - // localhost:5432 for accessing postgres via local dbeaver/psql client - "postgres:5432", - // Redis: - // localhost:6379 for accessing redis via local redis-cli - "redis:6379", - // RabbitMQ: - // localhost:5672 for accessing rabbitmq via local rabbitmq-cli - "rabbitmq:5672", - // OpenTelemetry Collector: - // localhost:4317 for accessing opentelemetry collector via local otel-cli - "otel-collector:4317" - ], - "portsAttributes": { - "5432": { - "label": "Postgres", - "onAutoForward": "notify" - }, - "8000": { - "label": "Backend API", - "onAutoForward": "notify" - }, - "6379": { - "label": "Redis", - "onAutoForward": "silent" - }, - "5672": { - "label": "RabbitMQ", - "onAutoForward": "silent" - }, - "4317": { - "label": "OpenTelemetry Collector", - "onAutoForward": "silent" - } - } -} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml deleted file mode 100644 index 285a2a4..0000000 --- a/.devcontainer/docker-compose.yaml +++ /dev/null @@ -1,64 +0,0 @@ -services: - devcontainer: - build: - context: ../ - target: devcontainer - args: - PROJECT_NAME: ${PROJECT_NAME:-python-template} - USER: ${USER} - ports: - - '8000:8000' - volumes: - - source: .. - target: /opt/app/${PROJECT_NAME:-python-template} - type: bind - - source: ./commandhistory - target: /home/${USER}/.commandhistory - type: bind - - source: cache - target: /home/${USER}/.cache - type: volume - env_file: ../.env - - postgres: - image: postgres:16 - pull_policy: always - restart: unless-stopped - environment: - POSTGRES_USER: dev - POSTGRES_DB: dev - POSTGRES_PASSWORD: dev - volumes: - - source: postgres - target: /var/lib/postgresql/data - type: volume - - redis: - image: redis:8.0-M04-alpine - ports: - - '6379:6379' - - rabbitmq: - image: rabbitmq:4.1-rc-management-alpine - ports: - - '5672:5672' - - '15672:15672' - - celery_worker: - build: - context: ../ - dockerfile: Dockerfile - target: celery_worker - env_file: ../.env - depends_on: - - rabbitmq - - redis - - otel-collector: - image: otel/opentelemetry-collector:latest - ports: - - "4317:4317" - -volumes: - postgres: {} - cache: {} diff --git a/.env.jinja b/.env.jinja new file mode 100644 index 0000000..b790781 --- /dev/null +++ b/.env.jinja @@ -0,0 +1,17 @@ +PROJECT_NAME={{project_name}} + +ASYNC_DATABASE_URL=postgresql+asyncpg://dev:dev@postgres:5432/dev +DATABASE_POOL_PRE_PING=True +DATABASE_POOL_SIZE=5 +DATABASE_POOL_RECYCLE=3600 +DATABASE_MAX_OVERFLOW=10 +SERVER_URL=example.com +ACCESS_TOKEN_EXPIRE_MINUTES=15 +JWT_SIGNING_KEY= + +# OpenTelemetry configuration +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 + +# Celery settings +CELERY_BROKER_URL=amqp://guest:guest@rabbitmq:5672 +CELERY_RESULT_BACKEND=redis://redis:6379/0 diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml.jinja similarity index 98% rename from .github/workflows/python-app.yml rename to .github/workflows/python-app.yml.jinja index 373e91c..e954fb8 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml.jinja @@ -51,7 +51,7 @@ jobs: --health-timeout 5s --health-retries 5 env: - PROJECT_NAME: python-template + PROJECT_NAME: {{ project_name }} ASYNC_DATABASE_URL: postgresql+asyncpg://dev:dev@localhost:5432/test DATABASE_POOL_PRE_PING: True DATABASE_POOL_SIZE: 5 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 963f63c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,101 +0,0 @@ -# ---- -# Base image install all the tools needed to build the project -FROM python:3.13-slim-bookworm AS base - -ARG PROJECT_NAME=python-template -ARG USER=appuser - -ENV RUNTIME_PACKAGES=libpq-dev -# These packages will be deleted from the final image, after the application is packaged -ENV BUILD_PACKAGES=gcc - -RUN apt-get update \ - && apt-get install -y ${BUILD_PACKAGES} ${RUNTIME_PACKAGES} \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /opt/app/${PROJECT_NAME} - -# Never run as root and prefer fixed IDs above 10000 to prevent conflicts with host users. -RUN groupadd -g 10001 ${USER} \ - && useradd -u 10000 -g ${USER} --create-home ${USER} \ - && chown -R ${USER}:${USER} /opt/app - -USER ${USER} - -ENV UV_VERSION=0.7.18 - -# uv is installed in user's home directory, which is not in PATH by default. -ENV PATH="$PATH:/home/${USER}/.local/bin" -ENV PYTHONPATH=/opt/app/${PROJECT_NAME} - -RUN pip install --upgrade pip \ - && pip install --user uv==${UV_VERSION} - -WORKDIR /opt/app/${PROJECT_NAME} -COPY --chown=${USER}:${USER} . . - -RUN uv lock -RUN uv sync --frozen --no-cache --no-install-project --no-default-groups - - -# ---- -# Devcontainer adds extra tools for development -FROM base AS devcontainer - -USER root - -# Add any other tool usefull during development to the following list, this won't be included -# in the deployment image. -ENV DEV_TOOLS="sudo curl nano postgresql-client" -RUN apt-get update \ - && apt-get install -y ${DEV_TOOLS} - -# To run chsh without password -RUN echo "auth sufficient pam_shells.so" > /etc/pam.d/chsh - -# Adding sudo in development stage is fine – it's like leaving your front door open during construction. -# Move this to production, and we’ll personally revoke your coffee privileges -RUN adduser ${USER} sudo -RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers - -USER ${USER} - -RUN mkdir -p /home/${USER}/.cache -RUN uv sync --frozen --no-cache --no-install-project --all-groups - -CMD ["sleep", "infinity"] - -# ---- -# Celery worker stage -FROM base AS celery_worker - -CMD ["uv", "run", "celery", "-A", "src.task_queue.celery_worker", "worker", "--loglevel=info"] - -# ---- -# Builder will package the app for deployment -FROM base AS builder - -RUN uv build --wheel - -# ---- -# Deployment stage to run in cloud environments. This must be the last stage, which is used to run the application by default -FROM base AS deployment - -# root is needed to remove build dependencies -USER root -RUN apt-get purge -y ${BUILD_PACKAGES} \ - && apt-get autoremove -y \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -USER ${USER} - -# TODO(remer): wheel version has to match what is set in pyproject.toml -COPY --from=builder /opt/app/${PROJECT_NAME}/dist/python_template-0.1.0-py3-none-any.whl /opt/app/${PROJECT_NAME}/dist/python_template-0.1.0-py3-none-any.whl - -RUN uv run pip install --no-deps dist/python_template-0.1.0-py3-none-any.whl - -EXPOSE 8000 - -ENTRYPOINT ["uv", "run", "python", "-m", "uvicorn", "src.main:app"] - -CMD ["--host", "0.0.0.0", "--port", "8000"] diff --git a/copier.yml b/copier.yml index 2c12636..5c20100 100644 --- a/copier.yml +++ b/copier.yml @@ -19,4 +19,4 @@ _exclude: _answers_file: .copier/.copier-answers.yml _tasks: - - ["{{ _copier_python }}", .copier/update_dotenv.py] + - ["{{ _copier_python }}"] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index c442c8f..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,130 +0,0 @@ -[project] -# Project's name must be the same as PROJECT_NAME environment variable used elsewhere. -name = "python-template" -version = "0.1.0" -description = "Xmartlabs' Python project template" -authors = [{ name = "Xmartlabs", email = "getintouch@xmartlabs.com" }] -requires-python = ">=3.13.0,<4.0.0" -readme = "README.md" -dependencies = [ - "alembic>=1.15.1,<2", - "asyncpg>=0.30.0,<0.31", - "bcrypt==4.3.0", - "email-validator>=2.2.0,<3", - "celery>=5.4.0,<6", - "fastapi>=0.115.11,<0.116", - "fastapi-pagination>=0.12.26,<0.13", - "httpx>=0.28.1,<0.29", - "itsdangerous>=2.2.0,<3", - "opentelemetry-api>=1.31.1,<2", - "opentelemetry-sdk>=1.31.1,<2", - "opentelemetry-instrumentation-fastapi>=0.52b1,<0.53", - "opentelemetry-instrumentation-sqlalchemy>=0.52b1,<0.53", - "opentelemetry-exporter-otlp>=1.31.1,<2", - "passlib>=1.7.4,<2", - "psycopg2>=2.9.9,<3", - "ptpython>=3.0.29,<4", - "pydantic>=2.10.6,<3", - "pydantic-settings>=2.8.1,<3", - "python-jose>=3.4.0,<4", - "redis>=5.2.1,<6", - "sqladmin>=0.20.1,<0.21", - "sqlalchemy>=2.0.39,<3", - "structlog>=25.3.0,<26", - "uvicorn>=0.34.0,<0.35", -] - -[dependency-groups] -dev = [ - "coverage>=7.7.1,<8", - "flower>=2.0.1,<3", - "mock>=5.2.0,<6", - "pyright>=1.1.402,<2", - "pytest>=8.3.5,<9", - "pytest-asyncio==0.26.0", - "pre-commit>=4.2.0,<5", - "ruff>=0.11.3,<0.12", -] -types = [ - "celery-types>=0.23.0,<0.24", - "types-passlib>=1.7.7.20241221,<2", - "types-pyasn1>=0.6.0.20250208,<0.7", - "types-python-jose>=3.4.0.20250224,<4", - "types-mock>=5.2.0.20250306,<6", - "typing-inspect>=0.9.0,<0.10", - "typing-extensions>=4.12.2,<5", -] - -[tool.uv] -default-groups = ["dev", "types"] - -[tool.hatch.build.targets.sdist] -include = ["src/*"] - -[tool.hatch.build.targets.wheel] -include = ["src/*"] - -[tool.hatch.build.targets.wheel.sources] -"src/*" = "*" - - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.ruff] -line-length = 130 -force-exclude = true # Ensure exclusions are respected by the pre-commit hook -extend-exclude = ["src/alembic/versions", "__pycache__", "scripts"] - -[tool.ruff.lint] -extend-select = [ # Defaults: [ "E4", "E7", "E9", "F" ] (https://docs.astral.sh/ruff/rules/#error-e) - "E501", # line-too-long - "I001", # unsorted-imports - "I002", # missing-required-import -] - -[tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401"] - -[tool.ruff.lint.isort] -known-first-party = ["src"] -known-third-party = ["fastapi", "sqlalchemy", "pydantic"] -force-single-line = false -combine-as-imports = true - -[tool.pyright] -include = ["src"] -exclude = ["src/alembic/versions", "**/__pycache__", "scripts"] -reportMissingImports = true -reportMissingTypeStubs = false -pythonVersion = "3.13" -typeCheckingMode = "standard" -useLibraryCodeForTypes = true -strictListInference = true -strictDictionaryInference = true -strictSetInference = true -analyzeUnannotatedFunctions = true -strictParameterNoneValue = true -enableTypeIgnoreComments = true -reportGeneralTypeIssues = true -reportOptionalSubscript = true -reportOptionalMemberAccess = true -reportOptionalCall = true -reportOptionalIterable = true -reportOptionalContextManager = true -reportOptionalOperand = true -reportTypedDictNotRequiredAccess = false -reportPrivateUsage = false -reportUnknownArgumentType = false -reportUnknownLambdaType = false -reportUnknownMemberType = false -reportUnknownParameterType = false -reportUnknownVariableType = false -reportUnnecessaryIsInstance = false -reportUnnecessaryCast = false -reportUnnecessaryComparison = false - -[tool.pytest.ini_options] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" From 2cd32718acd9859eb91e04c1a65c4e0b839a186f Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:22:06 -0300 Subject: [PATCH 12/19] let's see if workflow runs now --- .github/workflows/{python-app.yml.jinja => python-app.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{python-app.yml.jinja => python-app.yml} (98%) diff --git a/.github/workflows/python-app.yml.jinja b/.github/workflows/python-app.yml similarity index 98% rename from .github/workflows/python-app.yml.jinja rename to .github/workflows/python-app.yml index e954fb8..373e91c 100644 --- a/.github/workflows/python-app.yml.jinja +++ b/.github/workflows/python-app.yml @@ -51,7 +51,7 @@ jobs: --health-timeout 5s --health-retries 5 env: - PROJECT_NAME: {{ project_name }} + PROJECT_NAME: python-template ASYNC_DATABASE_URL: postgresql+asyncpg://dev:dev@localhost:5432/test DATABASE_POOL_PRE_PING: True DATABASE_POOL_SIZE: 5 From 8346b8321aca64fb2d24e6a0b9de73f25cfcf783 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:27:06 -0300 Subject: [PATCH 13/19] modified workflows to include copier --- .github/workflows/python-app.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 373e91c..635e91e 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -21,14 +21,24 @@ jobs: with: python-version: 3.13 + - name: Install Copier + run: pip install copier + + - name: Generate project from template + run: copier copy . generated --defaults --force + - name: Install dependencies + working-directory: generated run: | pip install uv uv sync --frozen --no-cache --no-install-project --all-groups - name: Install pre-commit + working-directory: generated run: pip install pre-commit + - name: Run linters and formatters + working-directory: generated run: pre-commit run --all-files tests: @@ -73,12 +83,20 @@ jobs: with: python-version: 3.13 + - name: Install Copier + run: pip install copier + + - name: Generate project from template + run: copier copy . generated --defaults --force + - name: Install dependencies + working-directory: generated run: | pip install uv uv sync --frozen --no-cache --no-install-project --group dev --no-group types - name: Run tests with coverage + working-directory: generated run: | uv run coverage run -m pytest uv run coverage report -m --fail-under=80 @@ -87,11 +105,16 @@ jobs: name: Build Docker Image runs-on: ubuntu-latest needs: tests - steps: - name: Checkout code uses: actions/checkout@v3 + - name: Install Copier + run: pip install copier + + - name: Generate project from template + run: copier copy . generated --defaults --force + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -106,7 +129,7 @@ jobs: - name: Build image with cache uses: docker/build-push-action@v5 with: - context: . + context: ./generated push: false tags: python-template:latest cache-from: type=local,src=/tmp/.buildx-cache From 476c6a791feaecdc58e55930f2d5d8d1b9a5ca27 Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:29:07 -0300 Subject: [PATCH 14/19] trust --- .github/workflows/python-app.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 635e91e..f673fe4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,7 +25,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --defaults --force + run: copier copy . generated --trust - name: Install dependencies working-directory: generated @@ -87,7 +87,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --defaults --force + run: copier copy . generated --trust - name: Install dependencies working-directory: generated @@ -113,7 +113,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --defaults --force + run: copier copy . generated --trust - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 From 445030678b63d01a64230aa2e50c2cfa019bf21e Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:30:45 -0300 Subject: [PATCH 15/19] use defaults options --- .github/workflows/python-app.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index f673fe4..4c93aab 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,7 +25,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --trust + run: copier copy . generated --defaults --trust - name: Install dependencies working-directory: generated @@ -87,7 +87,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --trust + run: copier copy . generated --defaults --trust - name: Install dependencies working-directory: generated @@ -113,7 +113,7 @@ jobs: run: pip install copier - name: Generate project from template - run: copier copy . generated --trust + run: copier copy . generated --defaults --trust - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 From 539a775432fd2a4d4f2ae58bc4b6fe9f2d0f20ba Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:34:27 -0300 Subject: [PATCH 16/19] remove frozen --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 4c93aab..00ff939 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,7 +31,7 @@ jobs: working-directory: generated run: | pip install uv - uv sync --frozen --no-cache --no-install-project --all-groups + uv sync --no-cache --no-install-project --all-groups - name: Install pre-commit working-directory: generated @@ -93,7 +93,7 @@ jobs: working-directory: generated run: | pip install uv - uv sync --frozen --no-cache --no-install-project --group dev --no-group types + uv sync --no-cache --no-install-project --group dev --no-group types - name: Run tests with coverage working-directory: generated From f2fc015d225695b7a1c1df6399b57c7be273f7ab Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 14:45:13 -0300 Subject: [PATCH 17/19] fixed dockerfile --- Dockerfile.jinja | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.jinja b/Dockerfile.jinja index 7bc7b66..fff7515 100644 --- a/Dockerfile.jinja +++ b/Dockerfile.jinja @@ -90,9 +90,9 @@ RUN apt-get purge -y ${BUILD_PACKAGES} \ USER ${USER} # TODO(remer): wheel version has to match what is set in pyproject.toml -COPY --from=builder /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl +COPY --from=builder /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl -RUN uv run pip install --no-deps dist/${PROJECT_NAME}-0.1.0-py3-none-any.whl +RUN uv run pip install --no-deps dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl EXPOSE 8000 From ee26700352654594066b09d1e1179c0fef523a9f Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 15:15:35 -0300 Subject: [PATCH 18/19] idk --- Dockerfile.jinja | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile.jinja b/Dockerfile.jinja index fff7515..3b8c4f9 100644 --- a/Dockerfile.jinja +++ b/Dockerfile.jinja @@ -90,9 +90,10 @@ RUN apt-get purge -y ${BUILD_PACKAGES} \ USER ${USER} # TODO(remer): wheel version has to match what is set in pyproject.toml -COPY --from=builder /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl /opt/app/${PROJECT_NAME}/dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl +# Note: Python wheel names convert hyphens to underscores +COPY --from=builder /opt/app/${PROJECT_NAME}/dist/ dist/ -RUN uv run pip install --no-deps dist/${PROJECT_NAME}-0.0.1-py3-none-any.whl +RUN uv run pip install --no-deps dist/*.whl EXPOSE 8000 From 9e1c3d1a1a27f7992e557fd0613fe5121b8aaaab Mon Sep 17 00:00:00 2001 From: Nicolas Fripp Date: Thu, 17 Jul 2025 15:20:12 -0300 Subject: [PATCH 19/19] a bit more specific --- Dockerfile.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.jinja b/Dockerfile.jinja index 3b8c4f9..8425cc8 100644 --- a/Dockerfile.jinja +++ b/Dockerfile.jinja @@ -91,7 +91,7 @@ USER ${USER} # TODO(remer): wheel version has to match what is set in pyproject.toml # Note: Python wheel names convert hyphens to underscores -COPY --from=builder /opt/app/${PROJECT_NAME}/dist/ dist/ +COPY --from=builder /opt/app/${PROJECT_NAME}/dist/*.whl /opt/app/${PROJECT_NAME}/dist/ RUN uv run pip install --no-deps dist/*.whl