diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 75063b00..0fcb50b6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,7 +60,14 @@ jobs: ${{ matrix.os }}-${{ matrix.python-version }}-build-${{ env.cache-name }}- - name: Run pre-commit - run: pre-commit run -a + run: | + pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? + if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then + echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" + echo "Showing git diff:" + git --no-pager diff + exit $PRE_COMMIT_EXIT_CODE + fi lint-matrix: needs: [ pre-commit ] diff --git a/copier.yaml b/copier.yaml index 19af399e..47585f52 100644 --- a/copier.yaml +++ b/copier.yaml @@ -72,6 +72,12 @@ template_might_want_to_use_vcrpy: default: no when: "{{ template_uses_python }}" +template_might_want_to_use_python_asyncio: + type: bool + help: Is this template for something that might want to use Python asyncio? + default: no + when: "{{ template_uses_python }}" + _min_copier_version: "9.4" diff --git a/template/.github/workflows/ci.yaml.jinja-base b/template/.github/workflows/ci.yaml.jinja-base index 9432d668..fa901a94 100644 --- a/template/.github/workflows/ci.yaml.jinja-base +++ b/template/.github/workflows/ci.yaml.jinja-base @@ -20,7 +20,8 @@ jobs: contents: write # needed for updating dependabot branches pre-commit: - needs: [ get-values ] + needs: + - get-values uses: ./.github/workflows/pre-commit.yaml permissions: contents: write # needed for mutex @@ -29,7 +30,8 @@ jobs: python-version: {% endraw %}{{ python_version }}{% raw %} lint-matrix: - needs: [ pre-commit ] + needs: + - pre-commit strategy: matrix: os: @@ -121,7 +123,13 @@ jobs: - name: Run pre-commit run: | # skip devcontainer context hash because the template instantiation may make it different every time - SKIP=git-dirty,compute-devcontainer-context-hash pre-commit run -a + SKIP=git-dirty,compute-devcontainer-context-hash pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? + if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then + echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" + echo "Showing git diff:" + git --no-pager diff + exit $PRE_COMMIT_EXIT_CODE + fi - name: Upload pre-commit log if failure if: ${{ failure() }} @@ -133,7 +141,9 @@ jobs: required-check: runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %} timeout-minutes: {% endraw %}{{ gha_short_timeout_minutes }}{% raw %} - needs: [ lint-matrix, get-values ] + needs: + - lint-matrix + - get-values permissions: statuses: write # needed for updating status on Dependabot PRs if: always() diff --git a/template/.github/workflows/pre-commit.yaml.jinja-base b/template/.github/workflows/pre-commit.yaml.jinja-base index 1ed8ad96..1a9c838b 100644 --- a/template/.github/workflows/pre-commit.yaml.jinja-base +++ b/template/.github/workflows/pre-commit.yaml.jinja-base @@ -69,4 +69,11 @@ jobs: {% endraw %}{{ gha_linux_runner }}{% raw %}-py${{ inputs.python-version }}-node-${{ inputs.node-version}}-${{ env.cache-name }}- - name: Run pre-commit - run: pre-commit run -a{% endraw %} + run: | + pre-commit run -a || PRE_COMMIT_EXIT_CODE=$? + if [ -n "$PRE_COMMIT_EXIT_CODE" ]; then + echo "Pre-commit failed with exit code $PRE_COMMIT_EXIT_CODE" + echo "Showing git diff:" + git --no-pager diff + exit $PRE_COMMIT_EXIT_CODE + fi{% endraw %} diff --git a/template/copier.yml.jinja-base b/template/copier.yml.jinja-base index 525cf469..c5288879 100644 --- a/template/copier.yml.jinja-base +++ b/template/copier.yml.jinja-base @@ -46,6 +46,10 @@ install_aws_ssm_port_forwarding_plugin: configure_vcrpy: type: bool help: Should VCRpy be configured for use during unit testing in Python? + default: no{% endraw %}{% endif %}{% if template_might_want_to_use_python_asyncio %}{% raw %} +configure_python_asyncio: + type: bool + help: Will python code be using asyncio? default: no{% endraw %}{% endif %}{% raw %} {% endraw %} python_version: diff --git a/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/asyncio_fixtures.py b/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/asyncio_fixtures.py new file mode 100644 index 00000000..43bb4405 --- /dev/null +++ b/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/asyncio_fixtures.py @@ -0,0 +1,36 @@ +import asyncio + +import pytest +from backend_api.background_tasks import background_task_exceptions +from backend_api.background_tasks import background_tasks_set + + +async def _wait_for_tasks(tasks_list: list[asyncio.Task[None]]): + _, pending = await asyncio.wait(tasks_list, timeout=5.0) + if pending: + raise RuntimeError(f"There are still pending tasks: {pending}") + + +@pytest.fixture(autouse=True) +def fail_on_background_task_errors(): + """Automatically fail tests if ANY background task raises an exception.""" + background_task_exceptions.clear() + + yield + + # Wait for background tasks to complete (using asyncio.run for sync fixture) + if background_tasks_set: + tasks_list = list(background_tasks_set) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(_wait_for_tasks(tasks_list)) + else: + loop.run_until_complete(_wait_for_tasks(tasks_list)) + + # Fail if any exceptions occurred + if background_task_exceptions: + pytest.fail( + f"Background tasks raised {len(background_task_exceptions)} exception(s):\n" + + "\n\n".join(f"{type(e).__name__}: {e}" for e in background_task_exceptions) + ) diff --git a/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/background_tasks.py b/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/background_tasks.py new file mode 100644 index 00000000..815f1721 --- /dev/null +++ b/template/copier_template_resources/{% if template_might_want_to_use_python_asyncio %}python_asyncio{% endif %}/background_tasks.py @@ -0,0 +1,41 @@ +import asyncio +import logging +import traceback +from collections import deque +from weakref import WeakSet + +logger = logging.getLogger(__name__) +background_tasks_set: WeakSet[asyncio.Task[None]] = WeakSet() +background_task_exceptions: deque[Exception] = deque( + maxlen=100 # don't grow infinitely in production +) +# Store creation tracebacks for debugging +_task_creation_tracebacks: dict[int, str] = {} + + +def _task_done_callback(task: asyncio.Task[None]): + task_id = id(task) + background_tasks_set.discard(task) + try: + task.result() + except ( # pragma: no cover # hard to unit test this, but it'd be good to think of a way to do so + asyncio.CancelledError + ): + _ = _task_creation_tracebacks.pop(task_id, None) + return + except Exception as e: # pragma: no cover # hard to unit test this, but it'd be good to think of a way to do so + creation_tb = _task_creation_tracebacks.pop(task_id, "No traceback available") + logger.exception(f"Unhandled exception in background task\nTask was created from:\n{creation_tb}") + background_task_exceptions.append(e) + else: + # Clean up on successful completion + _ = _task_creation_tracebacks.pop(task_id, None) + + +def register_task(task: asyncio.Task[None]) -> None: + # Capture the stack trace at task creation time (excluding this function) + creation_stack = "".join(traceback.format_stack()[:-1]) + _task_creation_tracebacks[id(task)] = creation_stack + + background_tasks_set.add(task) + task.add_done_callback(_task_done_callback) diff --git a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} index 065f9bdc..cb33e4ff 100644 --- a/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} +++ b/template/copier_template_resources/{% if template_might_want_to_use_vcrpy %}vcrpy_fixtures.py{% endif %} @@ -5,11 +5,16 @@ import pytest from pydantic import JsonValue from vcr import VCR -ALLOWED_HOSTS = ["testserver"] # Skip recording any requests to our own server - let them run live +UNREACHABLE_IP_ADDRESS = "192.0.2.1" # RFC 5737 TEST-NET-1 +IGNORED_HOSTS = [ + "testserver", # Skip recording any requests to our own server - let them run live + UNREACHABLE_IP_ADDRESS, # allow this through VCR in order to be able to test network failure handling +] +ALLOWED_HOSTS: list[str] = [] -CUSTOM_ALLOWED_HOSTS: tuple[str, ...] = () +CUSTOM_IGNORED_HOSTS: tuple[str, ...] = () -ALLOWED_HOSTS.extend(CUSTOM_ALLOWED_HOSTS) +IGNORED_HOSTS.extend(CUSTOM_IGNORED_HOSTS) if ( os.name == "nt" ): # on Windows (in CI), the network calls happen at a lower level socket connection even to our FastAPI test client, and can get automatically blocked. This disables that automatic network guard, which isn't great...but since it's still in place on Linux, any actual problems would hopefully get caught before pushing to CI. @@ -18,12 +23,18 @@ if ( @pytest.fixture(autouse=True) def vcr_config() -> dict[str, list[str]]: - return {"allowed_hosts": ALLOWED_HOSTS, "filter_headers": ["User-Agent"]} + cfg: dict[str, list[str]] = { + "ignore_hosts": IGNORED_HOSTS, + "filter_headers": ["User-Agent"], + } + if ALLOWED_HOSTS: + cfg["allowed_hosts"] = ALLOWED_HOSTS + return cfg def pytest_recording_configure( - vcr: VCR, config: pytest.Config, # noqa: ARG001 # the config argument MUST be present (even when unused) or pytest-recording throws an error + vcr: VCR, ): vcr.match_on = cast(tuple[str, ...], vcr.match_on) # pyright: ignore[reportUnknownMemberType] # I know vcr.match_on is unknown, that's why I'm casting and isinstance-ing it...not sure if there's a different approach pyright prefers assert isinstance(vcr.match_on, tuple), ( diff --git a/template/tests/copier_data/data1.yaml.jinja-base b/template/tests/copier_data/data1.yaml.jinja-base index 0f58e4f0..b09e0f20 100644 --- a/template/tests/copier_data/data1.yaml.jinja-base +++ b/template/tests/copier_data/data1.yaml.jinja-base @@ -8,6 +8,7 @@ ssh_port_number: 12345 use_windows_in_ci: false {% endraw %}{% if template_might_want_to_install_aws_ssm_port_forwarding_plugin %}{% raw %}install_aws_ssm_port_forwarding_plugin: true{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_might_want_to_use_vcrpy %}{% raw %}configure_vcrpy: true{% endraw %}{% endif %}{% raw %} +{% endraw %}{% if template_might_want_to_use_python_asyncio %}{% raw %}configure_python_asyncio: true{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_uses_javascript %}{% raw %} node_version: 22.13.0{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_uses_python %}{% raw %} diff --git a/template/tests/copier_data/data2.yaml.jinja-base b/template/tests/copier_data/data2.yaml.jinja-base index 51712214..c5a93aac 100644 --- a/template/tests/copier_data/data2.yaml.jinja-base +++ b/template/tests/copier_data/data2.yaml.jinja-base @@ -8,6 +8,7 @@ ssh_port_number: 54321 use_windows_in_ci: true {% endraw %}{% if template_might_want_to_install_aws_ssm_port_forwarding_plugin %}{% raw %}install_aws_ssm_port_forwarding_plugin: false{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_might_want_to_use_vcrpy %}{% raw %}configure_vcrpy: false{% endraw %}{% endif %}{% raw %} +{% endraw %}{% if template_might_want_to_use_python_asyncio %}{% raw %}configure_python_asyncio: false{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_uses_javascript %}{% raw %} node_version: 22.14.0{% endraw %}{% endif %}{% raw %} {% endraw %}{% if template_uses_python %}{% raw %} diff --git a/tests/copier_data/data1.yaml b/tests/copier_data/data1.yaml index 7e695e71..1294dfe9 100644 --- a/tests/copier_data/data1.yaml +++ b/tests/copier_data/data1.yaml @@ -12,3 +12,4 @@ template_uses_javascript: false template_uses_vuejs: false template_might_want_to_install_aws_ssm_port_forwarding_plugin: true template_might_want_to_use_vcrpy: false +template_might_want_to_use_python_asyncio: true diff --git a/tests/copier_data/data2.yaml b/tests/copier_data/data2.yaml index 4e92e5f7..3821467e 100644 --- a/tests/copier_data/data2.yaml +++ b/tests/copier_data/data2.yaml @@ -14,3 +14,4 @@ template_uses_javascript: true template_uses_vuejs: true template_might_want_to_install_aws_ssm_port_forwarding_plugin: false template_might_want_to_use_vcrpy: true +template_might_want_to_use_python_asyncio: false