This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new f18f07768be Add TaskGroup markdown documentation support (#67207)
f18f07768be is described below

commit f18f07768be7ee18ef11643fd4755fafd2ef1aa5
Author: Yorgos Toprakchioglu <[email protected]>
AuthorDate: Fri May 29 02:24:55 2026 +0100

    Add TaskGroup markdown documentation support (#67207)
    
    closes: #65679
---
 airflow-core/docs/core-concepts/dags.rst           | 29 ++++++-----
 .../api_fastapi/core_api/datamodels/ui/common.py   | 12 ++++-
 .../api_fastapi/core_api/openapi/_private_ui.yaml  |  5 ++
 .../api_fastapi/core_api/services/ui/task_group.py | 11 +++--
 .../airflow/serialization/definitions/taskgroup.py |  1 +
 airflow-core/src/airflow/serialization/schema.json |  5 ++
 .../airflow/serialization/serialized_objects.py    |  3 ++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 11 +++++
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  1 +
 .../airflow/ui/public/i18n/locales/ar/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/ca/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/de/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/el/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/en/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/es/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/fr/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/he/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/hi/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/hu/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/it/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/ja/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/ko/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/nl/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/pl/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/pt/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/ru/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/th/common.json  |  4 +-
 .../airflow/ui/public/i18n/locales/tr/common.json  |  4 +-
 .../ui/public/i18n/locales/zh-CN/common.json       |  4 +-
 .../ui/public/i18n/locales/zh-TW/common.json       |  4 +-
 .../airflow/ui/src/pages/Task/GroupTaskHeader.tsx  | 33 +++++++++++--
 .../src/airflow/ui/src/pages/Task/Task.tsx         |  2 +-
 .../core_api/datamodels/test_ui_common.py          | 31 ++++++++++++
 .../unit/serialization/test_dag_serialization.py   |  3 +-
 airflow-core/tests/unit/utils/test_task_group.py   | 20 +++++++-
 .../sdk/definitions/decorators/task_group.py       |  9 +++-
 task-sdk/src/airflow/sdk/definitions/taskgroup.py  | 17 +++++++
 .../definitions/decorators/test_task_group.py      | 56 ++++++++++++++++++++++
 .../tests/task_sdk/definitions/test_taskgroup.py   | 23 +++++++++
 39 files changed, 311 insertions(+), 45 deletions(-)

diff --git a/airflow-core/docs/core-concepts/dags.rst 
b/airflow-core/docs/core-concepts/dags.rst
index 0cc8fbfa9d0..a239d5e8817 100644
--- a/airflow-core/docs/core-concepts/dags.rst
+++ b/airflow-core/docs/core-concepts/dags.rst
@@ -645,10 +645,10 @@ Here's an example Dag which illustrates labeling 
different branches:
     :start-after: from airflow.sdk import DAG, Label
 
 
-Dag & Task Documentation
-------------------------
+Dag, Task Group & Task Documentation
+------------------------------------
 
-It's possible to add documentation or notes to your Dags & task objects that 
are visible in the web interface ("Graph" & "Tree" for Dags, "Task Instance 
Details" for tasks).
+It's possible to add documentation or notes to your Dags, TaskGroups, and task 
objects that are visible in the web interface.
 
 There are a set of special task attributes that get rendered as rich content 
if defined:
 
@@ -662,7 +662,7 @@ doc_md      markdown
 doc_rst     reStructuredText
 ==========  ================
 
-Please note that for Dags, ``doc_md`` is the only attribute interpreted. For 
Dags it can contain a string or the reference to a markdown file. Markdown 
files are recognized by str ending in ``.md``.
+Please note that for Dags and TaskGroups, ``doc_md`` is the only attribute 
interpreted. It can contain a string or the reference to a markdown file. 
Markdown files are recognized by str ending in ``.md``.
 If a relative path is supplied it will be loaded from the path relative to 
which the Airflow Scheduler or Dag parser was started. If the markdown file 
does not exist, the passed filename will be used as text, no exception will be 
displayed. Note that the markdown file is loaded during Dag parsing, changes to 
the markdown content take one Dag parsing cycle to have changes be displayed.
 
 This is especially useful if your tasks are built dynamically from 
configuration files, as it allows you to expose the configuration that led to 
the related tasks in Airflow:
@@ -674,20 +674,25 @@ This is especially useful if your tasks are built 
dynamically from configuration
     """
 
     import pendulum
+    from airflow.providers.standard.operators.empty import EmptyOperator
+    from airflow.sdk import DAG, TaskGroup
 
-    dag = DAG(
+    with DAG(
         "my_dag",
         start_date=pendulum.datetime(2021, 1, 1, tz="UTC"),
         schedule="@daily",
         catchup=False,
-    )
-    dag.doc_md = __doc__
+    ) as dag:
+        dag.doc_md = __doc__
 
-    t = BashOperator("foo", dag=dag)
-    t.doc_md = """\
-    #Title"
-    Here's a [url](www.airbnb.com)
-    """
+        t = EmptyOperator(task_id="foo")
+        t.doc_md = """\
+        #Title"
+        Here's a [url](www.airbnb.com)
+        """
+
+        with TaskGroup("extract", doc_md="### Extract tasks"):
+            EmptyOperator(task_id="extract_orders")
 
 
 Packaging Dags
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
index 86dd7d74bd2..464eef0402d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/common.py
@@ -17,10 +17,11 @@
 
 from __future__ import annotations
 
+import inspect
 from datetime import datetime
 from typing import Generic, Literal, TypeVar
 
-from pydantic import computed_field
+from pydantic import computed_field, field_validator
 
 from airflow._shared.timezones import timezone
 from airflow.api_fastapi.core_api.base import BaseModel
@@ -67,6 +68,15 @@ class GridNodeResponse(BaseModel):
     children: list[GridNodeResponse] | None = None
     is_mapped: bool | None
     setup_teardown_type: Literal["setup", "teardown"] | None = None
+    doc_md: str | None = None
+
+    @field_validator("doc_md", mode="before")
+    @classmethod
+    def get_doc_md(cls, doc_md: str | None) -> str | None:
+        """Clean indentation in doc md."""
+        if doc_md is None:
+            return None
+        return inspect.cleandoc(doc_md)
 
 
 class GridRunsResponse(BaseModel):
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 080f9c4ded3..e6fb850247d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2789,6 +2789,11 @@ components:
             - teardown
           - type: 'null'
           title: Setup Teardown Type
+        doc_md:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Doc Md
       type: object
       required:
       - id
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
index 9c156c097aa..47d49757e69 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/task_group.py
@@ -71,7 +71,7 @@ def task_group_to_dict(task_item_or_group, 
parent_group_is_mapped=False):
         # This is the join node used to reduce the number of edges between two 
TaskGroup.
         children.append({"id": task_group.downstream_join_id, "label": "", 
"type": "join"})
 
-    return {
+    node = {
         "id": task_group.group_id,
         "label": task_group.group_display_name or task_group.label,
         "tooltip": task_group.tooltip,
@@ -79,6 +79,7 @@ def task_group_to_dict(task_item_or_group, 
parent_group_is_mapped=False):
         "children": children,
         "type": "task",
     }
+    return node
 
 
 def task_group_to_dict_grid(task_item_or_group, parent_group_is_mapped=False):
@@ -94,13 +95,14 @@ def task_group_to_dict_grid(task_item_or_group, 
parent_group_is_mapped=False):
             setup_teardown_type = "teardown"
         # we explicitly want the short task ID here, not the full doted 
notation if in a group
         task_display_name = task.task_display_name if task.task_display_name 
!= task.task_id else task.label
-        return {
+        node = {
             "id": task.task_id,
             "label": task_display_name,
             "is_mapped": mapped,
             "children": None,
             "setup_teardown_type": setup_teardown_type,
         }
+        return node
 
     task_group = task_item_or_group
     task_group_sort = get_task_group_children_getter()
@@ -110,9 +112,12 @@ def task_group_to_dict_grid(task_item_or_group, 
parent_group_is_mapped=False):
         for x in task_group_sort(task_group)
     ]
 
-    return {
+    node = {
         "id": task_group.group_id,
         "label": task_group.group_display_name or task_group.label,
         "is_mapped": mapped or None,
         "children": children or None,
     }
+    if task_group.doc_md is not None:
+        node["doc_md"] = task_group.doc_md
+    return node
diff --git a/airflow-core/src/airflow/serialization/definitions/taskgroup.py 
b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
index 5db656019f1..a5d8b730b05 100644
--- a/airflow-core/src/airflow/serialization/definitions/taskgroup.py
+++ b/airflow-core/src/airflow/serialization/definitions/taskgroup.py
@@ -47,6 +47,7 @@ class SerializedTaskGroup(DAGNode):
     parent_group: SerializedTaskGroup | None = attrs.field()
     dag: SerializedDAG = attrs.field()
     tooltip: str = attrs.field()
+    doc_md: str | None = attrs.field(default=None)
     default_args: dict[str, Any] = attrs.field(factory=dict)
 
     # TODO: Are these actually useful?
diff --git a/airflow-core/src/airflow/serialization/schema.json 
b/airflow-core/src/airflow/serialization/schema.json
index 2ec82785631..b9efe884480 100644
--- a/airflow-core/src/airflow/serialization/schema.json
+++ b/airflow-core/src/airflow/serialization/schema.json
@@ -376,6 +376,11 @@
         "prefix_group_id": { "type": "boolean" },
         "children":  { "$ref": "#/definitions/dict" },
         "tooltip": { "type": "string" },
+        "doc_md": {
+          "anyOf": [
+            { "type": "string" },
+            { "type": "null" }
+          ]},
         "ui_color": { "type": "string" },
         "ui_fgcolor": { "type": "string" },
         "upstream_group_ids": {
diff --git a/airflow-core/src/airflow/serialization/serialized_objects.py 
b/airflow-core/src/airflow/serialization/serialized_objects.py
index 0d9631089bf..cfe4efa6275 100644
--- a/airflow-core/src/airflow/serialization/serialized_objects.py
+++ b/airflow-core/src/airflow/serialization/serialized_objects.py
@@ -2108,6 +2108,8 @@ class TaskGroupSerialization(BaseSerialization):
             "upstream_task_ids": 
cls.serialize(sorted(task_group.upstream_task_ids)),
             "downstream_task_ids": 
cls.serialize(sorted(task_group.downstream_task_ids)),
         }
+        if task_group.doc_md is not None:
+            encoded["doc_md"] = task_group.doc_md
 
         if isinstance(task_group, MappedTaskGroup):
             encoded["expand_input"] = 
encode_expand_input(task_group._expand_input)
@@ -2129,6 +2131,7 @@ class TaskGroupSerialization(BaseSerialization):
             key: cls.deserialize(encoded_group[key])
             for key in ["prefix_group_id", "tooltip", "ui_color", "ui_fgcolor"]
         }
+        kwargs["doc_md"] = cls.deserialize(encoded_group.get("doc_md"))
         kwargs["group_display_name"] = 
cls.deserialize(encoded_group.get("group_display_name", ""))
 
         if not encoded_group.get("is_mapped"):
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 4a4b95f1830..f14d1a07f3d 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8898,6 +8898,17 @@ export const $GridNodeResponse = {
                 }
             ],
             title: 'Setup Teardown Type'
+        },
+        doc_md: {
+            anyOf: [
+                {
+                    type: 'string'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Doc Md'
         }
     },
     type: 'object',
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 77380eec738..7c2d5c0ca99 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -2246,6 +2246,7 @@ export type GridNodeResponse = {
     children?: Array<GridNodeResponse> | null;
     is_mapped: boolean | null;
     setup_teardown_type?: 'setup' | 'teardown' | null;
+    doc_md?: string | null;
 };
 
 /**
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ar/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ar/common.json
index 7c9a3afa590..8ad2aa34501 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ar/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ar/common.json
@@ -277,7 +277,9 @@
   "task_other": "مهام",
   "task_two": "مهمتان",
   "task_zero": "لا يوجد أي مهمة",
-  "taskGroup": "مجموعة المهام",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "معرف المهمة",
   "taskInstance": {
     "dagVersion": "إصدار الDag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ca/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ca/common.json
index c2586d7dde2..6fd3f6aa430 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ca/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ca/common.json
@@ -267,7 +267,9 @@
   },
   "task_one": "Tasca",
   "task_other": "Tasques",
-  "taskGroup": "Grup de tasques",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_one": "Grup de tasques",
   "taskGroup_other": "Grups de tasques",
   "taskId": "ID de la tasca",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/de/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/de/common.json
index 0ee18a06985..2d0be6ce03a 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/de/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/de/common.json
@@ -267,7 +267,9 @@
   },
   "task_one": "Task",
   "task_other": "Tasks",
-  "taskGroup": "Task Gruppe",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_one": "Task Gruppe",
   "taskGroup_other": "Task Gruppen",
   "taskId": "Task ID",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/el/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/el/common.json
index 78d468e1d16..aa005e29b6d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/el/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/el/common.json
@@ -230,7 +230,9 @@
   },
   "task_one": "Εργασία",
   "task_other": "Εργασίες",
-  "taskGroup": "Ομάδα Εργασιών",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "ID Εργασίας",
   "taskInstance": {
     "dagVersion": "Έκδοση Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index df592195a74..4e87c45e305 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -267,7 +267,9 @@
   },
   "task_one": "Task",
   "task_other": "Tasks",
-  "taskGroup": "Task Group",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_one": "Task Group",
   "taskGroup_other": "Task Groups",
   "taskId": "Task ID",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/es/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/es/common.json
index 864713ca67e..a63eec6894b 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/es/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/es/common.json
@@ -245,7 +245,9 @@
   "task_many": "Tareas",
   "task_one": "Tarea",
   "task_other": "Tareas",
-  "taskGroup": "Grupo de Tareas",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "ID de la Tarea",
   "taskInstance": {
     "dagVersion": "Versión del Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/fr/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/fr/common.json
index 84a55c03b5c..8a3453fface 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/fr/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/fr/common.json
@@ -278,7 +278,9 @@
   "task_many": "Tâches",
   "task_one": "Tâche",
   "task_other": "Tâches",
-  "taskGroup": "Groupe de tâches",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_many": "Groupes de tâches",
   "taskGroup_one": "Groupe de tâches",
   "taskGroup_other": "Groupes de tâches",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/he/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/he/common.json
index 09096951b48..6b510322317 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/he/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/he/common.json
@@ -250,7 +250,9 @@
   "task_one": "משימה",
   "task_other": "משימות",
   "task_two": "משימות",
-  "taskGroup": "קבוצת משימות",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "מזהה משימה",
   "taskInstance": {
     "dagVersion": "גרסת Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hi/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/hi/common.json
index 496f80083e5..b631245c7ff 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hi/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hi/common.json
@@ -243,7 +243,9 @@
   },
   "task_one": "टास्क",
   "task_other": "टास्क्स",
-  "taskGroup": "टास्क ग्रुप",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "टास्क ID",
   "taskInstance": {
     "dagVersion": "डैग संस्करण",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hu/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/hu/common.json
index 4a083a44c78..04d91ce483c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hu/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hu/common.json
@@ -250,7 +250,9 @@
   },
   "task_one": "Feladat",
   "task_other": "Feladatok",
-  "taskGroup": "Feladatcsoport",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "Feladat azonosító",
   "taskInstance": {
     "dagVersion": "Dag verzió",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/it/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/it/common.json
index acd57d21362..96f36ffb47f 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/it/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/it/common.json
@@ -255,7 +255,9 @@
   "task_one": "Task",
   "task_other": "Tasks",
   "task_zero": "Nessun task",
-  "taskGroup": "Gruppo di Task",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "ID del Task",
   "taskInstance": {
     "dagVersion": "Versione del Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ja/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ja/common.json
index 1ec92cf9c30..e4c9cf23825 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ja/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ja/common.json
@@ -244,7 +244,9 @@
   },
   "task_one": "タスク",
   "task_other": "タスク",
-  "taskGroup": "タスクグループ",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "タスク ID",
   "taskInstance": {
     "dagVersion": "Dag バージョン",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ko/common.json
index cbacddede45..ef6a66b5dc3 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/common.json
@@ -267,7 +267,9 @@
   },
   "task_one": "태스크",
   "task_other": "태스크",
-  "taskGroup": "태스크 그룹",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_one": "태스크 그룹",
   "taskGroup_other": "태스크 그룹",
   "taskId": "태스크 ID",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json
index eed0ee70048..d4b427abb2c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/nl/common.json
@@ -261,7 +261,9 @@
   },
   "task_one": "Task",
   "task_other": "Tasks",
-  "taskGroup": "Task groep",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "Task ID",
   "taskInstance": {
     "dagVersion": "Dag versie",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pl/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/pl/common.json
index b3e98586d0b..e897156b3c9 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pl/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pl/common.json
@@ -289,7 +289,9 @@
   "task_many": "Zadań",
   "task_one": "Zadanie",
   "task_other": "Zadania",
-  "taskGroup": "Grupa zadań",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_few": "Grupy zadań",
   "taskGroup_many": "Grup zadań",
   "taskGroup_one": "Grupa zadań",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pt/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/pt/common.json
index eba55971775..e876c21dc1d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pt/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pt/common.json
@@ -272,7 +272,9 @@
   "task_one": "Tarefa",
   "task_other": "Tarefas",
   "task_zero": "Nenhuma tarefa",
-  "taskGroup": "Grupo de Tarefas",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "ID da Tarefa",
   "taskInstance": {
     "dagVersion": "Versão do Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ru/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ru/common.json
index be8f4c20396..e878e67ef86 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ru/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ru/common.json
@@ -254,7 +254,9 @@
   },
   "task_one": "Задача",
   "task_other": "Задач",
-  "taskGroup": "Группа задач",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "ID задачи",
   "taskInstance": {
     "dagVersion": "Версия Dag-а",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/th/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/th/common.json
index 5c461952e37..26c439b5781 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/th/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/th/common.json
@@ -230,7 +230,9 @@
   },
   "task_one": "งาน",
   "task_other": "งาน",
-  "taskGroup": "กลุ่มงาน",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "Task ID",
   "taskInstance": {
     "dagVersion": "เวอร์ชัน Dag",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/tr/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/tr/common.json
index b1540655678..0ae63fba28a 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/tr/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/tr/common.json
@@ -250,7 +250,9 @@
   },
   "task_one": "Görev",
   "task_other": "Görevler",
-  "taskGroup": "Görev Grubu",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "Görev Kimliği",
   "taskInstance": {
     "dagVersion": "Dag Versiyonu",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/common.json
index 1fe6307f6fb..33027f16fc0 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/common.json
@@ -250,7 +250,9 @@
   },
   "task_one": "任务",
   "task_other": "任务",
-  "taskGroup": "任务分组",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskId": "任务 ID",
   "taskInstance": {
     "dagVersion": "Dag 版本",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
index e8b4a5399fd..ae4d0291dc5 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json
@@ -267,7 +267,9 @@
   },
   "task_one": "任務",
   "task_other": "任務",
-  "taskGroup": "任務群組",
+  "taskGroup": {
+    "documentation": "Task Group Documentation"
+  },
   "taskGroup_one": "任務群組",
   "taskGroup_other": "任務群組",
   "taskId": "任務 ID",
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx 
b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
index 34d55dc3458..cc5ea82e32b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx
@@ -16,10 +16,37 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import { useTranslation } from "react-i18next";
 import { AiOutlineGroup } from "react-icons/ai";
+import { FiBookOpen } from "react-icons/fi";
 
+import DisplayMarkdownButton from "src/components/DisplayMarkdownButton";
 import { HeaderCard } from "src/components/HeaderCard";
 
-export const GroupTaskHeader = ({ title }: { readonly title: string }) => (
-  <HeaderCard icon={<AiOutlineGroup />} stats={[]} title={title} />
-);
+export const GroupTaskHeader = ({
+  docMd,
+  title,
+}: {
+  readonly docMd?: string | null;
+  readonly title: string;
+}) => {
+  const { t: translate } = useTranslation();
+
+  return (
+    <HeaderCard
+      actions={
+        docMd === undefined || docMd === null ? undefined : (
+          <DisplayMarkdownButton
+            header={translate("taskGroup.documentation")}
+            icon={<FiBookOpen />}
+            mdContent={docMd}
+            text={translate("docs.documentation")}
+          />
+        )
+      }
+      icon={<AiOutlineGroup />}
+      stats={[]}
+      title={title}
+    />
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx 
b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
index 6fff8ab723e..3d84c55c114 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -79,7 +79,7 @@ export const Task = () => {
     <ReactFlowProvider>
       <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
         {task === undefined ? undefined : <Header task={task} />}
-        {groupTask ? <GroupTaskHeader title={groupTask.label} /> : undefined}
+        {groupTask ? <GroupTaskHeader docMd={groupTask.doc_md} 
title={groupTask.label} /> : undefined}
       </DetailsLayout>
     </ReactFlowProvider>
   );
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_ui_common.py 
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_ui_common.py
new file mode 100644
index 00000000000..fca81485f4f
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_ui_common.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from airflow.api_fastapi.core_api.datamodels.ui.common import GridNodeResponse
+
+
+def test_grid_node_response_cleans_doc_md_indentation():
+    doc_md = """
+        # Heading
+
+        - item
+        """
+
+    response = GridNodeResponse(id="task_group", label="Task Group", 
is_mapped=None, doc_md=doc_md)
+
+    assert response.doc_md == "# Heading\n\n- item"
diff --git a/airflow-core/tests/unit/serialization/test_dag_serialization.py 
b/airflow-core/tests/unit/serialization/test_dag_serialization.py
index 306a7eebb68..b8156abd67e 100644
--- a/airflow-core/tests/unit/serialization/test_dag_serialization.py
+++ b/airflow-core/tests/unit/serialization/test_dag_serialization.py
@@ -1754,7 +1754,7 @@ class TestStringifiedDAGs:
         logical_date = datetime(2020, 1, 1)
         with DAG("test_task_group_serialization", schedule=None, 
start_date=logical_date) as dag:
             task1 = EmptyOperator(task_id="task1")
-            with TaskGroup("group234") as group234:
+            with TaskGroup("group234", doc_md="### TaskGroup Documentation") 
as group234:
                 _ = EmptyOperator(task_id="task2")
 
                 with TaskGroup("group34") as group34:
@@ -1774,6 +1774,7 @@ class TestStringifiedDAGs:
 
         assert serialized_dag.task_group.children
         assert serialized_dag.task_group.children.keys() == 
dag.task_group.children.keys()
+        assert serialized_dag.task_group.children["group234"].doc_md == "### 
TaskGroup Documentation"
 
         def check_task_group(node):
             assert node.dag is serialized_dag
diff --git a/airflow-core/tests/unit/utils/test_task_group.py 
b/airflow-core/tests/unit/utils/test_task_group.py
index ffc217fc078..e866bce62af 100644
--- a/airflow-core/tests/unit/utils/test_task_group.py
+++ b/airflow-core/tests/unit/utils/test_task_group.py
@@ -20,7 +20,7 @@ from __future__ import annotations
 import pendulum
 import pytest
 
-from airflow.api_fastapi.core_api.services.ui.task_group import 
task_group_to_dict
+from airflow.api_fastapi.core_api.services.ui.task_group import 
task_group_to_dict, task_group_to_dict_grid
 from airflow.providers.standard.operators.bash import BashOperator
 from airflow.providers.standard.operators.empty import EmptyOperator
 from airflow.providers.standard.operators.python import PythonOperator
@@ -243,6 +243,24 @@ def test_task_group_to_dict_alternative_syntax():
     assert task_group_to_dict(serialized_dag.task_group) == EXPECTED_JSON
 
 
+def test_task_group_to_dict_grid_includes_task_group_doc_md(dag_maker):
+    logical_date = pendulum.parse("20200101")
+    with dag_maker("test_task_group_to_dict_doc_md", schedule=None, 
start_date=logical_date) as dag:
+        with TaskGroup("group234", doc_md="### TaskGroup Documentation"):
+            EmptyOperator(task_id="task1", doc_md="### Task Documentation")
+
+    serialized_dag = create_scheduler_dag(dag)
+    group = serialized_dag.task_group_dict["group234"]
+
+    graph_node = task_group_to_dict(group)
+    assert "doc_md" not in graph_node
+    assert "doc_md" not in graph_node["children"][0]
+
+    grid_node = task_group_to_dict_grid(group)
+    assert grid_node["doc_md"] == "### TaskGroup Documentation"
+    assert "doc_md" not in grid_node["children"][0]
+
+
 def extract_node_id(node, include_label=False):
     ret = {"id": node["id"]}
     if include_label:
diff --git a/task-sdk/src/airflow/sdk/definitions/decorators/task_group.py 
b/task-sdk/src/airflow/sdk/definitions/decorators/task_group.py
index 44baff52c1d..3b1e0d35404 100644
--- a/task-sdk/src/airflow/sdk/definitions/decorators/task_group.py
+++ b/task-sdk/src/airflow/sdk/definitions/decorators/task_group.py
@@ -94,8 +94,12 @@ class _TaskGroupFactory(ExpandableFactory, Generic[FParams, 
FReturn]):
 
     def _create_task_group(self, tg_factory: Callable[..., TaskGroup], *args: 
Any, **kwargs: Any) -> DAGNode:
         with tg_factory(add_suffix_on_collision=True, **self.tg_kwargs) as 
task_group:
-            if self.function.__doc__ and not task_group.tooltip:
-                task_group.tooltip = self.function.__doc__
+            if doc := self.function.__doc__:
+                if not task_group.tooltip:
+                    task_group.tooltip = doc
+                if not task_group.doc_md:
+                    # Function docstrings are documentation text, not file 
paths for the doc_md converter.
+                    object.__setattr__(task_group, "doc_md", doc)
 
             # Invoke function to run Tasks inside the TaskGroup
             retval = self.function(*args, **kwargs)
@@ -194,6 +198,7 @@ def task_group(
     dag: DAG | None = None,
     default_args: dict[str, Any] | None = None,
     tooltip: str = "",
+    doc_md: str | None = None,
     ui_color: str = "CornflowerBlue",
     ui_fgcolor: str = "#000",
     add_suffix_on_collision: bool = False,
diff --git a/task-sdk/src/airflow/sdk/definitions/taskgroup.py 
b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
index 67376cb817a..cc1fc5cbda6 100644
--- a/task-sdk/src/airflow/sdk/definitions/taskgroup.py
+++ b/task-sdk/src/airflow/sdk/definitions/taskgroup.py
@@ -76,6 +76,21 @@ def _validate_group_id(instance, attribute, value: str) -> 
None:
     validate_group_key(value)
 
 
+def _convert_doc_md(doc_md: str | None) -> str | None:
+    """Convert markdown file paths to file contents."""
+    if doc_md is None:
+        return doc_md
+
+    if doc_md.endswith(".md"):
+        try:
+            with open(doc_md) as fh:
+                return fh.read()
+        except FileNotFoundError:
+            return doc_md
+
+    return doc_md
+
+
 @attrs.define(repr=False)
 class TaskGroup(DAGNode):
     """
@@ -101,6 +116,7 @@ class TaskGroup(DAGNode):
         here and `'depends_on_past': False` in the operator's call
         `default_args`, the actual value will be `False`.
     :param tooltip: The tooltip of the TaskGroup node when displayed in the UI
+    :param doc_md: Markdown documentation for the TaskGroup displayed in the UI
     :param ui_color: The fill color of the TaskGroup node when displayed in 
the UI
     :param ui_fgcolor: The label color of the TaskGroup node when displayed in 
the UI
     :param add_suffix_on_collision: If this task group name already exists,
@@ -119,6 +135,7 @@ class TaskGroup(DAGNode):
     dag: DAG = attrs.field(default=attrs.Factory(_default_dag, 
takes_self=True))
     default_args: dict[str, Any] = attrs.field(factory=dict, 
converter=copy.deepcopy)
     tooltip: str = attrs.field(default="", 
validator=attrs.validators.instance_of(str))
+    doc_md: str | None = attrs.field(default=None, converter=_convert_doc_md)
     children: dict[str, DAGNode] = attrs.field(factory=dict, init=False)
 
     upstream_group_ids: set[str | None] = attrs.field(factory=set, init=False)
diff --git a/task-sdk/tests/task_sdk/definitions/decorators/test_task_group.py 
b/task-sdk/tests/task_sdk/definitions/decorators/test_task_group.py
index 26e06cbfd6d..7a777b1ea19 100644
--- a/task-sdk/tests/task_sdk/definitions/decorators/test_task_group.py
+++ b/task-sdk/tests/task_sdk/definitions/decorators/test_task_group.py
@@ -78,6 +78,25 @@ def test_tooltip_derived_from_function_docstring():
     _ = pipeline()
 
     assert _.task_group_dict["tg"].tooltip == "Function docstring."
+    assert _.task_group_dict["tg"].doc_md == "Function docstring."
+
+
+def 
test_doc_md_derived_from_function_docstring_does_not_resolve_markdown_file(tmp_path,
 monkeypatch):
+    """Test that docstrings ending with .md are not treated as markdown file 
paths."""
+    (tmp_path / "README.md").write_text("External file content.")
+    monkeypatch.chdir(tmp_path)
+
+    @dag(schedule=None, start_date=pendulum.datetime(2022, 1, 1))
+    def pipeline():
+        @task_group()
+        def tg():
+            """README.md"""
+
+        tg()
+
+    _ = pipeline()
+
+    assert _.task_group_dict["tg"].doc_md == "README.md"
 
 
 def test_tooltip_not_overridden_by_function_docstring():
@@ -99,6 +118,43 @@ def test_tooltip_not_overridden_by_function_docstring():
     assert _.task_group_dict["tg"].tooltip == "tooltip for the TaskGroup"
 
 
+def test_doc_md_not_overridden_by_function_docstring():
+    """Test that explicitly set TaskGroup markdown docs are not overwritten by 
the function docstring."""
+
+    @dag(schedule=None, start_date=pendulum.datetime(2022, 1, 1))
+    def pipeline():
+        @task_group(doc_md="TaskGroup docs.")
+        def tg():
+            """Function docstring."""
+
+        tg()
+
+    _ = pipeline()
+
+    assert _.task_group_dict["tg"].doc_md == "TaskGroup docs."
+
+
+def test_doc_md_file_resolved(tmp_path):
+    """Test that task_group reads markdown files supplied as doc_md."""
+    raw_content = """
+    ### External Markdown TaskGroup documentation
+    """
+
+    path = tmp_path / "testfile.md"
+    path.write_text(raw_content)
+
+    @dag(schedule=None, start_date=pendulum.datetime(2022, 1, 1))
+    def pipeline():
+        @task_group(doc_md=str(path))
+        def tg(): ...
+
+        tg()
+
+    _ = pipeline()
+
+    assert _.task_group_dict["tg"].doc_md == raw_content
+
+
 def test_partial_evolves_factory():
     tgp = None
 
diff --git a/task-sdk/tests/task_sdk/definitions/test_taskgroup.py 
b/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
index d1ba11e3056..cf6d309f305 100644
--- a/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
+++ b/task-sdk/tests/task_sdk/definitions/test_taskgroup.py
@@ -68,6 +68,29 @@ class TestTaskGroup:
             TaskGroup(group_id)
         assert str(ctx.value) == exc_value
 
+    def test_resolve_documentation_file_not_rendered(self, tmp_path):
+        """Test that TaskGroup reads markdown files supplied as doc_md."""
+        raw_content = """
+        {% if True %}
+            External Markdown TaskGroup documentation
+        {% endif %}
+        """
+
+        path = tmp_path / "testfile.md"
+        path.write_text(raw_content)
+
+        with DAG(dag_id="test_dag", schedule=None, 
start_date=pendulum.parse("20200101")):
+            tg = TaskGroup("group1", doc_md=str(path))
+
+        assert tg.doc_md == raw_content
+
+    def test_missing_documentation_file_uses_path_as_doc_md(self):
+        """Test that TaskGroup keeps missing markdown file paths as doc_md."""
+        with DAG(dag_id="test_dag", schedule=None, 
start_date=pendulum.parse("20200101")):
+            tg = TaskGroup("group1", doc_md="missing_file.md")
+
+        assert tg.doc_md == "missing_file.md"
+
 
 def test_task_group_dependencies_between_tasks_if_task_group_is_empty_1():
     """


Reply via email to