├── version ├── system_dependencies.txt ├── django_temporalio ├── __init__.py ├── tests │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ └── test_autodiscover_modules.py │ ├── registry │ │ ├── __init__.py │ │ ├── test_get_queue_registry.py │ │ ├── test_schedules.py │ │ ├── test_queue_workflows.py │ │ └── test_queue_activities.py │ ├── management_commands │ │ ├── __init__.py │ │ ├── test_show_temporalio_queue_registry.py │ │ ├── test_start_temporalio_worker.py │ │ └── test_sync_temporal_schedules.py │ ├── test_init_client.py │ └── test_settings.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── show_temporalio_queue_registry.py │ │ ├── sync_temporalio_schedules.py │ │ └── start_temporalio_worker.py ├── apps.py ├── client.py ├── conf.py ├── utils.py └── registry.py ├── example ├── temporalio │ ├── __init__.py │ ├── foo │ │ ├── __init__.py │ │ ├── bar │ │ │ ├── __init__.py │ │ │ └── workflows_bar.py │ │ ├── foo_workflows.py │ │ └── invalid_workflows.txt │ ├── queues.py │ ├── activities.py │ ├── workflows.py │ └── schedules.py ├── __init__.py ├── apps.py └── settings.py ├── requirements-ci.txt ├── requirements-test.txt ├── .devcontainer ├── compose.yaml └── devcontainer.json ├── .sync.yml ├── HISTORY.md ├── manage.py ├── compose.yaml ├── .github └── workflows │ ├── lint.yaml │ ├── cron.yaml │ ├── tag-release.yaml │ ├── open-release-pr.yaml │ ├── check_pull_request.yaml │ ├── workflow.yaml │ └── build-and-publish.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── .vscode └── tasks.json ├── renovate.json ├── pyproject.toml ├── CHANGELOG.md └── README.md /version: -------------------------------------------------------------------------------- 1 | 1.4.0 2 | -------------------------------------------------------------------------------- /system_dependencies.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/foo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/foo/bar/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/foo/bar/workflows_bar.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/foo/foo_workflows.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-ci.txt: -------------------------------------------------------------------------------- 1 | temporalio==1.10.0 2 | -------------------------------------------------------------------------------- /django_temporalio/tests/registry/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/temporalio/foo/invalid_workflows.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_temporalio/tests/management_commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements-ci.txt 2 | Django==5.2.9 3 | setuptools==78.1.1 # without it PyCharm fails to index packages inside the Docker container 4 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | -------------------------------------------------------------------------------- /example/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from django.apps import AppConfig 3 | 4 | 5 | class ExampleConfig(AppConfig): 6 | name = "example" 7 | verbose_name = "Example" 8 | -------------------------------------------------------------------------------- /example/temporalio/queues.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class TestTaskQueues(StrEnum): 5 | MAIN = "MAIN_TASK_QUEUE" 6 | HIGH_PRIORITY = "HIGH_PRIORITY_TASK_QUEUE" 7 | -------------------------------------------------------------------------------- /django_temporalio/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoTemporalioConfig(AppConfig): 5 | name = "django_temporalio" 6 | verbose_name = "Django Temporal.io helpers" 7 | -------------------------------------------------------------------------------- /.devcontainer/compose.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | services: 7 | app: 8 | entrypoint: "" 9 | command: sleep infinity 10 | build: 11 | context: . 12 | -------------------------------------------------------------------------------- /django_temporalio/client.py: -------------------------------------------------------------------------------- 1 | from temporalio.client import Client 2 | 3 | from django_temporalio.conf import settings 4 | 5 | 6 | async def init_client(): 7 | """ 8 | Connect to Temporal.io server and return a client instance. 9 | """ 10 | return await Client.connect(**settings.CLIENT_CONFIG) 11 | -------------------------------------------------------------------------------- /example/temporalio/activities.py: -------------------------------------------------------------------------------- 1 | from temporalio import activity 2 | 3 | from django_temporalio.registry import queue_activities 4 | from example.temporalio.queues import TestTaskQueues 5 | 6 | 7 | @queue_activities.register(TestTaskQueues.MAIN) 8 | @activity.defn 9 | async def test_activity(): 10 | pass 11 | -------------------------------------------------------------------------------- /.sync.yml: -------------------------------------------------------------------------------- 1 | --- 2 | :global: 3 | module_rootname: "django_temporalio" 4 | module_description: "Temporal.io integration for Django" 5 | module_keywords: ["django", "temporal", "temporalio", "temporal.io"] 6 | dependencies: ["django>=4.2,<6.0", "temporalio>=1.6.0"] 7 | max_line_length: 88 8 | changelog_since_tag: "v1.2.0" 9 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent 4 | 5 | SECRET_KEY = "B*7t*&TB*T^*&T67rVC%^$EC$%EC$e45cE$%^E$%e" # noqa: S105 6 | DEBUG = True 7 | 8 | INSTALLED_APPS = [ 9 | "example.apps.ExampleConfig", 10 | "django_temporalio.apps.DjangoTemporalioConfig", 11 | ] 12 | -------------------------------------------------------------------------------- /example/temporalio/workflows.py: -------------------------------------------------------------------------------- 1 | from temporalio import workflow 2 | 3 | from django_temporalio.registry import queue_workflows 4 | from example.temporalio.queues import TestTaskQueues 5 | 6 | 7 | @queue_workflows.register(TestTaskQueues.MAIN) 8 | @workflow.defn 9 | class TestWorkflow: 10 | @workflow.run 11 | async def run(self): 12 | pass 13 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | ## 1.2.0 (2024-10-17) 2 | 3 | **Breaking changes:** 4 | 5 | * replaced `NAMESPACE` and `URL` settings with `CLIENT_CONFIG` setting 6 | 7 | ## 1.1.0 (2024-05-30) 8 | 9 | **Implemented enhancements:** 10 | 11 | * add Temporal.io related code encapsulation 12 | 13 | ## 1.0.0 (2024-05-16) 14 | 15 | **Implemented enhancements:** 16 | 17 | * Initial release 18 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # ------------------------------------------- 4 | # Managed by modulesync - DO NOT EDIT 5 | # ------------------------------------------- 6 | 7 | import os 8 | import sys 9 | 10 | if __name__ == "__main__": 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 12 | 13 | from django.core.management import execute_from_command_line 14 | 15 | execute_from_command_line(sys.argv) 16 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | services: 7 | app: 8 | build: . 9 | user: app 10 | command: /app/manage.py test 11 | volumes: 12 | - .:/app:cached 13 | environment: 14 | SHELL: /bin/bash 15 | IPYTHONDIR: /app/.ipython 16 | HISTFILE: /app/.bash_history 17 | PYTHONPATH: /app # make app available without installation 18 | restart: "no" 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | # code pushed to pull request branch 5 | push: 6 | branches-ignore: 7 | - main 8 | # when draft state is removed (needed as automatically created PRs are not triggering this action) 9 | pull_request: 10 | types: [ready_for_review] 11 | 12 | jobs: 13 | # lint code for errors 14 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/python-ruff.yaml 15 | lint: 16 | name: Lint 17 | permissions: 18 | contents: read 19 | uses: RegioHelden/github-reusable-workflows/.github/workflows/python-ruff.yaml@v2.8.0 20 | with: 21 | ruff-version: "0.14.9" 22 | -------------------------------------------------------------------------------- /.github/workflows/cron.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | name: Cron actions 7 | 8 | on: 9 | workflow_dispatch: 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | # synchronize labels from central definition at https://github.com/RegioHelden/.github/blob/main/labels.yaml 15 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/sync-labels.yaml 16 | update-labels: 17 | name: Update labels 18 | permissions: 19 | issues: write 20 | uses: RegioHelden/github-reusable-workflows/.github/workflows/sync-labels.yaml@v2.8.0 21 | -------------------------------------------------------------------------------- /.github/workflows/tag-release.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | name: Tag release 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | # create a new git tag when a version update was merged to main branch 15 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/tag-release.yaml 16 | tag-release: 17 | name: Create tag 18 | permissions: 19 | contents: write 20 | uses: RegioHelden/github-reusable-workflows/.github/workflows/tag-release.yaml@v2.8.0 21 | with: 22 | python-version: "3.12" 23 | secrets: 24 | personal-access-token: "${{ secrets.COMMIT_KEY }}" 25 | -------------------------------------------------------------------------------- /django_temporalio/tests/test_init_client.py: -------------------------------------------------------------------------------- 1 | from unittest import IsolatedAsyncioTestCase, mock 2 | 3 | from django.test import override_settings 4 | 5 | from django_temporalio.client import init_client 6 | from django_temporalio.conf import SETTINGS_KEY 7 | 8 | 9 | class InitClientTestCase(IsolatedAsyncioTestCase): 10 | """ 11 | Test case for init_client function. 12 | """ 13 | 14 | async def test_init_client(self): 15 | settings = { 16 | "CLIENT_CONFIG": {"foo": "bar"}, 17 | } 18 | 19 | with ( 20 | mock.patch("django_temporalio.client.Client.connect") as connect_mock, 21 | override_settings(**{SETTINGS_KEY: settings}), 22 | ): 23 | await init_client() 24 | 25 | connect_mock.assert_called_once_with(foo="bar") 26 | -------------------------------------------------------------------------------- /.github/workflows/open-release-pr.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | name: Open release PR 7 | 8 | on: 9 | workflow_dispatch: 10 | 11 | jobs: 12 | # trigger creation of a release pull request with version increase and changelog update 13 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/release-pull-request.yaml 14 | open-release-pr: 15 | name: Open release PR 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | uses: RegioHelden/github-reusable-workflows/.github/workflows/release-pull-request.yaml@v2.8.0 20 | secrets: 21 | personal-access-token: "${{ secrets.COMMIT_KEY }}" 22 | with: 23 | changelog-since-tag: "v1.2.0" 24 | -------------------------------------------------------------------------------- /.github/workflows/check_pull_request.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | name: Check pull request 7 | 8 | on: 9 | # when labels are added/removed or draft status gets changed to ready for review 10 | pull_request: 11 | types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review] 12 | 13 | jobs: 14 | # make sure the pull request matches our guidelines like having at least one label assigned 15 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/check-pull-request.yaml 16 | check-pull-request: 17 | name: Check pull request 18 | permissions: 19 | issues: write 20 | pull-requests: write 21 | uses: RegioHelden/github-reusable-workflows/.github/workflows/check-pull-request.yaml@v2.8.0 22 | -------------------------------------------------------------------------------- /example/temporalio/schedules.py: -------------------------------------------------------------------------------- 1 | from temporalio.client import ( 2 | Schedule, 3 | ScheduleActionStartWorkflow, 4 | ScheduleCalendarSpec, 5 | ScheduleRange, 6 | ScheduleSpec, 7 | ) 8 | 9 | from django_temporalio.registry import schedules 10 | from example.temporalio.queues import TestTaskQueues 11 | 12 | schedules.register( 13 | "do-cool-stuff-every-hour", 14 | Schedule( 15 | action=ScheduleActionStartWorkflow( 16 | "TestWorkflow", 17 | id="do-cool-stuff-every-hour", 18 | task_queue=TestTaskQueues.MAIN, 19 | ), 20 | spec=ScheduleSpec( 21 | calendars=[ 22 | ScheduleCalendarSpec( 23 | hour=[ScheduleRange(0, 23)], 24 | minute=[ScheduleRange(0)], 25 | second=[ScheduleRange(0)], 26 | ), 27 | ], 28 | ), 29 | ), 30 | ) 31 | -------------------------------------------------------------------------------- /django_temporalio/management/commands/show_temporalio_queue_registry.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from django_temporalio.registry import get_queue_registry 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Show django-temporalio queue registry." 8 | indent = 2 9 | 10 | def handle(self, *args, **options): 11 | for queue_name, item in get_queue_registry().items(): 12 | self.stdout.write(f"{queue_name}") 13 | for label, entities in [ 14 | ("workflows", item.workflows), 15 | ("activities", item.activities), 16 | ]: 17 | if not entities: 18 | continue 19 | 20 | self.stdout.write(f"{' ' * self.indent}{label}:") 21 | for entity in entities: 22 | self.stdout.write( 23 | f"{' ' * self.indent * 2}{entity.__module__}.{entity.__name__}", 24 | ) 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # dev env 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | .mypy_cache 40 | .ruff_cache 41 | .ipython 42 | .bash_history 43 | compose.override.yaml 44 | 45 | # Language specific 46 | __pycache__ 47 | *.pyc 48 | *.py[cod] 49 | *.sw* 50 | 51 | # Pycharm/Intellij 52 | .idea 53 | .pycharm_helpers 54 | 55 | # Complexity 56 | output/*.html 57 | output/*/index.html 58 | 59 | # Sphinx 60 | docs/_build 61 | 62 | # OS 63 | .bash_history 64 | 65 | # database 66 | db.sqlite3 67 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | FROM python:3.12-slim-bookworm 6 | 7 | ARG DEBIAN_FRONTEND=noninteractive 8 | ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=x LC_ALL=C.UTF-8 UV_COMPILE_BYTECODE=0 9 | 10 | COPY system_dependencies.txt /app/ 11 | 12 | RUN sys_deps=$(grep -v '^#' system_dependencies.txt | tr '\n' ' '); \ 13 | apt -y update && \ 14 | apt -y --no-install-recommends install pipx $sys_deps && \ 15 | apt clean && \ 16 | find /usr/share/man /usr/share/locale /usr/share/doc -type f -delete && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | WORKDIR /app 20 | 21 | RUN grep -q -w 1000 /etc/group || groupadd --gid 1000 app && \ 22 | id -u app >/dev/null 2>&1 || useradd --gid 1000 --uid 1000 -m app && \ 23 | chown app:app /app 24 | 25 | USER app 26 | 27 | COPY --chown=app requirements* /app/ 28 | 29 | ENV PATH=/home/app/.local/bin:/home/app/venv/bin:${PATH} DJANGO_SETTINGS_MODULE=example.settings 30 | 31 | RUN pipx install --force uv==0.9.17 && uv venv ~/venv && \ 32 | uv pip install --no-cache --upgrade --requirements /app/requirements-test.txt && \ 33 | uv cache clean 34 | 35 | EXPOSE 8000 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # ------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------- 4 | 5 | The MIT License (MIT) 6 | Copyright (c) RegioHelden GmbH 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in 16 | all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // ------------------------------------------- 2 | // Managed by modulesync - DO NOT EDIT 3 | // ------------------------------------------- 4 | 5 | // Defines standard actions that can be executed using the `Tasks: Run Task` command 6 | // See https://go.microsoft.com/fwlink/?LinkId=733558 for documentation 7 | // ------------------------------------------- 8 | { 9 | "version": "2.0.0", 10 | "problemMatcher": [], 11 | "presentation": { 12 | "reveal": "always", 13 | "panel": "new" 14 | }, 15 | "type": "shell", 16 | "tasks": [ 17 | { 18 | "label": "Test", 19 | "dependsOn": [ 20 | "Django: Run tests" 21 | ], 22 | // mark as the default build task so cmd/ctrl+shift+b will trigger it 23 | "group": { 24 | "kind": "test", 25 | "isDefault": true 26 | } 27 | }, 28 | { 29 | "label": "Django: Run tests", 30 | "command": "${config:python.defaultInterpreterPath}", 31 | "args": [ 32 | "manage.py", 33 | "test", 34 | "--no-input", 35 | "--parallel=2" 36 | ], 37 | "group": "test" 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /django_temporalio/tests/utils/test_autodiscover_modules.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from unittest import TestCase, mock 3 | 4 | from django.test import override_settings 5 | 6 | from django_temporalio.conf import SETTINGS_KEY, SettingIsNotSetError, settings 7 | from django_temporalio.utils import autodiscover_modules 8 | 9 | 10 | class AutodiscoverModulesTestCase(TestCase): 11 | """ 12 | Test case for utils.autodiscover_modules. 13 | """ 14 | 15 | @override_settings(**{SETTINGS_KEY: {"BASE_MODULE": "example.temporalio"}}) 16 | @mock.patch("django_temporalio.utils.import_module", wraps=import_module) 17 | def test_autodiscover_modules(self, import_module_mock): 18 | autodiscover_modules("*workflows*") 19 | 20 | import_module_mock.assert_has_calls( 21 | [ 22 | mock.call("example.temporalio"), 23 | mock.call("example.temporalio.workflows"), 24 | mock.call("example.temporalio.foo.foo_workflows"), 25 | mock.call("example.temporalio.foo.bar.workflows_bar"), 26 | ], 27 | ) 28 | 29 | def test_autodiscover_modules_raises_exception(self): 30 | self.assertIsNone(settings.BASE_MODULE) 31 | with self.assertRaises(SettingIsNotSetError): 32 | autodiscover_modules("*workflows*") 33 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | # code pushed to pull request branch 5 | push: 6 | branches-ignore: 7 | - main 8 | # when draft state is removed (needed as automatically created PRs are not triggering this action) 9 | pull_request: 10 | types: [ready_for_review] 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-24.04 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | django: 25 | - "4.2" 26 | - "5.0" 27 | - "5.1" 28 | exclude: 29 | - python-version: "3.13" 30 | django: "4.2" 31 | - python-version: "3.13" 32 | django: "5.0" 33 | 34 | steps: 35 | - uses: actions/checkout@v6 36 | with: 37 | persist-credentials: false 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v6 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | 44 | - name: Install the latest version of uv 45 | uses: astral-sh/setup-uv@v7 46 | 47 | - name: Install requirements 48 | run: uv pip install --system -r requirements-ci.txt 49 | 50 | - name: Install Django ${{ matrix.django }} 51 | run: uv pip install --system "Django~=${{ matrix.django }}" 52 | 53 | - name: Install package 54 | run: uv pip install --system -e . 55 | 56 | - name: Run tests 57 | run: python manage.py test 58 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish.yaml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------------- 4 | 5 | --- 6 | name: Build and publish 7 | 8 | on: 9 | push: 10 | tags: 11 | - "**" 12 | 13 | jobs: 14 | # build package, make release on github and upload to pypi when a new tag is pushed 15 | # see https://github.com/RegioHelden/github-reusable-workflows/blob/main/.github/workflows/build-and-publish.yaml 16 | build-and-release: 17 | name: Build and publish 18 | permissions: 19 | contents: write 20 | id-token: write 21 | uses: RegioHelden/github-reusable-workflows/.github/workflows/build-and-publish.yaml@v2.8.0 22 | with: 23 | python-version: "3.12" 24 | 25 | # must be defined in the repo as trusted publishing does not work with reusable workflows yet 26 | # see https://github.com/pypi/warehouse/issues/11096 27 | publish-pypi: 28 | name: Publish on PyPI 29 | runs-on: ubuntu-latest 30 | permissions: 31 | contents: read 32 | id-token: write 33 | needs: 34 | - build-and-release 35 | steps: 36 | - name: Set up Python 37 | uses: actions/setup-python@v6 38 | with: 39 | python-version: "3.12" 40 | 41 | - name: Install the latest version of uv 42 | uses: astral-sh/setup-uv@v7 43 | 44 | - name: Download the distribution packages 45 | uses: actions/download-artifact@v7 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | 50 | - name: Publish 51 | run: uv publish --trusted-publishing always 52 | -------------------------------------------------------------------------------- /django_temporalio/tests/registry/test_get_queue_registry.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | 3 | from django_temporalio.registry import QueueRegistryItem, get_queue_registry 4 | 5 | 6 | class GetQueueRegistryTestCase(TestCase): 7 | @mock.patch("django_temporalio.registry.queue_activities.get_registry") 8 | @mock.patch("django_temporalio.registry.queue_workflows.get_registry") 9 | def test_get_queue_registry( 10 | self, 11 | get_workflows_registry_mock, 12 | get_activities_registry_mock, 13 | ): 14 | """ 15 | Test that the queue registry is correctly built from the workflows and 16 | activities registries. 17 | """ 18 | get_workflows_registry_mock.return_value = { 19 | "TEST_QUEUE_1": ["TestWorkflow_1"], 20 | "TEST_QUEUE_2": ["TestWorkflow_2"], 21 | } 22 | get_activities_registry_mock.return_value = { 23 | "TEST_QUEUE_1": ["activity_1"], 24 | "TEST_QUEUE_2": ["activity_2"], 25 | } 26 | 27 | registry = get_queue_registry() 28 | 29 | get_workflows_registry_mock.assert_called_once_with() 30 | get_activities_registry_mock.assert_called_once_with() 31 | self.assertEqual( 32 | registry, 33 | { 34 | "TEST_QUEUE_1": QueueRegistryItem( 35 | workflows=["TestWorkflow_1"], 36 | activities=["activity_1"], 37 | ), 38 | "TEST_QUEUE_2": QueueRegistryItem( 39 | workflows=["TestWorkflow_2"], 40 | activities=["activity_2"], 41 | ), 42 | }, 43 | ) 44 | -------------------------------------------------------------------------------- /django_temporalio/tests/management_commands/test_show_temporalio_queue_registry.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest import TestCase, mock 3 | 4 | from django.core.management import call_command 5 | 6 | 7 | class ShowTemporalioQueueRegistryTestCase(TestCase): 8 | """ 9 | Test case for show_temporalio_queue_registry management command. 10 | """ 11 | 12 | def test_command(self): 13 | registry = { 14 | "TEST_QUEUE_1": mock.Mock( 15 | workflows=[mock.Mock(__name__="TestWorkflow_1")], 16 | activities=[mock.Mock(__name__="test_activity_1")], 17 | ), 18 | "TEST_QUEUE_2": mock.Mock( 19 | workflows=[mock.Mock(__name__="TestWorkflow_2")], 20 | activities=[mock.Mock(__name__="test_activity_2")], 21 | ), 22 | } 23 | 24 | with ( 25 | mock.patch( 26 | "django_temporalio.management.commands.show_temporalio_queue_registry.get_queue_registry", 27 | return_value=registry, 28 | ) as get_queue_registry_mock, 29 | StringIO() as stdout, 30 | ): 31 | call_command("show_temporalio_queue_registry", stdout=stdout) 32 | 33 | get_queue_registry_mock.assert_called_once_with() 34 | self.assertEqual( 35 | stdout.getvalue(), 36 | "TEST_QUEUE_1\n" 37 | " workflows:\n" 38 | " unittest.mock.TestWorkflow_1\n" 39 | " activities:\n" 40 | " unittest.mock.test_activity_1\n" 41 | "TEST_QUEUE_2\n" 42 | " workflows:\n" 43 | " unittest.mock.TestWorkflow_2\n" 44 | " activities:\n" 45 | " unittest.mock.test_activity_2\n", 46 | ) 47 | -------------------------------------------------------------------------------- /django_temporalio/conf.py: -------------------------------------------------------------------------------- 1 | """ 2 | Settings for django-temporalio are all namespaced in the DJANGO_TEMPORALIO setting. 3 | For example your project's `settings.py` file might look like this: 4 | 5 | DJANGO_TEMPORALIO = { 6 | # params passed to the `temporalio.client.Client.connect' method 7 | 'CLIENT_CONFIG': { 8 | 'target_host': 'localhost:7233', 9 | }, 10 | } 11 | 12 | This module provides the `settings` object, that is used to access 13 | django-temporalio settings, checking for user settings first, then falling 14 | back to the defaults. 15 | """ 16 | 17 | from django.conf import settings as django_settings 18 | from django.core.signals import setting_changed 19 | from django.dispatch import receiver 20 | 21 | SETTINGS_KEY = "DJANGO_TEMPORALIO" 22 | DEFAULTS = { 23 | "CLIENT_CONFIG": {}, 24 | "WORKER_CONFIGS": {}, 25 | "BASE_MODULE": None, 26 | } 27 | 28 | 29 | class SettingIsNotSetError(Exception): 30 | pass 31 | 32 | 33 | class Settings: 34 | def __init__(self): 35 | self.defaults = DEFAULTS 36 | 37 | @property 38 | def user_settings(self): 39 | if not hasattr(self, "_user_settings"): 40 | self._user_settings = getattr(django_settings, SETTINGS_KEY, {}) 41 | return self._user_settings 42 | 43 | def __getattr__(self, attr): 44 | if attr not in self.defaults: 45 | raise AttributeError(f"Invalid setting: '{attr}'") 46 | 47 | if attr in self.user_settings: 48 | return self.user_settings[attr] 49 | 50 | return self.defaults[attr] 51 | 52 | def reload(self): 53 | if hasattr(self, "_user_settings"): 54 | delattr(self, "_user_settings") 55 | 56 | 57 | settings = Settings() 58 | 59 | 60 | @receiver(setting_changed) 61 | def reload_settings(*args, **kwargs): 62 | if kwargs["setting"] == SETTINGS_KEY: 63 | settings.reload() 64 | -------------------------------------------------------------------------------- /django_temporalio/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import fnmatch 3 | import os 4 | from contextlib import asynccontextmanager 5 | from datetime import timedelta 6 | from importlib import import_module 7 | 8 | from temporalio import activity 9 | 10 | from django_temporalio.conf import SettingIsNotSetError, settings 11 | 12 | 13 | def autodiscover_modules(related_name_pattern: str): 14 | """ 15 | Autodiscover modules matching the related name pattern in the base module. 16 | 17 | Example for the following directory structure: 18 | 19 | foo/ 20 | workflows.py 21 | activities.py 22 | bar/ 23 | bar_workflows.py 24 | activities.py 25 | baz/ 26 | workflows_baz.py 27 | activities.py 28 | 29 | Calling `autodiscover_modules('*workflows*')` will discover the following modules: 30 | - foo.workflows 31 | - foo.bar.bar_workflows 32 | - foo.bar.baz.workflows_baz 33 | """ 34 | base_module_name = settings.BASE_MODULE 35 | 36 | if not base_module_name: 37 | raise SettingIsNotSetError("BASE_MODULE setting must be set.") 38 | 39 | base_module = import_module(base_module_name) 40 | base_module_path = base_module.__path__[0] 41 | 42 | for root, _, files in os.walk(base_module_path): 43 | for file in files: 44 | if not fnmatch.fnmatch(file, f"{related_name_pattern}.py"): 45 | continue 46 | 47 | module_name = root.replace(base_module_path, base_module_name).replace( 48 | os.sep, 49 | ".", 50 | ) 51 | import_module(f"{module_name}.{file[:-3]}") 52 | 53 | 54 | @asynccontextmanager 55 | async def heartbeat(interval: timedelta): 56 | stop = asyncio.Event() 57 | 58 | async def _heartbeat_loop(): 59 | while not stop.is_set(): 60 | activity.heartbeat() 61 | await asyncio.sleep(interval.seconds) 62 | 63 | try: 64 | yield 65 | finally: 66 | stop.set() 67 | await asyncio.create_task(_heartbeat_loop()) 68 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "enabled": true, 4 | "timezone": "Europe/Berlin", 5 | "extends": [ 6 | "config:recommended", 7 | ":semanticPrefixFixDepsChoreOthers", 8 | ":prConcurrentLimitNone", 9 | ":prHourlyLimitNone" 10 | ], 11 | "enabledManagers": [ 12 | "custom.regex", 13 | "github-actions" 14 | ], 15 | "dockerfile": { 16 | "enabled": false 17 | }, 18 | "docker-compose": { 19 | "enabled": false 20 | }, 21 | "github-actions": { 22 | "managerFilePatterns": [ 23 | ".github/workflows/workflow.yaml" 24 | ], 25 | "ignorePaths": [ 26 | ".github/workflows/!(workflow).yaml" 27 | ] 28 | }, 29 | "configMigration": false, 30 | "rangeStrategy": "pin", 31 | "vulnerabilityAlerts": { 32 | "labels": [ 33 | "security" 34 | ] 35 | }, 36 | "branchConcurrentLimit": 0, 37 | "rebaseWhen": "conflicted", 38 | "updateNotScheduled": true, 39 | "separateMajorMinor": false, 40 | "separateMultipleMajor": false, 41 | "separateMinorPatch": true, 42 | "autoApprove": false, 43 | "automerge": false, 44 | "labels": [ 45 | "infrastructure" 46 | ], 47 | "draftPR": false, 48 | "prCreation": "immediate", 49 | "gitAuthor": "Renovate bot ", 50 | "ignoreDeps": [], 51 | "customManagers": [ 52 | { 53 | "description": "regex match for workflow.yaml with datasource definition", 54 | "customType": "regex", 55 | "managerFilePatterns": [ 56 | "/^\\.github/workflows/workflow\\.yaml$/" 57 | ], 58 | "matchStrings": [ 59 | "# renovate: datasource=(?\\S+)\\s+depName=(?\\S+)\\s+\\S+:\\s+['\"](?[\\w+\\.]*)['\"]" 60 | ] 61 | } 62 | ], 63 | "packageRules": [ 64 | { 65 | "matchUpdateTypes": [ 66 | "major", 67 | "minor", 68 | "patch" 69 | ], 70 | "schedule": [ 71 | "before 5am" 72 | ] 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /django_temporalio/tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from django.conf import settings as django_settings 4 | from django.test.utils import override_settings 5 | 6 | from django_temporalio.conf import ( 7 | DEFAULTS, 8 | SETTINGS_KEY, 9 | ) 10 | from django_temporalio.conf import ( 11 | settings as temporalio_settings, 12 | ) 13 | 14 | 15 | class SettingsTestCase(TestCase): 16 | """ 17 | Test case for django_temporalio.conf.settings. 18 | """ 19 | 20 | def test_default_settings(self): 21 | self.assertFalse(hasattr(django_settings, SETTINGS_KEY)) 22 | self.assertEqual(temporalio_settings.CLIENT_CONFIG, DEFAULTS["CLIENT_CONFIG"]) 23 | self.assertEqual(temporalio_settings.WORKER_CONFIGS, DEFAULTS["WORKER_CONFIGS"]) 24 | self.assertEqual(temporalio_settings.BASE_MODULE, DEFAULTS["BASE_MODULE"]) 25 | 26 | def test_user_settings(self): 27 | user_settings = { 28 | "CLIENT_CONFIG": {"target_host": "temporal:7233"}, 29 | "WORKER_CONFIGS": {"main": "config"}, 30 | "BASE_MODULE": "example.temporalio", 31 | } 32 | with override_settings(**{SETTINGS_KEY: user_settings}): 33 | self.assertEqual( 34 | temporalio_settings.CLIENT_CONFIG, 35 | user_settings["CLIENT_CONFIG"], 36 | ) 37 | self.assertEqual( 38 | temporalio_settings.WORKER_CONFIGS, 39 | user_settings["WORKER_CONFIGS"], 40 | ) 41 | self.assertEqual( 42 | temporalio_settings.BASE_MODULE, 43 | user_settings["BASE_MODULE"], 44 | ) 45 | 46 | def test_fallback_to_defaults(self): 47 | user_settings = { 48 | "CLIENT_CONFIG": {"target_host": "temporal:7233"}, 49 | } 50 | with override_settings(**{SETTINGS_KEY: user_settings}): 51 | self.assertEqual( 52 | temporalio_settings.CLIENT_CONFIG, 53 | user_settings["CLIENT_CONFIG"], 54 | ) 55 | self.assertEqual( 56 | temporalio_settings.WORKER_CONFIGS, 57 | DEFAULTS["WORKER_CONFIGS"], 58 | ) 59 | self.assertEqual(temporalio_settings.BASE_MODULE, DEFAULTS["BASE_MODULE"]) 60 | 61 | def test_invalid_setting(self): 62 | with self.assertRaises(AttributeError): 63 | temporalio_settings.SOMETHING # noqa: B018 64 | -------------------------------------------------------------------------------- /django_temporalio/management/commands/sync_temporalio_schedules.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import partial 3 | 4 | from django.core.management.base import BaseCommand 5 | from temporalio.client import Schedule, ScheduleUpdate 6 | 7 | from django_temporalio.client import init_client 8 | from django_temporalio.registry import schedules 9 | 10 | 11 | class Command(BaseCommand): 12 | verbose = False 13 | dry_run = False 14 | help = "Syncs Temporal.io schedules." 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument( 18 | "-d", 19 | "--dry-run", 20 | action="store_true", 21 | default=False, 22 | help="Prints what would be done without actually doing it.", 23 | ) 24 | 25 | def log(self, msg: str): 26 | if self.verbose or self.dry_run: 27 | self.stdout.write(msg) 28 | 29 | async def sync_schedules(self): 30 | client = await init_client() 31 | current_schedule_ids = {s.id async for s in await client.list_schedules()} 32 | registry = schedules.get_registry() 33 | removed_schedule_ids = sorted(current_schedule_ids - set(registry)) 34 | updated_schedule_ids = [] 35 | new_schedule_ids = [] 36 | 37 | for schedule_id in removed_schedule_ids: 38 | if not self.dry_run: 39 | handle = client.get_schedule_handle(schedule_id) 40 | await handle.delete() 41 | self.log(f"Removed '{schedule_id}'") 42 | 43 | for schedule_id, schedule in registry.items(): 44 | if schedule_id in current_schedule_ids: 45 | if not self.dry_run: 46 | handle = client.get_schedule_handle(schedule_id) 47 | updater_fn = partial(self._schedule_updater_fn, schedule) 48 | await handle.update(updater_fn) 49 | updated_schedule_ids.append(schedule_id) 50 | self.log(f"Updated '{schedule_id}'") 51 | else: 52 | if not self.dry_run: 53 | await client.create_schedule(schedule_id, schedule) 54 | new_schedule_ids.append(schedule_id) 55 | self.log(f"Created '{schedule_id}'") 56 | 57 | self.stdout.write( 58 | f"removed {len(removed_schedule_ids)}, " 59 | f"updated {len(updated_schedule_ids)}, " 60 | f"created {len(new_schedule_ids)}", 61 | ) 62 | 63 | @staticmethod 64 | def _schedule_updater_fn(schedule: Schedule, _schedule_input) -> ScheduleUpdate: 65 | return ScheduleUpdate(schedule=schedule) 66 | 67 | def handle(self, *args, **options): 68 | self.verbose = int(options["verbosity"]) > 1 69 | self.dry_run = options["dry_run"] 70 | self.stdout.write(f"Syncing schedules{' [DRY RUN]' if self.dry_run else ''}...") 71 | asyncio.run(self.sync_schedules()) 72 | -------------------------------------------------------------------------------- /django_temporalio/management/commands/start_temporalio_worker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import sys 4 | 5 | from django.core.management import BaseCommand 6 | from temporalio.worker import Worker 7 | 8 | from django_temporalio.client import init_client 9 | from django_temporalio.conf import settings 10 | from django_temporalio.registry import get_queue_registry 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Starts Temporal.io worker." 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument( 18 | "worker_name", 19 | nargs="?", 20 | choices=settings.WORKER_CONFIGS.keys(), 21 | help="The name of the worker to start.", 22 | ) 23 | parser.add_argument( 24 | "-a", 25 | "--all", 26 | action="store_true", 27 | default=False, 28 | help=( 29 | "Start a worker per queue registered in the django-temporalio registry." 30 | " Meant for development purposes." 31 | ), 32 | ) 33 | 34 | async def start_dev_workers(self): 35 | client = await init_client() 36 | tasks = [] 37 | queues = [] 38 | 39 | for queue_name, item in get_queue_registry().items(): 40 | worker = Worker( 41 | client, 42 | task_queue=queue_name, 43 | workflows=item.workflows, 44 | activities=item.activities, 45 | ) 46 | tasks.append(worker.run()) 47 | queues.append(queue_name) 48 | 49 | self.stdout.write( 50 | f"Starting dev Temporal.io workers for queues: {', '.join(queues)}\n" 51 | f"(press ctrl-c to stop)...", 52 | ) 53 | await asyncio.gather(*tasks) 54 | 55 | async def start_worker(self, name): 56 | worker_config = settings.WORKER_CONFIGS[name] 57 | queue_name = worker_config["task_queue"] 58 | registry = get_queue_registry().get(queue_name) 59 | 60 | if not registry: 61 | self.stderr.write( 62 | f"Failed to start '{name}' worker.\n" 63 | f"No activities/workflows registered for queue '{queue_name}'.", 64 | ) 65 | sys.exit(1) 66 | 67 | client = await init_client() 68 | worker = Worker( 69 | client, 70 | **worker_config, 71 | workflows=registry.workflows, 72 | activities=registry.activities, 73 | ) 74 | self.stdout.write( 75 | f"Starting '{name}' worker for '{queue_name}' queue\n" 76 | f"(press ctrl-c to stop)...", 77 | ) 78 | await worker.run() 79 | 80 | def handle(self, *args, **options): 81 | worker_name = options["worker_name"] 82 | run_all = options["all"] 83 | 84 | if not worker_name and not run_all: 85 | self.stderr.write("You must provide either a worker name or --all flag.") 86 | sys.exit(2) 87 | 88 | with contextlib.suppress(KeyboardInterrupt): 89 | asyncio.run( 90 | ( 91 | self.start_dev_workers() 92 | if run_all 93 | else self.start_worker(worker_name) 94 | ), 95 | ) 96 | -------------------------------------------------------------------------------- /django_temporalio/tests/registry/test_schedules.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | 3 | from django.test import override_settings 4 | from temporalio.client import ( 5 | Schedule, 6 | ScheduleActionStartWorkflow, 7 | ScheduleCalendarSpec, 8 | ScheduleRange, 9 | ScheduleSpec, 10 | ) 11 | 12 | from django_temporalio.conf import SETTINGS_KEY 13 | from django_temporalio.registry import schedules 14 | from django_temporalio.utils import autodiscover_modules 15 | from example.temporalio.queues import TestTaskQueues 16 | 17 | 18 | class ScheduleRegistryTestCase(TestCase): 19 | """ 20 | Test case for schedules registry. 21 | """ 22 | 23 | @classmethod 24 | def setUpClass(cls): 25 | cls.schedule_id = "test-schedule" 26 | cls.schedule = Schedule( 27 | action=ScheduleActionStartWorkflow( 28 | "TestWorkflow", 29 | id="do-something-every-hour", 30 | task_queue=TestTaskQueues.MAIN, 31 | ), 32 | spec=ScheduleSpec( 33 | calendars=[ 34 | ScheduleCalendarSpec( 35 | hour=[ScheduleRange(0, 23)], 36 | minute=[ScheduleRange(0)], 37 | second=[ScheduleRange(0)], 38 | ), 39 | ], 40 | ), 41 | ) 42 | 43 | def tearDown(self): 44 | schedules.clear_registry() 45 | 46 | @override_settings(**{SETTINGS_KEY: {"BASE_MODULE": "example.temporalio"}}) 47 | @mock.patch( 48 | "django_temporalio.registry.autodiscover_modules", 49 | wraps=autodiscover_modules, 50 | ) 51 | @mock.patch.object(schedules, "register", wraps=schedules.register) 52 | def test_get_registry(self, mock_register, mock_autodiscover_modules): 53 | """ 54 | Test that schedules defined in schedules.py are automatically registered when 55 | the registry is accessed. 56 | """ 57 | registry = schedules.get_registry() 58 | 59 | mock_register.assert_called_once() 60 | mock_autodiscover_modules.assert_called_once_with("*schedules*") 61 | self.assertEqual(len(registry), 1) 62 | self.assertIn("do-cool-stuff-every-hour", registry) 63 | 64 | @mock.patch("django_temporalio.registry.autodiscover_modules") 65 | def test_register(self, _): 66 | """ 67 | Test that a schedule can be registered. 68 | """ 69 | schedules.register(self.schedule_id, self.schedule) 70 | 71 | registry = schedules.get_registry() 72 | self.assertIn(self.schedule_id, registry) 73 | self.assertEqual(registry[self.schedule_id], self.schedule) 74 | 75 | def test_already_registered_exception(self): 76 | """ 77 | Test that an exception is raised when attempting to register a schedule with 78 | the same ID. 79 | """ 80 | schedules.register(self.schedule_id, self.schedule) 81 | 82 | with self.assertRaises(schedules.AlreadyRegisteredError): 83 | schedules.register(self.schedule_id, self.schedule) 84 | 85 | @mock.patch("django_temporalio.registry.autodiscover_modules") 86 | def test_clear_registry(self, _): 87 | """ 88 | Test that the registry can be cleared. 89 | """ 90 | schedules.register(self.schedule_id, self.schedule) 91 | 92 | schedules.clear_registry() 93 | 94 | self.assertEqual(len(schedules.get_registry()), 0) 95 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # ------------------------------------------- 2 | # Managed by modulesync - DO NOT EDIT 3 | # ------------------------------------------- 4 | 5 | [project] 6 | name = "django-temporalio" 7 | dynamic = ["version"] 8 | license = "MIT" 9 | requires-python = ">=3.11" 10 | description = "Temporal.io integration for Django" 11 | readme = "README.md" 12 | keywords = ["django", "temporal", "temporalio", "temporal.io"] 13 | authors = [ 14 | { name = "RegioHelden GmbH", email = "opensource@regiohelden.de" }, 15 | ] 16 | maintainers = [ 17 | { name = "RegioHelden GmbH", email = "opensource@regiohelden.de" }, 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Topic :: Software Development", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | ] 34 | dependencies = ["django>=4.2,<6.0", "temporalio>=1.6.0"] 35 | 36 | [project.urls] 37 | Homepage = "https://github.com/RegioHelden/django-temporalio" 38 | Documentation = "https://github.com/RegioHelden/django-temporalio/blob/main/README.md" 39 | Repository = "https://github.com/RegioHelden/django-temporalio.git" 40 | Issues = "https://github.com/RegioHelden/django-temporalio/issues" 41 | Changelog = "https://github.com/RegioHelden/django-temporalio/blob/main/CHANGELOG.md" 42 | 43 | [build-system] 44 | requires = ["hatchling"] 45 | build-backend = "hatchling.build" 46 | 47 | [tool.hatch.version] 48 | path = "version" 49 | pattern = "(?P.+)" 50 | 51 | [tool.hatch.build.targets.wheel] 52 | include = ["LICENSE", "README.md", "CHANGELOG.md", "django_temporalio/*"] 53 | 54 | [tool.hatch.build.targets.sdist] 55 | include = ["LICENSE", "README.md", "CHANGELOG.md", "django_temporalio/*"] 56 | 57 | [tool.ruff] 58 | exclude = [ 59 | ".cache", 60 | ".git", 61 | "__pycache", 62 | "docs", 63 | "migrations", 64 | "src", 65 | ] 66 | line-length = 88 67 | 68 | [tool.ruff.lint] 69 | dummy-variable-rgx = "_|dummy" 70 | # See https://docs.astral.sh/ruff/rules/ for all supported rules 71 | select = [ 72 | "A", # flake8-builtins 73 | "B", # flake8-bugbear 74 | "BLE", # flake8-blind-except 75 | "C4", # flake8-comprehensions 76 | "C90", # mccabe 77 | "COM", # flake8-commas 78 | "DJ", # flake8-django 79 | "DTZ", # flake8-datetimez 80 | "E", # pycodestyle 81 | "ERA", # eradicate 82 | "F", # pyflakes 83 | "G", # flake8-logging-format 84 | "I", # isort 85 | "ICN", # flake8-import-conventions 86 | "INP", # flake8-no-pep420 87 | "N", # pep8-naming 88 | "PIE", # flake8-pie 89 | "PGH", # pygrep-hooks 90 | "PL", # pylint 91 | "PTH", # flake8-use-pathlib 92 | "RET", # flake8-return 93 | "RSE", # flake8-raise 94 | "RUF", # ruff-specific rules 95 | "S", # flake8-bandit 96 | "SIM", # flake8-simplify 97 | "T20", # flake8-print 98 | "TID", # flake8-tidy-imports 99 | "UP", # pyupgrade 100 | "W", # pycodestyle 101 | "YTT", # flake8-2020 102 | ] 103 | 104 | [tool.ruff.lint.pycodestyle] 105 | max-line-length = 88 106 | 107 | [tool.ruff.lint.mccabe] 108 | max-complexity = 16 109 | 110 | [tool.coverage.run] 111 | branch = true 112 | 113 | [tool.coverage.report] 114 | omit = ["*site-packages*", "*tests*", "*.tox*"] 115 | show_missing = true 116 | exclude_lines = ["raise NotImplementedError"] 117 | -------------------------------------------------------------------------------- /django_temporalio/registry.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Sequence 2 | from dataclasses import dataclass, field 3 | from functools import wraps 4 | 5 | from temporalio.client import Schedule 6 | 7 | from django_temporalio.utils import autodiscover_modules 8 | 9 | 10 | class ScheduleRegistry: 11 | _registry: dict[str, Schedule] 12 | 13 | class AlreadyRegisteredError(Exception): 14 | pass 15 | 16 | def __init__(self): 17 | self._init_registry() 18 | 19 | def _init_registry(self): 20 | self._registry = {} 21 | 22 | def register(self, schedule_id: str, schedule: Schedule): 23 | if schedule_id in self._registry: 24 | raise self.AlreadyRegisteredError( 25 | f"Schedule with ID '{schedule_id}' is already registered.", 26 | ) 27 | self._registry[schedule_id] = schedule 28 | 29 | def get_registry(self): 30 | autodiscover_modules("*schedules*") 31 | return self._registry 32 | 33 | def clear_registry(self): 34 | self._init_registry() 35 | 36 | 37 | class QueueRegistry: 38 | module_name: str 39 | check_attr: str 40 | _registry: dict[str, list] 41 | _registered_object_ids: set 42 | 43 | class MissingTemporalDecoratorError(Exception): 44 | pass 45 | 46 | def __init__(self, module_name: str, check_attr: str): 47 | self.module_name = module_name 48 | self.check_attr = check_attr 49 | self._init_registry() 50 | 51 | def _init_registry(self): 52 | self._registry = {} 53 | self._registered_object_ids = set() 54 | 55 | @staticmethod 56 | def _make_id(obj: Callable): 57 | return f"{obj.__module__}.{obj.__name__}" 58 | 59 | def register(self, *queue_names: str): 60 | if not queue_names: 61 | raise ValueError("At least one queue name must be provided.") 62 | 63 | @wraps(*queue_names) 64 | def decorator(obj): 65 | if not hasattr(obj, self.check_attr): 66 | raise self.MissingTemporalDecoratorError( 67 | f"'{self._make_id(obj)}' must be decorated with 'defn' Temporal.io" 68 | "decorator.\n" 69 | "See https://github.com/temporalio/sdk-python/blob/main/README.md", 70 | ) 71 | 72 | if (obj_id := self._make_id(obj)) not in self._registered_object_ids: 73 | self._registered_object_ids.add(obj_id) 74 | for queue_name in queue_names: 75 | self._registry.setdefault(queue_name, []).append(obj) 76 | 77 | return obj 78 | 79 | return decorator 80 | 81 | def clear_registry(self): 82 | self._init_registry() 83 | 84 | def get_registry(self): 85 | autodiscover_modules(self.module_name) 86 | return self._registry 87 | 88 | 89 | schedules = ScheduleRegistry() 90 | queue_workflows = QueueRegistry("*workflows*", "__temporal_workflow_definition") 91 | queue_activities = QueueRegistry("*activities*", "__temporal_activity_definition") 92 | 93 | 94 | @dataclass 95 | class QueueRegistryItem: 96 | workflows: Sequence[type] = field(default_factory=list) 97 | activities: Sequence[Callable] = field(default_factory=list) 98 | 99 | 100 | def get_queue_registry(): 101 | """ 102 | merges the workflows and activities registries 103 | """ 104 | result: dict[str, QueueRegistryItem] = { 105 | queue_name: QueueRegistryItem( 106 | workflows=workflows, 107 | ) 108 | for queue_name, workflows in queue_workflows.get_registry().items() 109 | } 110 | 111 | for queue_name, activities in queue_activities.get_registry().items(): 112 | result.setdefault(queue_name, QueueRegistryItem()).activities = activities 113 | return result 114 | -------------------------------------------------------------------------------- /django_temporalio/tests/registry/test_queue_workflows.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | 3 | from django.test import override_settings 4 | from temporalio import workflow 5 | 6 | from django_temporalio.conf import SETTINGS_KEY 7 | from django_temporalio.registry import autodiscover_modules, queue_workflows 8 | from example.temporalio.queues import TestTaskQueues 9 | 10 | 11 | @workflow.defn 12 | class TestWorkflow: 13 | @workflow.run 14 | async def run(self): 15 | pass 16 | 17 | 18 | class QueueWorkflowRegistryTestCase(TestCase): 19 | """ 20 | Test case for queue_workflows registry. 21 | """ 22 | 23 | def tearDown(self): 24 | queue_workflows.clear_registry() 25 | 26 | @override_settings(**{SETTINGS_KEY: {"BASE_MODULE": "example.temporalio"}}) 27 | @mock.patch( 28 | "django_temporalio.registry.autodiscover_modules", 29 | wraps=autodiscover_modules, 30 | ) 31 | @mock.patch( 32 | "django_temporalio.registry.queue_workflows.register", 33 | wraps=queue_workflows.register, 34 | ) 35 | def test_get_registry(self, mock_register, mock_autodiscover_modules): 36 | """ 37 | Test that workflows defined in workflows.py are automatically registered when 38 | the registry is accessed. 39 | """ 40 | registry = queue_workflows.get_registry() 41 | 42 | mock_register.assert_called_once_with(TestTaskQueues.MAIN) 43 | mock_autodiscover_modules.assert_called_once_with("*workflows*") 44 | self.assertEqual(len(registry), 1) 45 | self.assertIn(TestTaskQueues.MAIN, registry) 46 | workflows = registry[TestTaskQueues.MAIN] 47 | self.assertEqual(len(workflows), 1) 48 | self.assertEqual( 49 | "example.temporalio.workflows.TestWorkflow", 50 | f"{workflows[0].__module__}.{workflows[0].__name__}", 51 | ) 52 | 53 | @mock.patch("django_temporalio.registry.autodiscover_modules") 54 | def test_register(self, _): 55 | """ 56 | Test that a workflow can be registered. 57 | """ 58 | queue_workflows.register(TestTaskQueues.MAIN)(TestWorkflow) 59 | 60 | registry = queue_workflows.get_registry() 61 | self.assertIn(TestTaskQueues.MAIN, registry) 62 | self.assertIn(TestWorkflow, registry[TestTaskQueues.MAIN]) 63 | 64 | @mock.patch("django_temporalio.registry.autodiscover_modules") 65 | def test_register_multiple_queues(self, _): 66 | """ 67 | Test that a workflow can be registered with multiple queues. 68 | """ 69 | queue_workflows.register( 70 | TestTaskQueues.MAIN, 71 | TestTaskQueues.HIGH_PRIORITY, 72 | )(TestWorkflow) 73 | 74 | registry = queue_workflows.get_registry() 75 | self.assertIn(TestTaskQueues.MAIN, registry) 76 | self.assertIn(TestTaskQueues.HIGH_PRIORITY, registry) 77 | self.assertIn(TestWorkflow, registry[TestTaskQueues.MAIN]) 78 | self.assertIn(TestWorkflow, registry[TestTaskQueues.HIGH_PRIORITY]) 79 | 80 | @mock.patch("django_temporalio.registry.autodiscover_modules") 81 | def test_registry_uniqueness(self, _): 82 | """ 83 | Test that a workflow can only be registered once. 84 | """ 85 | queue_workflows.register(TestTaskQueues.MAIN)(TestWorkflow) 86 | queue_workflows.register(TestTaskQueues.MAIN)(TestWorkflow) 87 | 88 | registry = queue_workflows.get_registry() 89 | self.assertIn(TestTaskQueues.MAIN, registry) 90 | workflows = registry[TestTaskQueues.MAIN] 91 | self.assertEqual(len(workflows), 1) 92 | self.assertEqual(workflows[0], TestWorkflow) 93 | 94 | def test_register_no_queue(self): 95 | """ 96 | Test that an exception is raised when a workflow is registered without a queue. 97 | """ 98 | with self.assertRaises(ValueError): 99 | queue_workflows.register() 100 | 101 | @mock.patch("django_temporalio.registry.autodiscover_modules") 102 | def test_register_failure_on_missing_temporal_decorators(self, _): 103 | """ 104 | Test that an exception is raised when a workflow class is not decorated with 105 | Temporal.io decorator. 106 | """ 107 | with self.assertRaises(queue_workflows.MissingTemporalDecoratorError): 108 | 109 | @queue_workflows.register(TestTaskQueues.MAIN) 110 | class TestWorkflow: 111 | pass 112 | 113 | self.assertDictEqual(queue_workflows.get_registry(), {}) 114 | 115 | @mock.patch("django_temporalio.registry.autodiscover_modules") 116 | def test_clear_registry(self, _): 117 | """ 118 | Test that the registry can be cleared. 119 | """ 120 | queue_workflows.register(TestTaskQueues.MAIN)(TestWorkflow) 121 | 122 | queue_workflows.clear_registry() 123 | 124 | self.assertDictEqual(queue_workflows.get_registry(), {}) 125 | -------------------------------------------------------------------------------- /django_temporalio/tests/registry/test_queue_activities.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | 3 | from django.test import override_settings 4 | from temporalio import activity 5 | 6 | from django_temporalio.conf import SETTINGS_KEY 7 | from django_temporalio.registry import queue_activities 8 | from django_temporalio.utils import autodiscover_modules 9 | from example.temporalio.queues import TestTaskQueues 10 | 11 | 12 | @activity.defn 13 | def test_activity(): 14 | pass 15 | 16 | 17 | class QueueActivityRegistryTestCase(TestCase): 18 | """ 19 | Test case for queue_activities registry. 20 | """ 21 | 22 | def tearDown(self): 23 | queue_activities.clear_registry() 24 | 25 | @override_settings(**{SETTINGS_KEY: {"BASE_MODULE": "example.temporalio"}}) 26 | @mock.patch( 27 | "django_temporalio.registry.autodiscover_modules", 28 | wraps=autodiscover_modules, 29 | ) 30 | @mock.patch( 31 | "django_temporalio.registry.queue_activities.register", 32 | wraps=queue_activities.register, 33 | ) 34 | def test_get_registry(self, mock_register, mock_autodiscover_modules): 35 | """ 36 | Test that activities defined in activities.py are automatically registered when 37 | the registry is accessed. 38 | """ 39 | registry = queue_activities.get_registry() 40 | 41 | mock_register.assert_called_once_with(TestTaskQueues.MAIN) 42 | mock_autodiscover_modules.assert_called_once_with("*activities*") 43 | self.assertEqual(len(registry), 1) 44 | self.assertIn(TestTaskQueues.MAIN, registry) 45 | activities = registry[TestTaskQueues.MAIN] 46 | self.assertEqual(len(activities), 1) 47 | self.assertEqual( 48 | f"{activities[0].__module__}.{activities[0].__name__}", 49 | "example.temporalio.activities.test_activity", 50 | ) 51 | 52 | @mock.patch("django_temporalio.registry.autodiscover_modules") 53 | def test_register(self, _): 54 | """ 55 | Test that an activity can be registered. 56 | """ 57 | queue_activities.register(TestTaskQueues.MAIN)(test_activity) 58 | 59 | registry = queue_activities.get_registry() 60 | self.assertIn(TestTaskQueues.MAIN, registry) 61 | self.assertIn(test_activity, registry[TestTaskQueues.MAIN]) 62 | 63 | @mock.patch("django_temporalio.registry.autodiscover_modules") 64 | def test_register_multiple_queues(self, _): 65 | """ 66 | Test that an activity can be registered with multiple queues. 67 | """ 68 | queue_activities.register( 69 | TestTaskQueues.MAIN, 70 | TestTaskQueues.HIGH_PRIORITY, 71 | )(test_activity) 72 | 73 | registry = queue_activities.get_registry() 74 | self.assertIn(TestTaskQueues.MAIN, registry) 75 | self.assertIn(TestTaskQueues.HIGH_PRIORITY, registry) 76 | self.assertIn(test_activity, registry[TestTaskQueues.MAIN]) 77 | self.assertIn(test_activity, registry[TestTaskQueues.HIGH_PRIORITY]) 78 | 79 | @mock.patch("django_temporalio.registry.autodiscover_modules") 80 | def test_registry_uniqueness(self, _): 81 | """ 82 | Test that an activity can only be registered once. 83 | """ 84 | queue_activities.register(TestTaskQueues.MAIN)(test_activity) 85 | queue_activities.register(TestTaskQueues.MAIN)(test_activity) 86 | 87 | registry = queue_activities.get_registry() 88 | self.assertIn(TestTaskQueues.MAIN, registry) 89 | activities = registry[TestTaskQueues.MAIN] 90 | self.assertEqual(len(activities), 1) 91 | self.assertEqual(activities[0], test_activity) 92 | 93 | def test_register_no_queue(self): 94 | """ 95 | Test that an exception is raised when an activity is registered without a queue. 96 | """ 97 | with self.assertRaises(ValueError): 98 | queue_activities.register() 99 | 100 | @mock.patch("django_temporalio.registry.autodiscover_modules") 101 | def test_register_failure_on_missing_temporal_decorators(self, _): 102 | """ 103 | Test that an exception is raised when an activity function is not decorated with 104 | Temporal.io decorator. 105 | """ 106 | with self.assertRaises(queue_activities.MissingTemporalDecoratorError): 107 | 108 | @queue_activities.register(TestTaskQueues.MAIN) 109 | def test_activity(): 110 | pass 111 | 112 | self.assertDictEqual(queue_activities.get_registry(), {}) 113 | 114 | @mock.patch("django_temporalio.registry.autodiscover_modules") 115 | def test_clear_registry(self, _): 116 | """ 117 | Test that the registry can be cleared. 118 | """ 119 | queue_activities.register(TestTaskQueues.MAIN)(test_activity) 120 | 121 | queue_activities.clear_registry() 122 | 123 | self.assertDictEqual(queue_activities.get_registry(), {}) 124 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v1.4.0](https://github.com/RegioHelden/django-temporalio/tree/v1.4.0) (2025-07-04) 4 | 5 | [Full Changelog](https://github.com/RegioHelden/django-temporalio/compare/v1.3.1...v1.4.0) 6 | 7 | **Implemented enhancements:** 8 | 9 | - refactor: improve schedule update handling in `sync_temporalio_schedules` command [\#48](https://github.com/RegioHelden/django-temporalio/pull/48) (@krayevidi) 10 | 11 | ## [v1.3.1](https://github.com/RegioHelden/django-temporalio/tree/v1.3.1) (2025-07-03) 12 | 13 | [Full Changelog](https://github.com/RegioHelden/django-temporalio/compare/v1.3.0...v1.3.1) 14 | 15 | **Fixed bugs:** 16 | 17 | - fix: pass proper update object [\#46](https://github.com/RegioHelden/django-temporalio/pull/46) (@krayevidi) 18 | 19 | **Merged pull requests:** 20 | 21 | - Update uv to 0.7.19 [\#45](https://github.com/RegioHelden/django-temporalio/pull/45) (@regiohelden-dev) 22 | - Update uv to 0.7.18 [\#44](https://github.com/RegioHelden/django-temporalio/pull/44) (@regiohelden-dev) 23 | - Update uv to 0.7.17 [\#43](https://github.com/RegioHelden/django-temporalio/pull/43) (@regiohelden-dev) 24 | - Update uv to 0.7.15 [\#42](https://github.com/RegioHelden/django-temporalio/pull/42) (@regiohelden-dev) 25 | 26 | ## [v1.3.0](https://github.com/RegioHelden/django-temporalio/tree/v1.3.0) (2025-06-25) 27 | 28 | [Full Changelog](https://github.com/RegioHelden/django-temporalio/compare/v1.2.0...v1.3.0) 29 | 30 | **Implemented enhancements:** 31 | 32 | - feat: add "heartbeat" context manager. [\#38](https://github.com/RegioHelden/django-temporalio/pull/38) (@bodja) 33 | - Update reusable workflows [\#35](https://github.com/RegioHelden/django-temporalio/pull/35) (@lociii) 34 | - Bump django in test dependencies from 5.2 to 5.2.2 [\#31](https://github.com/RegioHelden/django-temporalio/pull/31) (@dependabot[bot]) 35 | - Bump setuptools from 78.1.0 to 78.1.1 [\#28](https://github.com/RegioHelden/django-temporalio/pull/28) (@dependabot[bot]) 36 | - Migrate to resuable workflows, align with RH library setup [\#9](https://github.com/RegioHelden/django-temporalio/pull/9) (@lociii) 37 | 38 | **Merged pull requests:** 39 | 40 | - Updates GitHub reusable workflows to 2.2.4 [\#37](https://github.com/RegioHelden/django-temporalio/pull/37) (@regiohelden-dev) 41 | - Update uv to 0.7.14 [\#36](https://github.com/RegioHelden/django-temporalio/pull/36) (@regiohelden-dev) 42 | - Updates ruff VSCode integration to 2025.24.0 [\#34](https://github.com/RegioHelden/django-temporalio/pull/34) (@regiohelden-dev) 43 | - Update uv to 0.7.13 [\#33](https://github.com/RegioHelden/django-temporalio/pull/33) (@regiohelden-dev) 44 | - Update uv to 0.7.12 [\#32](https://github.com/RegioHelden/django-temporalio/pull/32) (@regiohelden-dev) 45 | - Update uv to 0.7.11 [\#30](https://github.com/RegioHelden/django-temporalio/pull/30) (@regiohelden-dev) 46 | - Update uv to 0.7.8 [\#29](https://github.com/RegioHelden/django-temporalio/pull/29) (@regiohelden-dev) 47 | - Update uv to 0.7.7 [\#27](https://github.com/RegioHelden/django-temporalio/pull/27) (@regiohelden-dev) 48 | - Update uv to 0.7.6 [\#26](https://github.com/RegioHelden/django-temporalio/pull/26) (@regiohelden-dev) 49 | - Update uv to 0.7.5 [\#25](https://github.com/RegioHelden/django-temporalio/pull/25) (@regiohelden-dev) 50 | - Update uv to 0.7.4 [\#24](https://github.com/RegioHelden/django-temporalio/pull/24) (@regiohelden-dev) 51 | - Update uv to 0.7.3 [\#23](https://github.com/RegioHelden/django-temporalio/pull/23) (@regiohelden-dev) 52 | - Updates from modulesync [\#22](https://github.com/RegioHelden/django-temporalio/pull/22) (@regiohelden-dev) 53 | - Updates from modulesync [\#21](https://github.com/RegioHelden/django-temporalio/pull/21) (@regiohelden-dev) 54 | - Updates from modulesync [\#20](https://github.com/RegioHelden/django-temporalio/pull/20) (@regiohelden-dev) 55 | - Updates from modulesync [\#19](https://github.com/RegioHelden/django-temporalio/pull/19) (@regiohelden-dev) 56 | - Updates from modulesync [\#18](https://github.com/RegioHelden/django-temporalio/pull/18) (@regiohelden-dev) 57 | - Updates from modulesync [\#17](https://github.com/RegioHelden/django-temporalio/pull/17) (@regiohelden-dev) 58 | - Updates from modulesync [\#16](https://github.com/RegioHelden/django-temporalio/pull/16) (@regiohelden-dev) 59 | - Updates from modulesync [\#15](https://github.com/RegioHelden/django-temporalio/pull/15) (@regiohelden-dev) 60 | - Bump regiohelden/github-reusable-workflows from 2.0.0 to 2.1.0 [\#14](https://github.com/RegioHelden/django-temporalio/pull/14) (@dependabot[bot]) 61 | - Updates from modulesync [\#13](https://github.com/RegioHelden/django-temporalio/pull/13) (@regiohelden-dev) 62 | - Preparations for modulesync rollout [\#12](https://github.com/RegioHelden/django-temporalio/pull/12) (@lociii) 63 | - Renovate [\#6](https://github.com/RegioHelden/django-temporalio/pull/6) (@lociii) 64 | - add possibility to override all client connection settings [\#5](https://github.com/RegioHelden/django-temporalio/pull/5) (@krayevidi) 65 | - Bump django from 5.0.7 to 5.0.8 [\#4](https://github.com/RegioHelden/django-temporalio/pull/4) (@dependabot[bot]) 66 | - Temporalio encapsulation [\#3](https://github.com/RegioHelden/django-temporalio/pull/3) (@krayevidi) 67 | 68 | ## 1.2.0 (2024-10-17) 69 | 70 | **Breaking changes:** 71 | 72 | * replaced `NAMESPACE` and `URL` settings with `CLIENT_CONFIG` setting 73 | 74 | ## 1.1.0 (2024-05-30) 75 | 76 | **Implemented enhancements:** 77 | 78 | * add Temporal.io related code encapsulation 79 | 80 | ## 1.0.0 (2024-05-16) 81 | 82 | **Implemented enhancements:** 83 | 84 | * Initial release 85 | 86 | 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-temporalio 2 | ___ 3 | 4 | A small Django app that provides helpers for integrating [Temporal.io](https://temporal.io/) with Django. 5 | 6 | ## Features 7 | 8 | - Registry: Provides a registry that holds mappings between queue names and registered activities and workflows. 9 | - Management Commands: Includes management commands to manage Temporal.io workers and sync schedules. 10 | 11 | ## Installation 12 | 13 | You can install `django_temporalio` using pip: 14 | 15 | ```bash 16 | $ pip install django-temporalio 17 | ``` 18 | 19 | Add `django_temporalio` to your `INSTALLED_APPS`: 20 | 21 | ```python 22 | INSTALLED_APPS = [ 23 | ... 24 | 'django_temporalio.apps.DjangoTemporalioConfig', 25 | ... 26 | ] 27 | ``` 28 | 29 | Add the following settings to your `settings.py`: 30 | 31 | ```python 32 | from temporalio.worker import WorkerConfig 33 | 34 | DJANGO_TEMPORALIO = { 35 | "CLIENT_CONFIG": { 36 | "target_host": "localhost:7233", 37 | }, 38 | "BASE_MODULE": "path.to.module", 39 | "WORKER_CONFIGS": { 40 | "main": WorkerConfig( 41 | task_queue="MAIN_TASK_QUEUE", 42 | ... 43 | ), 44 | ... 45 | }, 46 | } 47 | ``` 48 | 49 | ## Usage 50 | 51 | Activities, workflows and schedules should be placed inside the base module defined by the `BASE_MODULE` setting, 52 | preferably outside of any Django application, in order to keep the uses of 53 | the [imports_passed_through](https://python.temporal.io/temporalio.workflow.unsafe.html) context manager encapsulated 54 | inside the module, along with Temporal.io related code. 55 | 56 | ### Workflow and Activity Registry 57 | 58 | The registry is a singleton that holds mappings between queue names and registered activities and workflows. 59 | You can register activities and workflows using the `register` method. 60 | 61 | Activities and workflows should be declared in modules matching the following patterns `*workflows*.py` and 62 | `*activities*.py` respectively. 63 | 64 | ```python 65 | from temporalio import activity, workflow 66 | from django_temporalio.registry import queue_activities, queue_workflows 67 | 68 | @queue_activities.register("HIGH_PRIORITY_TASK_QUEUE", "MAIN_TASK_QUEUE") 69 | @activity.defn 70 | def my_activity(): 71 | pass 72 | 73 | @queue_workflows.register("HIGH_PRIORITY_TASK_QUEUE", "MAIN_TASK_QUEUE") 74 | @workflow.defn 75 | class MyWorkflow: 76 | pass 77 | ``` 78 | 79 | ### Schedule Registry 80 | 81 | You can register schedules using the `register` method. 82 | 83 | Schedules should be declared in `schedules.py` module. 84 | 85 | ```python 86 | from django_temporalio.registry import schedules 87 | from temporalio.client import Schedule 88 | 89 | 90 | schedules.register("do-cool-stuff-every-hour", Schedule(...)) 91 | ``` 92 | 93 | ### Heartbeat 94 | Good practice for long-running activities is setting up a `heartbeat_timeout` and calling heartbeat periodically to make sure the activity is still alive. 95 | This can be achieved by setting up providing `heartbeat_timeout` when starting the activity and calling `activity.heartbeat()` directly inside your core logic e.g. on each iteration. 96 | If you encountered a use case where this approach does not fit your design, you can use `heartbeat` contextmanager. It creates a background task utilizing asyncio and calls the heartbeat with defined intervals. 97 | 98 | ```python 99 | from django_temporalio.utils import heartbeat 100 | 101 | 102 | @queue_activities.register("MAIN_TASK_QUEUE") 103 | @activity.defn 104 | async def long_running_activity(): 105 | async with heartbeat(timedelta(seconds=10)): 106 | await count_sheeps() 107 | 108 | 109 | await workflow.execute_activity( 110 | long_running_activity, 111 | start_to_close_timeout=timedelta(minutes=20), 112 | heartbeat_timeout=timedelta(seconds=30), 113 | ) 114 | ``` 115 | 116 | ### Management Commands 117 | 118 | To see a queue's registered activities and workflows: 119 | 120 | ```bash 121 | $ ./manage.py show_temporalio_queue_registry 122 | ``` 123 | 124 | To start a worker defined in the settings (for production): 125 | 126 | ```bash 127 | $ ./manage.py start_temporalio_worker 128 | ``` 129 | 130 | To start a worker for development (starts a worker for each registered queue, WORKER_CONFIGS setting is ignored): 131 | 132 | ```bash 133 | $ ./manage.py start_temporalio_worker --all 134 | ``` 135 | 136 | To sync schedules with Temporal.io: 137 | 138 | ```bash 139 | $ ./manage.py sync_temporalio_schedules 140 | ``` 141 | 142 | To see what sync operation would do without actually syncing: 143 | 144 | ```bash 145 | $ ./manage.py sync_temporal_schedules --dry-run 146 | ``` 147 | 148 | ## Configuration 149 | 150 | You can configure the app using the following settings: 151 | 152 | DJANGO_TEMPORALIO: A dictionary containing the following keys: 153 | 154 | - CLIENT_CONFIG: A dictionary of kwargs that are passed to the `temporalio.client.Client.connect` 155 | method on the client initialization, defaults to `{}` 156 | - WORKER_CONFIGS: A dictionary containing worker configurations. 157 | The key is the worker name and the value is a `temporalio.worker.WorkerConfig` instance. 158 | - BASE_MODULE: A python module that holds workflows, activities and schedules, defaults to `None` 159 | 160 | ## Making a new release 161 | 162 | This project makes use of [RegioHelden's reusable GitHub workflows](https://github.com/RegioHelden/github-reusable-workflows). \ 163 | Make a new release by manually triggering the `Open release PR` workflow. 164 | -------------------------------------------------------------------------------- /django_temporalio/tests/management_commands/test_start_temporalio_worker.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest import TestCase, mock 3 | 4 | from django.core.management import CommandError, call_command 5 | from django.test import override_settings 6 | from temporalio.worker import WorkerConfig 7 | 8 | from django_temporalio.conf import SETTINGS_KEY 9 | 10 | 11 | class StartTemporalioWorkerTestCase(TestCase): 12 | """ 13 | Test case for start_temporalio_worker management command. 14 | """ 15 | 16 | @classmethod 17 | def setUpClass(cls): 18 | worker_configs: dict[str, WorkerConfig] = { 19 | "worker_1": WorkerConfig( 20 | task_queue="TEST_QUEUE_1", 21 | ), 22 | "worker_2": WorkerConfig( 23 | task_queue="TEST_QUEUE_2", 24 | ), 25 | } 26 | 27 | cls._overridden_context = override_settings( 28 | **{SETTINGS_KEY: {"WORKER_CONFIGS": worker_configs}}, 29 | ) 30 | cls._overridden_context.enable() 31 | cls.addClassCleanup(cls._overridden_context.disable) 32 | 33 | def setUp(self): 34 | self.worker_run_mock = mock.AsyncMock() 35 | worker_patcher = mock.patch( 36 | "django_temporalio.management.commands.start_temporalio_worker.Worker", 37 | return_value=mock.Mock(run=self.worker_run_mock), 38 | ) 39 | self.worker_mock = worker_patcher.start() 40 | self.addCleanup(worker_patcher.stop) 41 | 42 | self.client_mock = mock.Mock() 43 | init_client_patcher = mock.patch( 44 | "django_temporalio.management.commands.start_temporalio_worker.init_client", 45 | return_value=self.client_mock, 46 | ) 47 | init_client_patcher.start() 48 | self.addCleanup(init_client_patcher.stop) 49 | 50 | get_queue_registry_patcher = mock.patch( 51 | "django_temporalio.management.commands.start_temporalio_worker.get_queue_registry", 52 | return_value={ 53 | "TEST_QUEUE_1": mock.MagicMock( 54 | workflows=["workflow_1"], 55 | activities=["activity_1"], 56 | ), 57 | "TEST_QUEUE_2": mock.MagicMock( 58 | workflows=["workflow_2"], 59 | activities=["activity_2"], 60 | ), 61 | }, 62 | ) 63 | get_queue_registry_patcher.start() 64 | self.addCleanup(get_queue_registry_patcher.stop) 65 | 66 | self.stdout = StringIO() 67 | self.addCleanup(self.stdout.close) 68 | 69 | def test_flag_all(self): 70 | """ 71 | Test command execution with --all flag. 72 | """ 73 | call_command("start_temporalio_worker", all=True, stdout=self.stdout) 74 | 75 | self.worker_mock.assert_has_calls( 76 | [ 77 | mock.call( 78 | self.client_mock, 79 | task_queue="TEST_QUEUE_1", 80 | workflows=["workflow_1"], 81 | activities=["activity_1"], 82 | ), 83 | mock.call( 84 | self.client_mock, 85 | task_queue="TEST_QUEUE_2", 86 | workflows=["workflow_2"], 87 | activities=["activity_2"], 88 | ), 89 | ], 90 | any_order=True, 91 | ) 92 | self.worker_run_mock.assert_has_calls([mock.call(), mock.call()]) 93 | self.assertEqual( 94 | self.stdout.getvalue(), 95 | "Starting dev Temporal.io workers for queues: TEST_QUEUE_1, TEST_QUEUE_2\n" 96 | "(press ctrl-c to stop)...\n", 97 | ) 98 | 99 | def test_start_worker(self): 100 | """ 101 | Test command execution with worker name argument. 102 | """ 103 | call_command("start_temporalio_worker", "worker_1", stdout=self.stdout) 104 | 105 | self.worker_mock.assert_called_once_with( 106 | self.client_mock, 107 | task_queue="TEST_QUEUE_1", 108 | workflows=["workflow_1"], 109 | activities=["activity_1"], 110 | ) 111 | self.worker_run_mock.assert_called_once() 112 | self.assertEqual( 113 | self.stdout.getvalue(), 114 | "Starting 'worker_1' worker for 'TEST_QUEUE_1' queue\n" 115 | "(press ctrl-c to stop)...\n", 116 | ) 117 | 118 | def test_start_invalid_worker(self): 119 | """ 120 | Test that an error is raised when not declared worker name is provided. 121 | """ 122 | with self.assertRaises(CommandError) as cm: 123 | call_command("start_temporalio_worker", "worker_3", stdout=self.stdout) 124 | 125 | self.worker_mock.assert_not_called() 126 | # use regex due to different error messages in different Python versions 127 | self.assertRegex( 128 | str(cm.exception), 129 | r"Error: argument worker_name: invalid choice: '?worker_3'? " 130 | r"\(choose from '?worker_1'?, '?worker_2'?\)", 131 | ) 132 | 133 | def test_no_arguments(self): 134 | """ 135 | Test that an error is raised when no arguments are provided. 136 | """ 137 | with self.assertRaises(SystemExit): 138 | call_command("start_temporalio_worker", stderr=self.stdout) 139 | 140 | self.worker_mock.assert_not_called() 141 | self.assertEqual( 142 | self.stdout.getvalue(), 143 | "You must provide either a worker name or --all flag.\n", 144 | ) 145 | -------------------------------------------------------------------------------- /django_temporalio/tests/management_commands/test_sync_temporal_schedules.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest import TestCase, mock 3 | 4 | from django.core.management import call_command 5 | from temporalio.client import ScheduleUpdate 6 | 7 | from django_temporalio.management.commands.sync_temporalio_schedules import Command 8 | 9 | 10 | async def async_iterable(*items): 11 | for item in items: 12 | yield item 13 | 14 | 15 | class SyncTemporalioSchedulesTestCase(TestCase): 16 | """ 17 | Test case for sync_temporalio_schedules management command. 18 | """ 19 | 20 | def setUp(self, *args, **kwargs): 21 | self.schedule_handle_mock = mock.AsyncMock() 22 | self.client_mock = mock.AsyncMock( 23 | list_schedules=mock.AsyncMock( 24 | return_value=async_iterable( 25 | mock.Mock(id="schedule_1"), 26 | mock.Mock(id="schedule_2"), 27 | mock.Mock(id="schedule_3"), 28 | mock.Mock(id="schedule_4"), 29 | mock.Mock(id="schedule_5"), 30 | ), 31 | ), 32 | get_schedule_handle=mock.Mock(return_value=self.schedule_handle_mock), 33 | ) 34 | init_client_patcher = mock.patch( 35 | "django_temporalio.management.commands.sync_temporalio_schedules.init_client", 36 | return_value=self.client_mock, 37 | ) 38 | init_client_patcher.start() 39 | self.addCleanup(init_client_patcher.stop) 40 | 41 | get_registry_patcher = mock.patch( 42 | "django_temporalio.management.commands.sync_temporalio_schedules.schedules.get_registry", 43 | return_value={ 44 | "schedule_1": "schedule_instance_1", 45 | "schedule_2": "schedule_instance_2", 46 | "schedule_6": "schedule_instance_6", 47 | }, 48 | ) 49 | self.get_registry_mock = get_registry_patcher.start() 50 | self.addCleanup(get_registry_patcher.stop) 51 | 52 | partial_patcher = mock.patch( 53 | "django_temporalio.management.commands.sync_temporalio_schedules.partial", 54 | ) 55 | self.partial_mock = partial_patcher.start() 56 | self.addCleanup(partial_patcher.stop) 57 | 58 | self.stdout = StringIO() 59 | self.addCleanup(self.stdout.close) 60 | 61 | def _test_sync_schedules(self, verbosity=0): 62 | call_command( 63 | "sync_temporalio_schedules", 64 | verbosity=verbosity, 65 | stdout=self.stdout, 66 | ) 67 | 68 | self.get_registry_mock.assert_called_once_with() 69 | self.assertListEqual( 70 | self.client_mock.mock_calls, 71 | [ 72 | mock.call.list_schedules(), 73 | # get handle to initiate delete 74 | mock.call.get_schedule_handle("schedule_3"), 75 | mock.call.get_schedule_handle("schedule_4"), 76 | mock.call.get_schedule_handle("schedule_5"), 77 | # get handle to initiate update 78 | mock.call.get_schedule_handle("schedule_1"), 79 | mock.call.get_schedule_handle("schedule_2"), 80 | mock.call.create_schedule("schedule_6", "schedule_instance_6"), 81 | ], 82 | ) 83 | self.assertListEqual( 84 | self.partial_mock.mock_calls, 85 | [ 86 | mock.call(Command._schedule_updater_fn, "schedule_instance_1"), 87 | mock.call(Command._schedule_updater_fn, "schedule_instance_2"), 88 | ], 89 | ) 90 | updater_fn = self.partial_mock.return_value 91 | self.assertListEqual( 92 | self.schedule_handle_mock.mock_calls, 93 | [ 94 | mock.call.delete(), 95 | mock.call.delete(), 96 | mock.call.delete(), 97 | mock.call.update(updater_fn), 98 | mock.call.update(updater_fn), 99 | ], 100 | ) 101 | 102 | def test_sync_schedules(self): 103 | self._test_sync_schedules() 104 | self.assertEqual( 105 | "Syncing schedules...\nremoved 3, updated 2, created 1\n", 106 | self.stdout.getvalue(), 107 | ) 108 | 109 | def test_sync_schedules_verbose_output(self): 110 | self._test_sync_schedules(verbosity=2) 111 | self.assertEqual( 112 | self.stdout.getvalue(), 113 | "Syncing schedules...\n" 114 | "Removed 'schedule_3'\n" 115 | "Removed 'schedule_4'\n" 116 | "Removed 'schedule_5'\n" 117 | "Updated 'schedule_1'\n" 118 | "Updated 'schedule_2'\n" 119 | "Created 'schedule_6'\n" 120 | "removed 3, updated 2, created 1\n", 121 | ) 122 | 123 | def test_sync_schedules_dry_run(self): 124 | call_command("sync_temporalio_schedules", dry_run=True, stdout=self.stdout) 125 | 126 | self.get_registry_mock.assert_called_once_with() 127 | self.assertListEqual( 128 | self.client_mock.mock_calls, 129 | [ 130 | mock.call.list_schedules(), 131 | ], 132 | ) 133 | self.schedule_handle_mock.assert_not_called() 134 | self.assertEqual( 135 | self.stdout.getvalue(), 136 | "Syncing schedules [DRY RUN]...\n" 137 | "Removed 'schedule_3'\n" 138 | "Removed 'schedule_4'\n" 139 | "Removed 'schedule_5'\n" 140 | "Updated 'schedule_1'\n" 141 | "Updated 'schedule_2'\n" 142 | "Created 'schedule_6'\n" 143 | "removed 3, updated 2, created 1\n", 144 | ) 145 | 146 | def test_schedule_updater_fn(self): 147 | schedule = "schedule_instance" 148 | 149 | result = Command._schedule_updater_fn(schedule, "ignored_arg") 150 | 151 | self.assertIsInstance(result, ScheduleUpdate) 152 | self.assertIs(result.schedule, schedule) 153 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------- 2 | // Managed by modulesync - DO NOT EDIT 3 | // ------------------------------------------------- 4 | 5 | { 6 | // name of the devcontainer 7 | "name": "django-temporalio", 8 | // define the docker compose file to use for the devcontainer 9 | "dockerComposeFile": [ 10 | "../compose.yaml", 11 | "./compose.yaml" 12 | ], 13 | // define which services from the compose file to start and stop 14 | "runServices": ["app"], 15 | // define the docker-compose service to use for the dev container 16 | "service": "app", 17 | // define the workspace folder our app is located in 18 | "workspaceFolder": "/app", 19 | // set the remote user to connect as 20 | "remoteUser": "app", 21 | // features to be installed in the dev container 22 | "features": { 23 | "ghcr.io/devcontainers/features/common-utils:2": {}, 24 | "ghcr.io/devcontainers/features/git:1": {}, 25 | "ghcr.io/jsburckhardt/devcontainer-features/ruff:1": { 26 | "version": "v0.14.9" 27 | } 28 | }, 29 | // configure vscode 30 | "customizations": { 31 | // Configure properties specific to VS Code. 32 | "vscode": { 33 | "settings": { 34 | // terminal settings 35 | "terminal.integrated.profiles.linux": { 36 | "bash": { 37 | "path": "/bin/bash" 38 | } 39 | }, 40 | "terminal.integrated.defaultProfile.linux": "bash", 41 | // language specific editor settings 42 | "[python]": { 43 | "editor.defaultFormatter": "charliermarsh.ruff" 44 | }, 45 | "[django-html]": { 46 | "editor.defaultFormatter": "monosans.djlint" 47 | }, 48 | "[html]": { 49 | "editor.defaultFormatter": "monosans.djlint" 50 | }, 51 | "[markdown]": { 52 | "files.trimTrailingWhitespace": false 53 | }, 54 | // allow tasks to run on editor startup 55 | "task.allowAutomaticTasks": "on", 56 | // python environment 57 | "python.defaultInterpreterPath": "/home/app/venv/bin/python", 58 | "python.analysis.extraPaths": [ 59 | "/home/app/venv/lib/python3.12/site-packages/" 60 | ], 61 | "python.analysis.useImportHeuristic": true, 62 | "python.analysis.autoSearchPaths": true, 63 | "python.analysis.autoImportCompletions": true, 64 | "python.analysis.indexing": true, 65 | "python.analysis.packageIndexDepths": [ 66 | { 67 | "name": "", 68 | "depth": 10, 69 | "includeAllSymbols": true 70 | } 71 | ], 72 | // use ruff from environment to keep control of version 73 | "ruff.importStrategy": "fromEnvironment", 74 | // don't activate the virtual environment every time as we're using the env binary 75 | "python.terminal.activateEnvironment": false, 76 | "python.terminal.activateEnvInCurrentTerminal": true, 77 | // used for autocomplete etc 78 | "python.languageServer": "Pylance", 79 | // editor settings 80 | "editor.formatOnPaste": true, 81 | "editor.formatOnSave": true, 82 | "editor.codeActionsOnSave": { 83 | "source.fixAll": "always", 84 | "source.organizeImports": "always" 85 | }, 86 | "editor.rulers": [ 87 | 88, 88 | 120 89 | ], 90 | // shows the nested current scopes during the scroll at the top of the editor 91 | "editor.stickyScroll.enabled": true, 92 | // file formatting options 93 | "files.trimTrailingWhitespace": true, 94 | "files.insertFinalNewline": true, 95 | "files.associations": { 96 | "**/*.html": "html", 97 | "**/templates/*": "django-html", 98 | "**/requirements{/**,*}.{txt,in}": "pip-requirements" 99 | }, 100 | "emmet.includeLanguages": { 101 | "django-html": "html" 102 | }, 103 | // files to exclude from search results 104 | "search.exclude": { 105 | "**/__pycache__": true, 106 | "**/.bash_aliases": true, 107 | "**/.git": true, 108 | "**/.ipython": true, 109 | "**/.mypy_cache": true, 110 | "**/logs": true, 111 | "**/node_modules": true, 112 | "**/tmp": true 113 | }, 114 | // files to exclude from all checks 115 | "files.exclude": { 116 | "**/*.pyc": true, 117 | "**/.git": false, 118 | "**/migrations/*": false 119 | }, 120 | // gitlens settings 121 | "gitlens.codeLens.enabled": false, 122 | "gitlens.advanced.blame.customArguments": [ 123 | "--ignore-revs-file", 124 | ".git-blame-ignore-revs" 125 | ], 126 | // copilot settings 127 | "github.copilot.editor.enableAutoCompletions": true, 128 | "github.copilot.enable": { 129 | "*": true, 130 | "plaintext": false, 131 | "markdown": false, 132 | "scminput": false 133 | } 134 | }, 135 | // list all extensions that should be installed when the container is created 136 | "extensions": [ 137 | // --------------------------------------- 138 | // CODING SUPPORT 139 | // --------------------------------------- 140 | // Visual Studio IntelliCode - AI-assisted development 141 | // https://marketplace.visualstudio.com/items?itemName=VisualStudioExptTeam.vscodeintellicode 142 | "visualstudioexptteam.vscodeintellicode", 143 | // --------------------------------------- 144 | // PYTHON 145 | // --------------------------------------- 146 | // Python extension for Visual Studio Code 147 | // https://marketplace.visualstudio.com/items?itemName=ms-python.python 148 | "ms-python.python", 149 | // Pylance - A performant, feature-rich language server for Python in VS Code 150 | // https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance 151 | "ms-python.vscode-pylance", 152 | // Python docstring generator 153 | // https://marketplace.visualstudio.com/items?itemName=njpwerner.autodocstring 154 | "njpwerner.autodocstring", 155 | // Proper indentation for Python 156 | // https://marketplace.visualstudio.com/items?itemName=KevinRose.vsc-python-indent 157 | "KevinRose.vsc-python-indent", 158 | // Visually highlight indentation depth 159 | // https://marketplace.visualstudio.com/items?itemName=oderwat.indent-rainbow 160 | "oderwat.indent-rainbow", 161 | // Code comment highlights 162 | // https://marketplace.visualstudio.com/items?itemName=aaron-bond.better-comments 163 | "aaron-bond.better-comments", 164 | // Linting with ruff 165 | // https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff 166 | "charliermarsh.ruff@2025.32.0", 167 | // Linting with mypy 168 | // https://marketplace.visualstudio.com/items?itemName=ms-python.mypy-type-checker 169 | "ms-python.mypy-type-checker", 170 | // --------------------------------------- 171 | // GIT 172 | // --------------------------------------- 173 | // View git log, file history, compare branches or commits 174 | // https://marketplace.visualstudio.com/items?itemName=donjayamanne.githistory 175 | "donjayamanne.githistory", 176 | // Supercharge the Git capabilities built into Visual Studio Code 177 | // https://marketplace.visualstudio.com/items?itemName=eamodio.gitlens 178 | "eamodio.gitlens", 179 | // GitLab Workflow 180 | // https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow 181 | "GitLab.gitlab-workflow", 182 | // create / apply git patches 183 | // https://marketplace.visualstudio.com/items?itemName=paragdiwan.gitpatch 184 | "paragdiwan.gitpatch", 185 | // --------------------------------------- 186 | // FILE TYPE SUPPORT 187 | // --------------------------------------- 188 | // Support for dotenv file syntax 189 | // https://marketplace.visualstudio.com/items?itemName=mikestead.dotenv 190 | "mikestead.dotenv", 191 | // Syntax highlighting for .po files 192 | // https://marketplace.visualstudio.com/items?itemName=mrorz.language-gettext 193 | "mrorz.language-gettext", 194 | // Duplicate translation error marking for .po files 195 | // https://marketplace.visualstudio.com/items?itemName=ovcharik.gettext-duplicate-error 196 | "ovcharik.gettext-duplicate-error", 197 | // Formatter and linter for Jinja2 templates 198 | // https://marketplace.visualstudio.com/items?itemName=monosans.djlint 199 | "monosans.djlint", 200 | // YAML language support 201 | // https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml 202 | "redhat.vscode-yaml", 203 | // TOML language support 204 | // https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml 205 | "tamasfe.even-better-toml", 206 | // --------------------------------------- 207 | // DJANGO 208 | // --------------------------------------- 209 | // Django template support 210 | // https://marketplace.visualstudio.com/items?itemName=batisteo.vscode-django 211 | "batisteo.vscode-django" 212 | ] 213 | } 214 | } 215 | } 216 | --------------------------------------------------------------------------------