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

jscheffl 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 e9a41115aea Implements Support for Format="Duration" in Params (#65469)
e9a41115aea is described below

commit e9a41115aeae52c4c59ccb9b4d9c35481331d007
Author: Piyush Mudgal <[email protected]>
AuthorDate: Fri Jun 5 22:44:13 2026 +0530

    Implements Support for Format="Duration" in Params (#65469)
    
    * feat: add format="duration" support to Params in task_sdk
    
    * adding validation tests and some nits
    
    * run prek
    
    * feat: add format="duration" support to Params
    
    Implements ISO 8601 duration validation for Airflow Params using
    format="duration" in JSON Schema. Adds FormatChecker support via
    isoduration dependency and a new FieldDuration UI component.
    
    Closes #65080
---
 airflow-core/docs/core-concepts/params.rst         |   3 +
 airflow-core/pyproject.toml                        |   1 +
 .../example_dags/example_params_ui_tutorial.py     |   9 ++
 .../src/airflow/serialization/definitions/param.py |   8 +-
 .../ui/public/i18n/locales/en/components.json      |   2 +
 .../components/FlexibleForm/FieldDuration.test.tsx | 105 +++++++++++++++++++++
 .../src/components/FlexibleForm/FieldDuration.tsx  |  76 +++++++++++++++
 .../src/components/FlexibleForm/FieldSelector.tsx  |   6 ++
 .../src/components/FlexibleForm/FlexibleForm.tsx   |   6 +-
 .../unit/serialization/definitions/test_param.py   |  60 ++++++++++++
 task-sdk/pyproject.toml                            |   1 +
 task-sdk/src/airflow/sdk/definitions/param.py      |  10 +-
 task-sdk/tests/task_sdk/definitions/test_param.py  |  33 +++++++
 uv.lock                                            |  29 ++++++
 14 files changed, 342 insertions(+), 7 deletions(-)

diff --git a/airflow-core/docs/core-concepts/params.rst 
b/airflow-core/docs/core-concepts/params.rst
index de2598b95b4..ac5df462fac 100644
--- a/airflow-core/docs/core-concepts/params.rst
+++ b/airflow-core/docs/core-concepts/params.rst
@@ -242,6 +242,9 @@ The following features are supported in the Trigger UI Form:
             * | ``format="date-time"``: Generate a date and
               | time-picker with calendar pop-up
             * ``format="time"``: Generate a time-picker
+            * | ``format="duration"``: Generate a duration
+              | input field accepting ISO 8601 duration
+              | strings (e.g. ``P1D``, ``PT15M``, ``PT2H``)
             * ``format="multiline"``: Generate a multi-line textarea
             * | ``enum=["a", "b", "c"]``: Generates a
               | drop-down select list for scalar values.
diff --git a/airflow-core/pyproject.toml b/airflow-core/pyproject.toml
index 664ff7d905f..41bca47af35 100644
--- a/airflow-core/pyproject.toml
+++ b/airflow-core/pyproject.toml
@@ -168,6 +168,7 @@ dependencies = [
     # Start of shared listeners dependencies
     "pluggy>=1.5.0",
     # End of shared listeners dependencies
+    "isoduration>=20.11.0",
 ]
 
 
diff --git 
a/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py 
b/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
index df872f72133..279ddf82c71 100644
--- a/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
+++ b/airflow-core/src/airflow/example_dags/example_params_ui_tutorial.py
@@ -93,6 +93,15 @@ with DAG(
             description="Please select a date and time, use the button on the 
left for a pop-up calendar.",
             section="Typed parameters with Param object",
         ),
+        # Durations are also Supported support for ISO 8601
+        "duration": Param(
+            "PT15M",
+            type="string",
+            format="duration",
+            title="Duration definitions",
+            description="Please enter a duration in ISO 8601 format (e.g. 
PT15M, PT2H, P1D).",
+            section="Typed parameters with Param object",
+        ),
         "date": Param(
             f"{datetime.date.today()}",
             type="string",
diff --git a/airflow-core/src/airflow/serialization/definitions/param.py 
b/airflow-core/src/airflow/serialization/definitions/param.py
index 69a779f710e..5df0af8596f 100644
--- a/airflow-core/src/airflow/serialization/definitions/param.py
+++ b/airflow-core/src/airflow/serialization/definitions/param.py
@@ -56,12 +56,16 @@ class SerializedParam:
         :param raises: All exceptions during validation are suppressed by
             default. They are only raised if this is set to *True* instead.
         """
-        import jsonschema
+        from jsonschema import FormatChecker, validate
 
         try:
             if not is_arg_set(value := self.value):
                 raise ValueError("No value passed")
-            jsonschema.validate(value, self.schema, 
format_checker=jsonschema.FormatChecker())
+            validate(
+                value,
+                self.schema,
+                format_checker=FormatChecker(),
+            )
         except Exception:
             if not raises:
                 return None
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index 64a78bee04b..545d8e66928 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -72,6 +72,7 @@
     "files_other": "{{count}} files"
   },
   "flexibleForm": {
+    "durationPlaceholder": "Enter Duration in ISO 8601 format",
     "placeholder": "Select Value",
     "placeholderArray": "Enter each string on a new line",
     "placeholderExamples": "Start typing to see options",
@@ -79,6 +80,7 @@
     "validationErrorArrayNotArray": "Value must be an array.",
     "validationErrorArrayNotNumbers": "All elements in the array must be 
numbers.",
     "validationErrorArrayNotObject": "All elements in the array must be 
objects.",
+    "validationErrorDuration": "Invalid ISO 8601 duration format",
     "validationErrorRequired": "This field is required"
   },
   "graph": {
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.test.tsx
 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.test.tsx
new file mode 100644
index 00000000000..1c504febfce
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.test.tsx
@@ -0,0 +1,105 @@
+/*!
+ * 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.
+ */
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { FieldDuration } from "./FieldDuration";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const mockParamsDict: Record<string, any> = {};
+const mockSetParamsDict = vi.fn();
+
+vi.mock("src/queries/useParamStore", () => ({
+  paramPlaceholder: {
+    schema: {},
+    value: null,
+  },
+  useParamStore: () => ({
+    disabled: false,
+    paramsDict: mockParamsDict,
+    setParamsDict: mockSetParamsDict,
+  }),
+}));
+
+describe("FieldDuration", () => {
+  beforeEach(() => {
+    Object.keys(mockParamsDict).forEach((key) => {
+      // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
+      delete mockParamsDict[key];
+    });
+    mockSetParamsDict.mockClear();
+  });
+
+  it("renders the duration input", () => {
+    mockParamsDict.test_duration = { schema: { format: "duration", type: 
"string" }, value: "" };
+    render(<FieldDuration name="test_duration" onUpdate={vi.fn()} />, { 
wrapper: Wrapper });
+    expect(screen.getByRole("textbox")).toBeDefined();
+  });
+
+  it("calls onUpdate with value when a valid duration is entered", () => {
+    const onUpdate = vi.fn();
+
+    mockParamsDict.test_duration = { schema: { format: "duration", type: 
"string" }, value: "" };
+
+    render(<FieldDuration name="test_duration" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(screen.getByRole("textbox"), { target: { value: "PT15M" } 
});
+
+    expect(onUpdate).toHaveBeenCalledWith("PT15M");
+    expect(onUpdate).not.toHaveBeenCalledWith(undefined, expect.any(String));
+  });
+
+  it("calls onUpdate with error message when an invalid duration is entered", 
() => {
+    const onUpdate = vi.fn();
+
+    mockParamsDict.test_duration = { schema: { format: "duration", type: 
"string" }, value: "" };
+
+    render(<FieldDuration name="test_duration" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(screen.getByRole("textbox"), { target: { value: "garbage" 
} });
+
+    expect(onUpdate).toHaveBeenCalledWith(undefined, expect.any(String));
+  });
+
+  it("normalizes comma decimal separator to dot before calling onUpdate", () 
=> {
+    const onUpdate = vi.fn();
+
+    mockParamsDict.test_duration = { schema: { format: "duration", type: 
"string" }, value: "" };
+
+    render(<FieldDuration name="test_duration" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(screen.getByRole("textbox"), { target: { value: "PT1,5H" 
} });
+
+    expect(onUpdate).toHaveBeenCalledWith("PT1.5H");
+  });
+
+  it("calls onUpdate with empty string when field is cleared", () => {
+    const onUpdate = vi.fn();
+
+    mockParamsDict.test_duration = { schema: { format: "duration", type: 
"string" }, value: "PT1H" };
+
+    render(<FieldDuration name="test_duration" onUpdate={onUpdate} />, { 
wrapper: Wrapper });
+
+    fireEvent.change(screen.getByRole("textbox"), { target: { value: "" } });
+
+    expect(onUpdate).toHaveBeenCalledWith("");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.tsx
new file mode 100644
index 00000000000..42d3abbba6f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldDuration.tsx
@@ -0,0 +1,76 @@
+/*!
+ * 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.
+ */
+import { Input } from "@chakra-ui/react";
+import dayjs from "dayjs";
+import duration from "dayjs/plugin/duration";
+import { useTranslation } from "react-i18next";
+
+import { paramPlaceholder, useParamStore } from "src/queries/useParamStore";
+
+import type { FlexibleFormElementProps } from ".";
+
+dayjs.extend(duration);
+
+export const FieldDuration = ({ name, namespace = "default", onUpdate }: 
FlexibleFormElementProps) => {
+  const { t: translate } = useTranslation("components");
+  const { disabled, paramsDict, setParamsDict } = useParamStore(namespace);
+  const param = paramsDict[name] ?? paramPlaceholder;
+  const handleChange = (value: string) => {
+    const isEmpty = value === "";
+    const parsedValue = value.replaceAll(",", ".");
+
+    if (paramsDict[name]) {
+      setParamsDict({
+        ...paramsDict,
+        [name]: {
+          ...paramsDict[name],
+          value: isEmpty ? null : parsedValue,
+        },
+      });
+    }
+
+    if (isEmpty) {
+      onUpdate(parsedValue);
+
+      return;
+    }
+    const dur = dayjs.duration(parsedValue);
+    const isValid = !Number.isNaN(dur.asMilliseconds());
+
+    if (isValid) {
+      onUpdate(parsedValue);
+    } else {
+      onUpdate(undefined, translate("flexibleForm.validationErrorDuration"));
+    }
+  };
+
+  return (
+    <Input
+      disabled={disabled}
+      id={`element_${name}`}
+      name={`element_${name}`}
+      onChange={(event) => {
+        handleChange(event.target.value);
+      }}
+      placeholder={translate("flexibleForm.durationPlaceholder")}
+      size="sm"
+      value={(param.value ?? "") as string}
+    />
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
index e43c006b5cb..173289a06d8 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FieldSelector.tsx
@@ -24,6 +24,7 @@ import { FieldAdvancedArray } from "./FieldAdvancedArray";
 import { FieldBool } from "./FieldBool";
 import { FieldDateTime } from "./FieldDateTime";
 import { FieldDropdown } from "./FieldDropdown";
+import { FieldDuration } from "./FieldDuration";
 import { FieldMultiSelect } from "./FieldMultiSelect";
 import { FieldMultiType } from "./FieldMultiType";
 import { FieldMultilineText } from "./FieldMultilineText";
@@ -67,6 +68,9 @@ const isFieldDate = (fieldType: string, fieldSchema: 
ParamSchema) =>
 const isFieldDateTime = (fieldType: string, fieldSchema: ParamSchema) =>
   fieldType === "string" && fieldSchema.format === "date-time";
 
+const isFieldDuration = (fieldType: string, fieldSchema: ParamSchema) =>
+  fieldType === "string" && fieldSchema.format === "duration";
+
 const enumTypes = ["null", "string", "number", "integer"];
 
 const isFieldDropdown = (fieldType: string, fieldSchema: ParamSchema) =>
@@ -140,6 +144,8 @@ export const FieldSelector = ({ name, namespace = 
"default", onUpdate }: Flexibl
     return <FieldObject name={name} namespace={namespace} onUpdate={onUpdate} 
/>;
   } else if (isFieldNumber(fieldType)) {
     return <FieldNumber name={name} namespace={namespace} onUpdate={onUpdate} 
/>;
+  } else if (isFieldDuration(fieldType, param.schema)) {
+    return <FieldDuration name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else if (isFieldMultilineText(fieldType, param.schema)) {
     return <FieldMultilineText name={name} namespace={namespace} 
onUpdate={onUpdate} />;
   } else {
diff --git 
a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx 
b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
index 7b523f14fe0..792ca6e25a8 100644
--- a/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
+++ b/airflow-core/src/airflow/ui/src/components/FlexibleForm/FlexibleForm.tsx
@@ -71,6 +71,7 @@ export const FlexibleForm = ({
   const { paramsDict: params, setDisabled, setInitialParamDict, setParamsDict 
} = useParamStore(namespace);
   const processedSections = new Map();
   const [sectionError, setSectionError] = useState<Map<string, boolean>>(new 
Map());
+  const [hasValidationError, setHasValidationError] = useState(false);
 
   useEffect(() => {
     // Initialize paramsDict and initialParamDict when modal opens
@@ -95,8 +96,8 @@ export const FlexibleForm = ({
     const newSectionError = computeSectionErrors(params, 
flexibleFormDefaultSection);
 
     setSectionError(newSectionError);
-    setError(newSectionError.size > 0);
-  }, [params, flexibleFormDefaultSection, setError]);
+    setError(hasValidationError || newSectionError.size > 0);
+  }, [params, flexibleFormDefaultSection, setError, hasValidationError]);
 
   useEffect(() => {
     setDisabled(disabled ?? false);
@@ -105,6 +106,7 @@ export const FlexibleForm = ({
   const onUpdate = (_value?: string, error?: unknown) => {
     const newSectionError = computeSectionErrors(params, 
flexibleFormDefaultSection);
 
+    setHasValidationError(Boolean(error));
     setSectionError(newSectionError);
     setError(Boolean(error) || newSectionError.size > 0);
   };
diff --git a/airflow-core/tests/unit/serialization/definitions/test_param.py 
b/airflow-core/tests/unit/serialization/definitions/test_param.py
new file mode 100644
index 00000000000..806417945ea
--- /dev/null
+++ b/airflow-core/tests/unit/serialization/definitions/test_param.py
@@ -0,0 +1,60 @@
+# 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
+
+import pytest
+
+from airflow.serialization.definitions.param import SerializedParam
+
+
+class TestSerializedParam:
+    def test_resolve_no_schema(self):
+        """Test resolve when no schema is provided."""
+        assert SerializedParam(default=42).resolve() == 42
+
+    @pytest.mark.parametrize(
+        "duration",
+        [
+            pytest.param("PT15M", id="minutes-only"),
+            pytest.param("P1Y", id="years-only"),
+            pytest.param("P1W", id="weeks-only"),
+            pytest.param("P1D", id="days-only"),
+            pytest.param("PT1H", id="hours-only"),
+            pytest.param("PT30S", id="seconds-only"),
+            pytest.param("P1DT2H", id="days-and-hours"),
+            pytest.param("P1Y2M3DT4H5M6S", id="full-duration"),
+            pytest.param("PT1.5H", id="fractional-hours-dot"),
+        ],
+    )
+    def test_string_duration_format(self, duration):
+        """Test valid ISO 8601 duration strings."""
+        assert SerializedParam(duration, type="string", 
format="duration").resolve(raises=True) == duration
+
+    @pytest.mark.parametrize(
+        "duration",
+        [
+            pytest.param("P", id="bare-P"),
+            pytest.param("PT", id="bare-PT"),
+            pytest.param("invalid", id="plain-text"),
+            pytest.param("15M", id="missing-P-prefix"),
+            pytest.param("1Y2M", id="no-P-prefix"),
+        ],
+    )
+    def test_string_duration_format_error(self, duration):
+        """Test invalid ISO 8601 duration strings."""
+        with pytest.raises(Exception, match="is not a 'duration'"):
+            SerializedParam(duration, type="string", 
format="duration").resolve(raises=True)
diff --git a/task-sdk/pyproject.toml b/task-sdk/pyproject.toml
index 5937011e8d2..795fc6c5c05 100644
--- a/task-sdk/pyproject.toml
+++ b/task-sdk/pyproject.toml
@@ -90,6 +90,7 @@ dependencies = [
     # Start of shared observability dependencies
     "opentelemetry-api>=1.27.0",
     # End of shared observability dependencies
+    "isoduration>=20.11.0"
 ]
 
 [project.optional-dependencies]
diff --git a/task-sdk/src/airflow/sdk/definitions/param.py 
b/task-sdk/src/airflow/sdk/definitions/param.py
index d1174ec561b..ab319d20c9e 100644
--- a/task-sdk/src/airflow/sdk/definitions/param.py
+++ b/task-sdk/src/airflow/sdk/definitions/param.py
@@ -95,8 +95,7 @@ class Param:
         :param suppress_exception: To raise an exception or not when the 
validations fails.
             If true and validations fails, the return value would be None.
         """
-        import jsonschema
-        from jsonschema import FormatChecker
+        from jsonschema import FormatChecker, validate
         from jsonschema.exceptions import ValidationError
 
         if value is not NOTSET:
@@ -107,7 +106,12 @@ class Param:
                 return None
             raise ParamValidationError("No value passed and Param has no 
default value")
         try:
-            jsonschema.validate(final_val, self.schema, 
format_checker=FormatChecker())
+            validate(
+                final_val,
+                self.schema,
+                format_checker=FormatChecker(),
+            )
+
         except ValidationError as err:
             if suppress_exception:
                 return None
diff --git a/task-sdk/tests/task_sdk/definitions/test_param.py 
b/task-sdk/tests/task_sdk/definitions/test_param.py
index 887003d13dc..40841129754 100644
--- a/task-sdk/tests/task_sdk/definitions/test_param.py
+++ b/task-sdk/tests/task_sdk/definitions/test_param.py
@@ -138,6 +138,39 @@ class TestParam:
         with pytest.raises(ParamValidationError, match="is not a 'date'"):
             Param(date_string, type="string", format="date").resolve()
 
+    @pytest.mark.parametrize(
+        "duration",
+        [
+            pytest.param("PT15M", id="minutes-only"),
+            pytest.param("P1Y", id="years-only"),
+            pytest.param("P1W", id="weeks-only"),
+            pytest.param("P1D", id="days-only"),
+            pytest.param("PT1H", id="hours-only"),
+            pytest.param("PT30S", id="seconds-only"),
+            pytest.param("P1DT2H", id="days-and-hours"),
+            pytest.param("P1Y2M3DT4H5M6S", id="full-duration"),
+            pytest.param("PT1.5H", id="fractional-hours-dot"),
+        ],
+    )
+    def test_string_duration_format(self, duration):
+        """Test valid ISO 8601 duration strings."""
+        assert Param(duration, type="string", format="duration").resolve() == 
duration
+
+    @pytest.mark.parametrize(
+        "duration",
+        [
+            pytest.param("P", id="bare-P"),
+            pytest.param("PT", id="bare-PT"),
+            pytest.param("invalid", id="plain-text"),
+            pytest.param("15M", id="missing-P-prefix"),
+            pytest.param("1Y2M", id="no-P-prefix"),
+        ],
+    )
+    def test_string_duration_format_error(self, duration):
+        """Test invalid ISO 8601 duration strings."""
+        with pytest.raises(ParamValidationError, match="is not a 'duration'"):
+            Param(duration, type="string", format="duration").resolve()
+
     def test_int_param(self):
         p = Param(5)
         assert p.resolve() == 5
diff --git a/uv.lock b/uv.lock
index 49ea5a7b877..c3402fbff19 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1900,6 +1900,7 @@ dependencies = [
     { name = "fastapi", extra = ["standard-no-fastapi-cloud-cli"] },
     { name = "httpx" },
     { name = "importlib-metadata" },
+    { name = "isoduration" },
     { name = "itsdangerous" },
     { name = "jinja2" },
     { name = "jsonschema" },
@@ -2037,6 +2038,7 @@ requires-dist = [
     { name = "httpx", specifier = ">=0.25.0" },
     { name = "importlib-metadata", marker = "python_full_version < '3.12'", 
specifier = ">=6.5" },
     { name = "importlib-metadata", marker = "python_full_version >= '3.12'", 
specifier = ">=7.0" },
+    { name = "isoduration", specifier = ">=20.11.0" },
     { name = "itsdangerous", specifier = ">=2.0" },
     { name = "jinja2", specifier = ">=3.1.5" },
     { name = "jsonschema", specifier = ">=4.19.1" },
@@ -8740,6 +8742,7 @@ dependencies = [
     { name = "greenback" },
     { name = "httpx" },
     { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
+    { name = "isoduration" },
     { name = "jinja2" },
     { name = "jsonschema" },
     { name = "methodtools" },
@@ -8816,6 +8819,7 @@ requires-dist = [
     { name = "greenback", specifier = ">=1.2.1" },
     { name = "httpx", specifier = ">=0.27.0" },
     { name = "importlib-metadata", marker = "python_full_version < '3.12'", 
specifier = ">=6.5" },
+    { name = "isoduration", specifier = ">=20.11.0" },
     { name = "jinja2", specifier = ">=3.1.5" },
     { name = "jsonschema", specifier = ">=4.19.1" },
     { name = "methodtools", specifier = ">=0.4.7" },
@@ -9026,6 +9030,19 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/12/03/8653a2dce9f3908fe01fb5dc5aaaabd89dcd9f22bbdaa50745aad7c47a7a/arro3_core-0.8.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl";,
 hash = 
"sha256:d285aab000ef4ad4d91597e9662298ad3ac774939e8accea96a6522815331896", size 
= 3209809, upload-time = "2026-02-23T15:12:19.359Z" },
 ]
 
+[[package]]
+name = "arrow"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "python-dateutil" },
+    { name = "tzdata" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz";,
 hash = 
"sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size 
= 152931, upload-time = "2025-10-18T17:46:46.761Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl";,
 hash = 
"sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size 
= 68797, upload-time = "2025-10-18T17:46:45.663Z" },
+]
+
 [[package]]
 name = "asana"
 version = "5.2.4"
@@ -14340,6 +14357,18 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl";,
 hash = 
"sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size 
= 22320, upload-time = "2024-10-08T23:04:09.501Z" },
 ]
 
+[[package]]
+name = "isoduration"
+version = "20.11.0"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "arrow" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz";,
 hash = 
"sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size 
= 11649, upload-time = "2020-11-01T11:00:00.312Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl";,
 hash = 
"sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size 
= 11321, upload-time = "2020-11-01T10:59:58.02Z" },
+]
+
 [[package]]
 name = "isort"
 version = "6.1.0"

Reply via email to