This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun 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 3733e62d6a4 Type asset_expression in the REST API so the UI does not
cast through unknown (#67725)
3733e62d6a4 is described below
commit 3733e62d6a4262a4e4e11759ebc201d25e9a9142
Author: Anurag Pappula <[email protected]>
AuthorDate: Fri Jun 26 20:14:36 2026 +0530
Type asset_expression in the REST API so the UI does not cast through
unknown (#67725)
* Type asset_expression in the REST API instead of an untyped dict
asset_expression was declared as `dict | None` on the REST API response
models, so the OpenAPI generator emitted an opaque `{ [k: string]: unknown
}`
and the UI hand-maintained an ExpressionType union and cast the value
through
it with no runtime check. A change to the server shape would not be caught
by
the TypeScript build.
This adds a structured, recursive AssetExpression model in
datamodels/common.py: a discriminated union over the five shapes
BaseAsset.as_expression() produces (asset, alias, asset_ref, any, all),
following the existing Annotated[Union[...], Discriminator] pattern. It is
used on every response that serves the field (DAG details, DAGs with latest
runs, and both partitioned dag run responses), types the next_run_assets UI
endpoint with a NextRunAssetsResponse model, regenerates the OpenAPI spec
and
the TypeScript client, and removes the now-redundant hand-written union and
casts in the UI.
The asset leaf carries the optional id that DagModelOperation injects on
persistence; id is optional so a row written before id-enrichment, or
migrated from the pre-3.0 dataset format, still validates. For expressions
produced by current code the emitted JSON is unchanged, so this is not a
breaking change for API consumers; only the declared schema becomes precise.
The test_get_dags fixtures previously set asset_expression to a simplified
`{"any": [{"uri": ...}]}` shape that as_expression() never produces. That
was
harmless against an untyped dict but the structured model correctly rejects
the bare leaf, so the fixtures now use the real `{"any": [{"asset":
{...}}]}`
shape with each asset's name and group.
* Derive ExpressionType from generated types, drop manual aliases
ExpressionType is now NonNullable<DAGDetailsResponse["asset_expression"]>
instead of a hand-maintained union that could drift from the server shape.
Remove AssetSummary and inline its union at the only use site (AssetNode),
and drop the explanatory comment. Per review feedback.
* Remove unused NextRunEvent type from AssetExpression
* Apply ruff-format to partitioned_dag_runs route
Wrap the over-length created_at/updated_at ternaries so the ruff-format
pre-commit hook passes. No behaviour change.
* Degrade legacy asset_expression shapes to null instead of 500ing the API
The newly-typed asset_expression field rejects pre-3.0 dataset shapes (a
bare uri string, {"any": [<strings>]}, {"alias": "<name>"}) that the 2.x->3.x
column rename carried over verbatim, which would surface as an HTTP 500 on the
dag-detail, dag-list and next_run_assets endpoints until the dag is re-parsed.
MaybeAssetExpression wraps the union with a BeforeValidator that coerces any
unrecognized stored shape to null, reproducing the blank render the UI showed
while the field was an un [...]
* Log a warning when an unrecognized asset_expression shape is dropped
Per review feedback: the tolerant validator silently coerced any
unrecognized shape to None, which also masked genuine server/UI shape drift
this PR guards against. Emit a warning when a non-None value is dropped so the
drift stays observable. Graceful degradation behaviour is unchanged.
* Extract response dict into a named variable before model_validate
Per review feedback: pull the dict passed to .model_validate() into a
model_data local in the UI routes that build responses from a dict, which reads
better for the larger payloads. No behaviour change.
* Annotate extracted model_data dicts as dict[str, Any]
The dict literals were inferred with a narrow value type once pulled into a
local, so mypy rejected heterogeneous entries (e.g. pending_partition_count:
int | None). Declare them as dict[str, Any], which is what model_validate
accepts, annotating the first occurrence per function scope to avoid no-redef.
* Add route test for legacy asset_expression served as null
Per review: a pre-3.0 dataset-format asset_expression (string leaves) is
served as null on /dags/{id}/details instead of 500ing the endpoint, exercising
MaybeAssetExpression end-to-end.
---
.../api_fastapi/core_api/datamodels/common.py | 123 +++++++++-
.../api_fastapi/core_api/datamodels/dags.py | 3 +-
.../api_fastapi/core_api/datamodels/ui/assets.py | 3 +-
.../api_fastapi/core_api/datamodels/ui/dags.py | 3 +-
.../core_api/datamodels/ui/partitioned_dag_runs.py | 5 +-
.../api_fastapi/core_api/openapi/_private_ui.yaml | 149 +++++++++++-
.../core_api/openapi/v2-rest-api-generated.yaml | 125 +++++++++-
.../api_fastapi/core_api/routes/ui/assets.py | 27 ++-
.../core_api/routes/ui/partitioned_dag_runs.py | 38 ++--
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 253 ++++++++++++++++++++-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 80 +++++--
.../components/AssetExpression/AssetExpression.tsx | 6 +-
.../src/components/AssetExpression/AssetNode.tsx | 9 +-
.../ui/src/components/AssetExpression/index.ts | 2 +-
.../ui/src/components/AssetExpression/types.ts | 27 +--
.../ui/src/components/AssetProgressCell.tsx | 4 +-
.../ui/src/pages/DagsList/AssetSchedule.tsx | 2 +-
.../api_fastapi/core_api/datamodels/test_common.py | 117 ++++++++++
.../core_api/routes/public/test_dags.py | 35 ++-
.../src/airflowctl/api/datamodels/generated.py | 234 +++++++++++++------
20 files changed, 1066 insertions(+), 179 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
index 48b55f2f1e6..75fd131d2a2 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/common.py
@@ -23,12 +23,133 @@ Common Data Models for Airflow REST API.
from __future__ import annotations
import enum
+import logging
from typing import Annotated, Any, Generic, Literal, TypeVar, Union
-from pydantic import Discriminator, Field, Tag
+from pydantic import BeforeValidator, Discriminator, Field, Tag, TypeAdapter,
ValidationError
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+log = logging.getLogger(__name__)
+
+# Asset Scheduling Expression Data Models
+#
+# These mirror the JSON produced by ``BaseAsset.as_expression()`` (see
+# ``airflow.serialization.definitions.assets``), which is stored verbatim in
+# ``DagModel.asset_expression``. Declaring them gives the REST API -- and the
+# TypeScript client generated from its OpenAPI spec -- a real type instead of
an
+# opaque ``dict``. The shape is a recursive boolean tree whose leaves are
assets,
+# asset aliases, or asset references.
+
+
+class AssetExpressionAssetInfo(BaseModel):
+ """
+ Body of an ``asset`` leaf node.
+
+ ``id`` is injected by ``DagModelOperation.update_dag_asset_expression``
when the expression is
+ persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
+ optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
+ degrades gracefully instead of failing response validation.
+ """
+
+ uri: str
+ name: str
+ group: str
+ id: int | None = None
+
+
+class AssetExpressionAliasInfo(BaseModel):
+ """Body of an ``alias`` leaf node."""
+
+ name: str
+ group: str
+
+
+class AssetExpressionAsset(BaseModel):
+ """An asset leaf: ``{"asset": {"uri": ..., "name": ..., "group":
...}}``."""
+
+ asset: AssetExpressionAssetInfo
+
+
+class AssetExpressionAlias(BaseModel):
+ """An asset alias leaf: ``{"alias": {"name": ..., "group": ...}}``."""
+
+ alias: AssetExpressionAliasInfo
+
+
+class AssetExpressionRef(BaseModel):
+ """An unresolved asset reference leaf: ``{"asset_ref": {"name": ...}}`` or
``{"asset_ref": {"uri": ...}}``."""
+
+ asset_ref: dict[str, str]
+
+
+class AssetExpressionAny(BaseModel):
+ """An "or" node: ``{"any": [...]}`` -- satisfied when any child is
satisfied."""
+
+ any: list[AssetExpression]
+
+
+class AssetExpressionAll(BaseModel):
+ """An "and" node: ``{"all": [...]}`` -- satisfied when all children are
satisfied."""
+
+ all: list[AssetExpression]
+
+
+def _asset_expression_discriminator(value: Any) -> str | None:
+ """Select an expression variant by the single key that is present."""
+ keys = ("asset", "alias", "asset_ref", "any", "all")
+ if isinstance(value, dict):
+ present = [key for key in keys if key in value]
+ else:
+ present = [key for key in keys if getattr(value, key, None) is not
None]
+ return present[0] if len(present) == 1 else None
+
+
+AssetExpression = Annotated[
+ Union[
+ Annotated[AssetExpressionAsset, Tag("asset")],
+ Annotated[AssetExpressionAlias, Tag("alias")],
+ Annotated[AssetExpressionRef, Tag("asset_ref")],
+ Annotated[AssetExpressionAny, Tag("any")],
+ Annotated[AssetExpressionAll, Tag("all")],
+ ],
+ Discriminator(_asset_expression_discriminator),
+]
+"""A nested asset scheduling expression; see ``BaseAsset.as_expression()``."""
+
+AssetExpressionAny.model_rebuild()
+AssetExpressionAll.model_rebuild()
+
+_asset_expression_adapter: TypeAdapter = TypeAdapter(AssetExpression)
+
+
+def _coerce_unrecognized_expression_to_none(value: Any) -> Any:
+ """
+ Degrade an unrecognized ``asset_expression`` to ``None`` instead of
failing response validation.
+
+ ``DagModel.asset_expression`` is stored verbatim from
``BaseAsset.as_expression()`` and is rewritten
+ to the current shape whenever a Dag is parsed
(``DagModelOperation.update_dag_asset_expression``). A
+ row written by the pre-3.0 dataset scheduler and not yet re-parsed can
still hold a legacy shape --
+ a bare uri string, ``{"any": ["s3://..."]}``, or ``{"alias": "<name>"}``
-- that the typed model does
+ not recognise. Serving such a row as ``None`` reproduces the blank render
the UI showed while this
+ field was an untyped ``dict``, rather than turning stored data into an
HTTP 500.
+ """
+ if value is None:
+ return None
+ try:
+ _asset_expression_adapter.validate_python(value)
+ except ValidationError:
+ log.warning("Dropping unrecognized asset_expression shape to None:
%r", value)
+ return None
+ return value
+
+
+MaybeAssetExpression = Annotated[
+ Union[AssetExpression, None],
+ BeforeValidator(_coerce_unrecognized_expression_to_none),
+]
+"""``AssetExpression | None`` that degrades a legacy/unrecognized stored shape
to ``None``."""
+
# Common Bulk Data Models
T = TypeVar("T")
K = TypeVar("K")
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
index dea97c46352..31c9d24fa16 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/dags.py
@@ -35,6 +35,7 @@ from pydantic import (
from airflow._shared.module_loading import qualname
from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel,
make_partial_model
+from airflow.api_fastapi.core_api.datamodels.common import MaybeAssetExpression
from airflow.api_fastapi.core_api.datamodels.dag_tags import DagTagResponse
from airflow.api_fastapi.core_api.datamodels.dag_versions import
DagVersionResponse
from airflow.configuration import conf
@@ -191,7 +192,7 @@ class DAGDetailsResponse(DAGResponse):
catchup: bool
dag_run_timeout: timedelta | None
- asset_expression: dict | None
+ asset_expression: MaybeAssetExpression
doc_md: str | None
start_date: datetime | None
end_date: datetime | None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/assets.py
index 446c0659361..54847eae467 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/assets.py
@@ -21,6 +21,7 @@ from datetime import datetime
from pydantic import Field
from airflow.api_fastapi.core_api.base import BaseModel
+from airflow.api_fastapi.core_api.datamodels.common import MaybeAssetExpression
class NextRunAssetEventResponse(BaseModel):
@@ -49,6 +50,6 @@ class NextRunAssetEventResponse(BaseModel):
class NextRunAssetsResponse(BaseModel):
"""Response for the ``next_run_assets`` endpoint."""
- asset_expression: dict | None = None
+ asset_expression: MaybeAssetExpression = None
events: list[NextRunAssetEventResponse]
pending_partition_count: int | None = None
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
index fc2fe3ed2d2..febdf45c0a8 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py
@@ -18,6 +18,7 @@
from __future__ import annotations
from airflow.api_fastapi.core_api.base import BaseModel
+from airflow.api_fastapi.core_api.datamodels.common import MaybeAssetExpression
from airflow.api_fastapi.core_api.datamodels.dags import DAGResponse
from airflow.api_fastapi.core_api.datamodels.hitl import HITLDetail
from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import
DAGRunLightResponse
@@ -26,7 +27,7 @@ from airflow.api_fastapi.core_api.datamodels.ui.dag_runs
import DAGRunLightRespo
class DAGWithLatestDagRunsResponse(DAGResponse):
"""DAG with latest dag runs response serializer."""
- asset_expression: dict | None
+ asset_expression: MaybeAssetExpression
latest_dag_runs: list[DAGRunLightResponse]
pending_actions: list[HITLDetail]
is_favorite: bool
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
index b7cbdc70566..79a284b3f3f 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/partitioned_dag_runs.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from airflow.api_fastapi.core_api.base import BaseModel
+from airflow.api_fastapi.core_api.datamodels.common import MaybeAssetExpression
class PartitionedDagRunResponse(BaseModel):
@@ -37,7 +38,7 @@ class PartitionedDagRunCollectionResponse(BaseModel):
partitioned_dag_runs: list[PartitionedDagRunResponse]
total: int
- asset_expressions: dict[str, dict | None] | None = None
+ asset_expressions: dict[str, MaybeAssetExpression] | None = None
class PartitionedDagRunAssetResponse(BaseModel):
@@ -75,4 +76,4 @@ class PartitionedDagRunDetailResponse(BaseModel):
assets: list[PartitionedDagRunAssetResponse]
total_required: int
total_received: int
- asset_expression: dict | None = None
+ asset_expression: MaybeAssetExpression = None
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 5d51f7b7480..5a62197dcfc 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
@@ -1736,6 +1736,123 @@ paths:
$ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
+ AssetExpressionAlias:
+ properties:
+ alias:
+ $ref: '#/components/schemas/AssetExpressionAliasInfo'
+ type: object
+ required:
+ - alias
+ title: AssetExpressionAlias
+ description: 'An asset alias leaf: ``{"alias": {"name": ..., "group":
...}}``.'
+ AssetExpressionAliasInfo:
+ properties:
+ name:
+ type: string
+ title: Name
+ group:
+ type: string
+ title: Group
+ type: object
+ required:
+ - name
+ - group
+ title: AssetExpressionAliasInfo
+ description: Body of an ``alias`` leaf node.
+ AssetExpressionAll:
+ properties:
+ all:
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
+ type: array
+ title: All
+ type: object
+ required:
+ - all
+ title: AssetExpressionAll
+ description: 'An "and" node: ``{"all": [...]}`` -- satisfied when all
children
+ are satisfied.'
+ AssetExpressionAny:
+ properties:
+ any:
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
+ type: array
+ title: Any
+ type: object
+ required:
+ - any
+ title: AssetExpressionAny
+ description: 'An "or" node: ``{"any": [...]}`` -- satisfied when any
child is
+ satisfied.'
+ AssetExpressionAsset:
+ properties:
+ asset:
+ $ref: '#/components/schemas/AssetExpressionAssetInfo'
+ type: object
+ required:
+ - asset
+ title: AssetExpressionAsset
+ description: 'An asset leaf: ``{"asset": {"uri": ..., "name": ...,
"group":
+ ...}}``.'
+ AssetExpressionAssetInfo:
+ properties:
+ uri:
+ type: string
+ title: Uri
+ name:
+ type: string
+ title: Name
+ group:
+ type: string
+ title: Group
+ id:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Id
+ type: object
+ required:
+ - uri
+ - name
+ - group
+ title: AssetExpressionAssetInfo
+ description: 'Body of an ``asset`` leaf node.
+
+
+ ``id`` is injected by
``DagModelOperation.update_dag_asset_expression`` when
+ the expression is
+
+ persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``.
+ It is left
+
+ optional so a row persisted before id-enrichment (or migrated from the
pre-3.0
+ dataset format)
+
+ degrades gracefully instead of failing response validation.'
+ AssetExpressionRef:
+ properties:
+ asset_ref:
+ additionalProperties:
+ type: string
+ type: object
+ title: Asset Ref
+ type: object
+ required:
+ - asset_ref
+ title: AssetExpressionRef
+ description: 'An unresolved asset reference leaf: ``{"asset_ref":
{"name": ...}}``
+ or ``{"asset_ref": {"uri": ...}}``.'
AuthenticatedMeResponse:
properties:
id:
@@ -2295,8 +2412,12 @@ components:
title: Owners
asset_expression:
anyOf:
- - additionalProperties: true
- type: object
+ - oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
- type: 'null'
title: Asset Expression
latest_dag_runs:
@@ -3239,8 +3360,12 @@ components:
properties:
asset_expression:
anyOf:
- - additionalProperties: true
- type: object
+ - oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
- type: 'null'
title: Asset Expression
events:
@@ -3400,8 +3525,12 @@ components:
anyOf:
- additionalProperties:
anyOf:
- - additionalProperties: true
- type: object
+ - oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
- type: 'null'
type: object
- type: 'null'
@@ -3451,8 +3580,12 @@ components:
title: Total Received
asset_expression:
anyOf:
- - additionalProperties: true
- type: object
+ - oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
- type: 'null'
title: Asset Expression
type: object
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index ea6f0a70fcd..44f797e1d3c 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -11624,6 +11624,123 @@ components:
- timestamp
title: AssetEventResponse
description: Asset event serializer for responses.
+ AssetExpressionAlias:
+ properties:
+ alias:
+ $ref: '#/components/schemas/AssetExpressionAliasInfo'
+ type: object
+ required:
+ - alias
+ title: AssetExpressionAlias
+ description: 'An asset alias leaf: ``{"alias": {"name": ..., "group":
...}}``.'
+ AssetExpressionAliasInfo:
+ properties:
+ name:
+ type: string
+ title: Name
+ group:
+ type: string
+ title: Group
+ type: object
+ required:
+ - name
+ - group
+ title: AssetExpressionAliasInfo
+ description: Body of an ``alias`` leaf node.
+ AssetExpressionAll:
+ properties:
+ all:
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
+ type: array
+ title: All
+ type: object
+ required:
+ - all
+ title: AssetExpressionAll
+ description: 'An "and" node: ``{"all": [...]}`` -- satisfied when all
children
+ are satisfied.'
+ AssetExpressionAny:
+ properties:
+ any:
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
+ type: array
+ title: Any
+ type: object
+ required:
+ - any
+ title: AssetExpressionAny
+ description: 'An "or" node: ``{"any": [...]}`` -- satisfied when any
child is
+ satisfied.'
+ AssetExpressionAsset:
+ properties:
+ asset:
+ $ref: '#/components/schemas/AssetExpressionAssetInfo'
+ type: object
+ required:
+ - asset
+ title: AssetExpressionAsset
+ description: 'An asset leaf: ``{"asset": {"uri": ..., "name": ...,
"group":
+ ...}}``.'
+ AssetExpressionAssetInfo:
+ properties:
+ uri:
+ type: string
+ title: Uri
+ name:
+ type: string
+ title: Name
+ group:
+ type: string
+ title: Group
+ id:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Id
+ type: object
+ required:
+ - uri
+ - name
+ - group
+ title: AssetExpressionAssetInfo
+ description: 'Body of an ``asset`` leaf node.
+
+
+ ``id`` is injected by
``DagModelOperation.update_dag_asset_expression`` when
+ the expression is
+
+ persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``.
+ It is left
+
+ optional so a row persisted before id-enrichment (or migrated from the
pre-3.0
+ dataset format)
+
+ degrades gracefully instead of failing response validation.'
+ AssetExpressionRef:
+ properties:
+ asset_ref:
+ additionalProperties:
+ type: string
+ type: object
+ title: Asset Ref
+ type: object
+ required:
+ - asset_ref
+ title: AssetExpressionRef
+ description: 'An unresolved asset reference leaf: ``{"asset_ref":
{"name": ...}}``
+ or ``{"asset_ref": {"uri": ...}}``.'
AssetResponse:
properties:
id:
@@ -13318,8 +13435,12 @@ components:
title: Dag Run Timeout
asset_expression:
anyOf:
- - additionalProperties: true
- type: object
+ - oneOf:
+ - $ref: '#/components/schemas/AssetExpressionAsset'
+ - $ref: '#/components/schemas/AssetExpressionAlias'
+ - $ref: '#/components/schemas/AssetExpressionRef'
+ - $ref: '#/components/schemas/AssetExpressionAny'
+ - $ref: '#/components/schemas/AssetExpressionAll'
- type: 'null'
title: Asset Expression
doc_md:
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
index 4c0e34fd297..d3da95178a1 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/assets.py
@@ -16,7 +16,7 @@
# under the License.
from __future__ import annotations
-from typing import TYPE_CHECKING, cast
+from typing import TYPE_CHECKING, Any, cast
import structlog
from fastapi import Depends, HTTPException, status
@@ -147,7 +147,8 @@ def next_run_assets(
)
for row in raw_rows
]
- return
NextRunAssetsResponse(asset_expression=dag_model.asset_expression,
events=events)
+ model_data: dict[str, Any] = {"asset_expression":
dag_model.asset_expression, "events": events}
+ return NextRunAssetsResponse.model_validate(model_data)
# Partitioned Dags: enrich with per-asset received/required counts and
rollup flag.
# FIFO matches the scheduler's pending-APDR processing order
@@ -180,11 +181,12 @@ def next_run_assets(
)
for row in raw_rows
]
- return NextRunAssetsResponse(
- asset_expression=dag_model.asset_expression,
- events=events,
- pending_partition_count=pending_partition_count,
- )
+ model_data = {
+ "asset_expression": dag_model.asset_expression,
+ "events": events,
+ "pending_partition_count": pending_partition_count,
+ }
+ return NextRunAssetsResponse.model_validate(model_data)
# Collect received upstream partition keys per asset for this partition
run.
# Use a set to deduplicate: multiple events for the same key count as one.
@@ -253,8 +255,9 @@ def next_run_assets(
)
)
- return NextRunAssetsResponse(
- asset_expression=dag_model.asset_expression,
- events=events,
- pending_partition_count=pending_partition_count,
- )
+ model_data = {
+ "asset_expression": dag_model.asset_expression,
+ "events": events,
+ "pending_partition_count": pending_partition_count,
+ }
+ return NextRunAssetsResponse.model_validate(model_data)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
index e7f949e92cf..29b393070e3 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/partitioned_dag_runs.py
@@ -16,7 +16,7 @@
# under the License.
from __future__ import annotations
-from typing import TYPE_CHECKING, NamedTuple, TypeAlias, cast
+from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, cast
import structlog
from fastapi import Depends, HTTPException, status
@@ -334,11 +334,12 @@ def get_partitioned_dag_runs(
if dag_id.value is None:
asset_expressions = {dm.dag_id: dm.asset_expression for dm in
dag_models.values()}
- return PartitionedDagRunCollectionResponse(
- partitioned_dag_runs=results,
- total=len(results),
- asset_expressions=asset_expressions,
- )
+ model_data: dict[str, Any] = {
+ "partitioned_dag_runs": results,
+ "total": len(results),
+ "asset_expressions": asset_expressions,
+ }
+ return PartitionedDagRunCollectionResponse.model_validate(model_data)
@partitioned_dag_runs_router.get(
@@ -463,15 +464,16 @@ def get_pending_partitioned_dag_run(
total_required = sum(a.required_count for a in assets)
asset_expression = dag_model.asset_expression if dag_model is not None
else None
- return PartitionedDagRunDetailResponse(
- id=partitioned_dag_run.id,
- dag_id=dag_id,
- partition_key=partition_key,
- created_at=partitioned_dag_run.created_at.isoformat() if
partitioned_dag_run.created_at else None,
- updated_at=partitioned_dag_run.updated_at.isoformat() if
partitioned_dag_run.updated_at else None,
- created_dag_run_id=partitioned_dag_run.created_dag_run_id,
- assets=assets,
- total_required=total_required,
- total_received=total_received,
- asset_expression=asset_expression,
- )
+ model_data: dict[str, Any] = {
+ "id": partitioned_dag_run.id,
+ "dag_id": dag_id,
+ "partition_key": partition_key,
+ "created_at": partitioned_dag_run.created_at.isoformat() if
partitioned_dag_run.created_at else None,
+ "updated_at": partitioned_dag_run.updated_at.isoformat() if
partitioned_dag_run.updated_at else None,
+ "created_dag_run_id": partitioned_dag_run.created_dag_run_id,
+ "assets": assets,
+ "total_required": total_required,
+ "total_received": total_received,
+ "asset_expression": asset_expression,
+ }
+ return PartitionedDagRunDetailResponse.model_validate(model_data)
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 0f98b36e8c7..ebf27dbbb84 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
@@ -315,6 +315,164 @@ export const $AssetEventResponse = {
description: 'Asset event serializer for responses.'
} as const;
+export const $AssetExpressionAlias = {
+ properties: {
+ alias: {
+ '$ref': '#/components/schemas/AssetExpressionAliasInfo'
+ }
+ },
+ type: 'object',
+ required: ['alias'],
+ title: 'AssetExpressionAlias',
+ description: 'An asset alias leaf: ``{"alias": {"name": ..., "group":
...}}``.'
+} as const;
+
+export const $AssetExpressionAliasInfo = {
+ properties: {
+ name: {
+ type: 'string',
+ title: 'Name'
+ },
+ group: {
+ type: 'string',
+ title: 'Group'
+ }
+ },
+ type: 'object',
+ required: ['name', 'group'],
+ title: 'AssetExpressionAliasInfo',
+ description: 'Body of an ``alias`` leaf node.'
+} as const;
+
+export const $AssetExpressionAll = {
+ properties: {
+ all: {
+ items: {
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
+ },
+ type: 'array',
+ title: 'All'
+ }
+ },
+ type: 'object',
+ required: ['all'],
+ title: 'AssetExpressionAll',
+ description: 'An "and" node: ``{"all": [...]}`` -- satisfied when all
children are satisfied.'
+} as const;
+
+export const $AssetExpressionAny = {
+ properties: {
+ any: {
+ items: {
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
+ },
+ type: 'array',
+ title: 'Any'
+ }
+ },
+ type: 'object',
+ required: ['any'],
+ title: 'AssetExpressionAny',
+ description: 'An "or" node: ``{"any": [...]}`` -- satisfied when any child
is satisfied.'
+} as const;
+
+export const $AssetExpressionAsset = {
+ properties: {
+ asset: {
+ '$ref': '#/components/schemas/AssetExpressionAssetInfo'
+ }
+ },
+ type: 'object',
+ required: ['asset'],
+ title: 'AssetExpressionAsset',
+ description: 'An asset leaf: ``{"asset": {"uri": ..., "name": ...,
"group": ...}}``.'
+} as const;
+
+export const $AssetExpressionAssetInfo = {
+ properties: {
+ uri: {
+ type: 'string',
+ title: 'Uri'
+ },
+ name: {
+ type: 'string',
+ title: 'Name'
+ },
+ group: {
+ type: 'string',
+ title: 'Group'
+ },
+ id: {
+ anyOf: [
+ {
+ type: 'integer'
+ },
+ {
+ type: 'null'
+ }
+ ],
+ title: 'Id'
+ }
+ },
+ type: 'object',
+ required: ['uri', 'name', 'group'],
+ title: 'AssetExpressionAssetInfo',
+ description: `Body of an \`\`asset\`\` leaf node.
+
+\`\`id\`\` is injected by
\`\`DagModelOperation.update_dag_asset_expression\`\` when the expression is
+persisted; \`\`BaseAsset.as_expression()\`\` itself only emits
\`\`uri\`\`/\`\`name\`\`/\`\`group\`\`. It is left
+optional so a row persisted before id-enrichment (or migrated from the pre-3.0
dataset format)
+degrades gracefully instead of failing response validation.`
+} as const;
+
+export const $AssetExpressionRef = {
+ properties: {
+ asset_ref: {
+ additionalProperties: {
+ type: 'string'
+ },
+ type: 'object',
+ title: 'Asset Ref'
+ }
+ },
+ type: 'object',
+ required: ['asset_ref'],
+ title: 'AssetExpressionRef',
+ description: 'An unresolved asset reference leaf: ``{"asset_ref": {"name":
...}}`` or ``{"asset_ref": {"uri": ...}}``.'
+} as const;
+
export const $AssetResponse = {
properties: {
id: {
@@ -2808,8 +2966,23 @@ export const $DAGDetailsResponse = {
asset_expression: {
anyOf: [
{
- additionalProperties: true,
- type: 'object'
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
},
{
type: 'null'
@@ -8993,8 +9166,23 @@ export const $DAGWithLatestDagRunsResponse = {
asset_expression: {
anyOf: [
{
- additionalProperties: true,
- type: 'object'
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
},
{
type: 'null'
@@ -9864,8 +10052,23 @@ export const $NextRunAssetsResponse = {
asset_expression: {
anyOf: [
{
- additionalProperties: true,
- type: 'object'
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
},
{
type: 'null'
@@ -10087,8 +10290,23 @@ export const $PartitionedDagRunCollectionResponse = {
additionalProperties: {
anyOf: [
{
- additionalProperties: true,
- type: 'object'
+ oneOf: [
+ {
+ '$ref':
'#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref':
'#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref':
'#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref':
'#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref':
'#/components/schemas/AssetExpressionAll'
+ }
+ ]
},
{
type: 'null'
@@ -10175,8 +10393,23 @@ export const $PartitionedDagRunDetailResponse = {
asset_expression: {
anyOf: [
{
- additionalProperties: true,
- type: 'object'
+ oneOf: [
+ {
+ '$ref': '#/components/schemas/AssetExpressionAsset'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAlias'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionRef'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAny'
+ },
+ {
+ '$ref': '#/components/schemas/AssetExpressionAll'
+ }
+ ]
},
{
type: 'null'
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 81b5665a333..83e95991808 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
@@ -83,6 +83,66 @@ export type AssetEventResponse = {
partition_key?: string | null;
};
+/**
+ * An asset alias leaf: ``{"alias": {"name": ..., "group": ...}}``.
+ */
+export type AssetExpressionAlias = {
+ alias: AssetExpressionAliasInfo;
+};
+
+/**
+ * Body of an ``alias`` leaf node.
+ */
+export type AssetExpressionAliasInfo = {
+ name: string;
+ group: string;
+};
+
+/**
+ * An "and" node: ``{"all": [...]}`` -- satisfied when all children are
satisfied.
+ */
+export type AssetExpressionAll = {
+ all: Array<(AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll)>;
+};
+
+/**
+ * An "or" node: ``{"any": [...]}`` -- satisfied when any child is satisfied.
+ */
+export type AssetExpressionAny = {
+ any: Array<(AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll)>;
+};
+
+/**
+ * An asset leaf: ``{"asset": {"uri": ..., "name": ..., "group": ...}}``.
+ */
+export type AssetExpressionAsset = {
+ asset: AssetExpressionAssetInfo;
+};
+
+/**
+ * Body of an ``asset`` leaf node.
+ *
+ * ``id`` is injected by ``DagModelOperation.update_dag_asset_expression``
when the expression is
+ * persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
+ * optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
+ * degrades gracefully instead of failing response validation.
+ */
+export type AssetExpressionAssetInfo = {
+ uri: string;
+ name: string;
+ group: string;
+ id?: number | null;
+};
+
+/**
+ * An unresolved asset reference leaf: ``{"asset_ref": {"name": ...}}`` or
``{"asset_ref": {"uri": ...}}``.
+ */
+export type AssetExpressionRef = {
+ asset_ref: {
+ [key: string]: (string);
+ };
+};
+
/**
* Asset serializer for responses.
*/
@@ -797,9 +857,7 @@ export type DAGDetailsResponse = {
owners: Array<(string)>;
catchup: boolean;
dag_run_timeout: string | null;
- asset_expression: {
- [key: string]: unknown;
-} | null;
+ asset_expression: AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll | null;
doc_md: string | null;
start_date: string | null;
end_date: string | null;
@@ -2263,9 +2321,7 @@ export type DAGWithLatestDagRunsResponse = {
next_dagrun_run_after: string | null;
allowed_run_types: Array<DagRunType> | null;
owners: Array<(string)>;
- asset_expression: {
- [key: string]: unknown;
-} | null;
+ asset_expression: AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll | null;
latest_dag_runs: Array<DAGRunLightResponse>;
pending_actions: Array<HITLDetail>;
is_favorite: boolean;
@@ -2509,9 +2565,7 @@ export type NextRunAssetEventResponse = {
* Response for the ``next_run_assets`` endpoint.
*/
export type NextRunAssetsResponse = {
- asset_expression?: {
- [key: string]: unknown;
-} | null;
+ asset_expression?: AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll | null;
events: Array<NextRunAssetEventResponse>;
pending_partition_count?: number | null;
};
@@ -2558,9 +2612,7 @@ export type PartitionedDagRunCollectionResponse = {
partitioned_dag_runs: Array<PartitionedDagRunResponse>;
total: number;
asset_expressions?: {
- [key: string]: ({
- [key: string]: unknown;
-} | null);
+ [key: string]: (AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll | null);
} | null;
};
@@ -2577,9 +2629,7 @@ export type PartitionedDagRunDetailResponse = {
assets: Array<PartitionedDagRunAssetResponse>;
total_required: number;
total_received: number;
- asset_expression?: {
- [key: string]: unknown;
-} | null;
+ asset_expression?: AssetExpressionAsset | AssetExpressionAlias |
AssetExpressionRef | AssetExpressionAny | AssetExpressionAll | null;
};
/**
diff --git
a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
index c8c0382fd73..3d5e1f2c37a 100644
---
a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
+++
b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetExpression.tsx
@@ -53,7 +53,7 @@ export const AssetExpression = ({
<>
{"any" in expression ? (
<OrGateNode>
- {expression.any?.map((item, index) => (
+ {expression.any.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={`any-${index}`}>
{"asset" in item || "alias" in item ? (
@@ -64,7 +64,7 @@ export const AssetExpression = ({
) : (
<AssetExpression events={events} expression={item} />
)}
- {expression.any && index === expression.any.length - 1 ?
undefined : (
+ {index === expression.any.length - 1 ? undefined : (
<Badge alignItems="center" borderRadius="full" fontSize="sm"
px={3} py={1}>
<TbLogicOr size={18} />
{translate("expression.or")}
@@ -76,7 +76,7 @@ export const AssetExpression = ({
) : undefined}
{"all" in expression ? (
<AndGateNode>
- {expression.all?.map((item, index) => (
+ {expression.all.map((item, index) => (
// eslint-disable-next-line react/no-array-index-key
<Box display="inline-block" key={`all-${index}`}>
{"asset" in item || "alias" in item ? (
diff --git
a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetNode.tsx
b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetNode.tsx
index 1a0d0b289a9..7d2b0c9c5ae 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetExpression/AssetNode.tsx
@@ -20,18 +20,21 @@ import { Box, HStack, Text } from "@chakra-ui/react";
import { FiDatabase } from "react-icons/fi";
import { PiRectangleDashed } from "react-icons/pi";
-import type { NextRunAssetEventResponse } from "openapi/requests/types.gen";
+import type {
+ AssetExpressionAlias,
+ AssetExpressionAsset,
+ NextRunAssetEventResponse,
+} from "openapi/requests/types.gen";
import { RollupKeyChecklistPopover } from "src/components/RollupKeyChecklist";
import { RouterLink } from "src/components/ui";
import Time from "../Time";
-import type { AssetSummary } from "./types";
export const AssetNode = ({
asset,
event,
}: {
- readonly asset: AssetSummary;
+ readonly asset: AssetExpressionAlias | AssetExpressionAsset;
readonly event?: NextRunAssetEventResponse;
}) => {
const isFullyReceived = Boolean(event?.last_update);
diff --git
a/airflow-core/src/airflow/ui/src/components/AssetExpression/index.ts
b/airflow-core/src/airflow/ui/src/components/AssetExpression/index.ts
index c54f53ab6c5..5414b934cd2 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetExpression/index.ts
+++ b/airflow-core/src/airflow/ui/src/components/AssetExpression/index.ts
@@ -18,4 +18,4 @@
*/
export { AssetExpression } from "./AssetExpression";
-export type { ExpressionType, AssetSummary } from "./types";
+export type { ExpressionType } from "./types";
diff --git
a/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
b/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
index cae9bfa524f..486ebc5b497 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
+++ b/airflow-core/src/airflow/ui/src/components/AssetExpression/types.ts
@@ -16,29 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
+import type { DAGDetailsResponse } from "openapi/requests/types.gen";
-type Asset = {
- asset: {
- group: string;
- id: number;
- name: string;
- uri: string;
- };
-};
-
-type Alias = {
- alias: {
- group: string;
- name: string;
- };
-};
-
-export type AssetSummary = Alias | Asset;
-
-export type ExpressionType =
- | Alias
- | Asset
- | {
- all?: Array<AssetSummary | ExpressionType>;
- any?: Array<AssetSummary | ExpressionType>;
- };
+export type ExpressionType =
NonNullable<DAGDetailsResponse["asset_expression"]>;
diff --git a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
index 44ea0d76ee5..fe662437bf3 100644
--- a/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
+++ b/airflow-core/src/airflow/ui/src/components/AssetProgressCell.tsx
@@ -21,7 +21,7 @@ import { FiDatabase } from "react-icons/fi";
import { usePartitionedDagRunServiceGetPendingPartitionedDagRun } from
"openapi/queries";
import type { NextRunAssetEventResponse, PartitionedDagRunAssetResponse } from
"openapi/requests/types.gen";
-import { AssetExpression, type ExpressionType } from
"src/components/AssetExpression";
+import { AssetExpression } from "src/components/AssetExpression";
import { RollupKeyChecklist } from "src/components/RollupKeyChecklist";
import { Popover, RouterLink } from "src/components/ui";
@@ -35,7 +35,7 @@ type Props = {
export const AssetProgressCell = ({ dagId, partitionKey, totalReceived,
totalRequired }: Props) => {
const { data, isLoading } =
usePartitionedDagRunServiceGetPendingPartitionedDagRun({ dagId, partitionKey });
- const assetExpression = data?.asset_expression as ExpressionType | undefined;
+ const assetExpression = data?.asset_expression ?? undefined;
const assets: Array<PartitionedDagRunAssetResponse> = data?.assets ?? [];
const hasRollup = assets.some((ak) => ak.is_rollup);
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
index 5f252cbca58..3037cce9975 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/AssetSchedule.tsx
@@ -259,7 +259,7 @@ export const AssetSchedule = ({ assetExpression, dagId,
timetablePartitioned, ti
<Popover.Body>
<AssetExpression
events={popoverEvents}
- expression={(nextRun?.asset_expression ?? assetExpression) as
ExpressionType | undefined}
+ expression={nextRun?.asset_expression ?? assetExpression ??
undefined}
/>
</Popover.Body>
</Popover.Content>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
new file mode 100644
index 00000000000..2bf6fddb122
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_common.py
@@ -0,0 +1,117 @@
+# 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 pydantic import TypeAdapter, ValidationError
+
+from airflow.api_fastapi.core_api.datamodels.common import AssetExpression,
MaybeAssetExpression
+
+# A single adapter is enough to validate/serialize the discriminated union.
+_adapter: TypeAdapter[AssetExpression] = TypeAdapter(AssetExpression)
+# The field type as actually wired onto the response models: tolerant of
legacy shapes.
+_field_adapter: TypeAdapter[MaybeAssetExpression] =
TypeAdapter(MaybeAssetExpression)
+
+# Leaf nodes as actually stored in ``DagModel.asset_expression``: ``asset``
leaves are
+# enriched with the resolved ``AssetModel.id`` (see
+# ``DagModelOperation.update_dag_asset_expression``), while ``alias`` and
``asset_ref``
+# leaves are left as ``BaseAsset.as_expression()`` produced them.
+_ASSET = {"asset": {"uri": "s3://bucket/key", "name": "my_asset", "group":
"asset", "id": 7}}
+_ALIAS = {"alias": {"name": "my_alias", "group": "asset"}}
+_REF_BY_NAME = {"asset_ref": {"name": "by_name"}}
+_REF_BY_URI = {"asset_ref": {"uri": "s3://bucket/key"}}
+
+
[email protected](
+ "expression",
+ [
+ pytest.param(_ASSET, id="asset"),
+ pytest.param(_ALIAS, id="alias"),
+ pytest.param(_REF_BY_NAME, id="asset_ref_by_name"),
+ pytest.param(_REF_BY_URI, id="asset_ref_by_uri"),
+ pytest.param({"any": [_ASSET, _ALIAS]}, id="any"),
+ pytest.param({"all": [_ASSET, _REF_BY_NAME]}, id="all"),
+ pytest.param({"all": [{"any": [_ASSET]}, _ASSET]}, id="nested"),
+ ],
+)
+def test_asset_expression_round_trips_unchanged(expression: dict):
+ """The typed model must accept and re-serialize each stored expression
byte-identically."""
+ validated = _adapter.validate_python(expression)
+ assert _adapter.dump_python(validated, by_alias=True) == expression
+
+
+def test_asset_expression_tolerates_legacy_asset_leaf_without_id():
+ """
+ ``asset`` leaves written by the current scheduler always carry ``id``, but
a leaf persisted
+ before id-enrichment may not. Such a leaf must still validate -- with
``id`` defaulting to
+ ``None`` -- so a not-yet-enriched row is served instead of returning a
500. (Genuinely legacy
+ pre-3.0 dataset shapes are a separate concern, covered below via
``MaybeAssetExpression``.)
+ """
+ validated = _adapter.validate_python({"asset": {"uri": "s3://b", "name":
"n", "group": "asset"}})
+ assert validated.asset.id is None
+
+
[email protected](
+ "invalid",
+ [
+ pytest.param({}, id="empty"),
+ pytest.param({"unknown": {}}, id="unknown_key"),
+ pytest.param({"asset": _ASSET["asset"], "alias": _ALIAS["alias"]},
id="ambiguous_two_keys"),
+ pytest.param({"asset": {"name": "a", "id": 1}},
id="asset_missing_fields"),
+ ],
+)
+def test_asset_expression_rejects_invalid_shapes(invalid: dict):
+ with pytest.raises(ValidationError):
+ _adapter.validate_python(invalid)
+
+
+# Shapes a released Airflow 2.x stored in ``dataset_expression`` and that the
3.0 column rename
+# (``0041_rename_dataset_as_asset``) carried over verbatim into
``asset_expression``: dataset leaves
+# serialized as a bare uri string, aliases as ``{"alias": "<name>"}``,
composites over those strings.
+_LEGACY_SHAPES = [
+ pytest.param("s3://bucket/key", id="bare_uri_string"),
+ pytest.param({"any": ["s3://a", "s3://b"]}, id="any_of_uri_strings"),
+ pytest.param({"all": ["s3://a", "s3://b"]}, id="all_of_uri_strings"),
+ pytest.param({"alias": "my_alias"}, id="alias_as_string"),
+]
+
+
[email protected]("legacy", _LEGACY_SHAPES)
+def test_field_coerces_legacy_pre_3_0_shapes_to_none(legacy):
+ """
+ A row written by the pre-3.0 dataset scheduler and not yet re-parsed still
holds a legacy shape
+ the typed model cannot describe. The field must serve it as ``None`` --
the blank render the UI
+ showed while this field was an untyped ``dict`` -- rather than 500 the
whole endpoint.
+ """
+ assert _field_adapter.validate_python(legacy) is None
+
+
[email protected](
+ "expression",
+ [
+ pytest.param(None, id="none"),
+ pytest.param(_ASSET, id="asset"),
+ pytest.param({"any": [_ASSET, _ALIAS]}, id="any"),
+ ],
+)
+def test_field_preserves_current_shapes(expression):
+ """Tolerance must not swallow valid current expressions: they pass through
unchanged."""
+ validated = _field_adapter.validate_python(expression)
+ if expression is None:
+ assert validated is None
+ else:
+ assert _field_adapter.dump_python(validated, by_alias=True) ==
expression
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
index f3a3eef61c0..fd01b167263 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_dags.py
@@ -137,7 +137,17 @@ class TestDagEndpoint:
is_stale=False,
is_paused=False,
owners="airflow",
- asset_expression={"any": [{"uri": "test://scheduled_asset"}]},
+ asset_expression={
+ "any": [
+ {
+ "asset": {
+ "uri": "test://scheduled_asset",
+ "name": "scheduled_asset",
+ "group": "test-group",
+ }
+ }
+ ]
+ },
max_active_tasks=16,
max_active_runs=16,
max_consecutive_failed_dag_runs=0,
@@ -156,7 +166,9 @@ class TestDagEndpoint:
is_stale=False,
is_paused=False,
owners="airflow",
- asset_expression={"any": [{"uri": "test://asset1"}]},
+ asset_expression={
+ "any": [{"asset": {"uri": "test://asset1", "name":
"test_asset_1", "group": "test-group"}}]
+ },
max_active_tasks=16,
max_active_runs=16,
max_consecutive_failed_dag_runs=0,
@@ -174,7 +186,11 @@ class TestDagEndpoint:
is_stale=False,
is_paused=False,
owners="airflow",
- asset_expression={"any": [{"uri": "s3://bucket/dataset"}]},
+ asset_expression={
+ "any": [
+ {"asset": {"uri": "s3://bucket/dataset", "name":
"dataset_asset", "group": "test-group"}}
+ ]
+ },
max_active_tasks=16,
max_active_runs=16,
max_consecutive_failed_dag_runs=0,
@@ -1203,6 +1219,19 @@ class TestDagDetails(TestDagEndpoint):
assert isinstance(body["is_favorite"], bool)
assert body["is_favorite"] is False
+ def test_dag_details_serves_legacy_asset_expression_as_null(self, session,
test_client):
+ """A pre-3.0 dataset-format ``asset_expression`` that the typed model
cannot describe is
+ served as ``null`` instead of 500ing the endpoint (handled by
``MaybeAssetExpression``)."""
+ dag_model = session.get(DagModel, DAG2_ID)
+ # The 2.x dataset scheduler stored bare-string leaves; the 3.0 column
rename kept them verbatim.
+ dag_model.asset_expression = {"any": ["s3://legacy-a",
"s3://legacy-b"]}
+ session.commit()
+
+ response = test_client.get(f"/dags/{DAG2_ID}/details")
+
+ assert response.status_code == 200
+ assert response.json()["asset_expression"] is None
+
def test_dag_details_includes_active_runs_count(self, session,
test_client):
"""Test that DAG details include the active_runs_count field."""
# Create running and queued DAG runs for DAG2
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index bd7ce6dd448..7fbe5a86d2e 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -61,6 +61,39 @@ class AssetEventAccessControl(BaseModel):
allow_global: Annotated[bool | None, Field(title="Allow Global")] = True
+class AssetExpressionAliasInfo(BaseModel):
+ """
+ Body of an ``alias`` leaf node.
+ """
+
+ name: Annotated[str, Field(title="Name")]
+ group: Annotated[str, Field(title="Group")]
+
+
+class AssetExpressionAssetInfo(BaseModel):
+ """
+ Body of an ``asset`` leaf node.
+
+ ``id`` is injected by ``DagModelOperation.update_dag_asset_expression``
when the expression is
+ persisted; ``BaseAsset.as_expression()`` itself only emits
``uri``/``name``/``group``. It is left
+ optional so a row persisted before id-enrichment (or migrated from the
pre-3.0 dataset format)
+ degrades gracefully instead of failing response validation.
+ """
+
+ uri: Annotated[str, Field(title="Uri")]
+ name: Annotated[str, Field(title="Name")]
+ group: Annotated[str, Field(title="Group")]
+ id: Annotated[int | None, Field(title="Id")] = None
+
+
+class AssetExpressionRef(BaseModel):
+ """
+ An unresolved asset reference leaf: ``{"asset_ref": {"name": ...}}`` or
``{"asset_ref": {"uri": ...}}``.
+ """
+
+ asset_ref: Annotated[dict[str, str], Field(title="Asset Ref")]
+
+
class AssetStateStoreWriterKind(str, Enum):
"""
Identifies what kind of writer last updated an asset state store entry.
@@ -1305,6 +1338,22 @@ class AssetEventResponse(BaseModel):
partition_key: Annotated[str | None, Field(title="Partition Key")] = None
+class AssetExpressionAlias(BaseModel):
+ """
+ An asset alias leaf: ``{"alias": {"name": ..., "group": ...}}``.
+ """
+
+ alias: AssetExpressionAliasInfo
+
+
+class AssetExpressionAsset(BaseModel):
+ """
+ An asset leaf: ``{"asset": {"uri": ..., "name": ..., "group": ...}}``.
+ """
+
+ asset: AssetExpressionAssetInfo
+
+
class AssetResponse(BaseModel):
"""
Asset serializer for responses.
@@ -1700,76 +1749,6 @@ class ConnectionCollectionResponse(BaseModel):
total_entries: Annotated[int, Field(title="Total Entries")]
-class DAGDetailsResponse(BaseModel):
- """
- Specific serializer for Dag Details responses.
- """
-
- dag_id: Annotated[str, Field(title="Dag Id")]
- dag_display_name: Annotated[str, Field(title="Dag Display Name")]
- is_paused: Annotated[bool, Field(title="Is Paused")]
- is_stale: Annotated[bool, Field(title="Is Stale")]
- last_parsed_time: Annotated[datetime | None, Field(title="Last Parsed
Time")] = None
- last_parse_duration: Annotated[float | None, Field(title="Last Parse
Duration")] = None
- last_expired: Annotated[datetime | None, Field(title="Last Expired")] =
None
- bundle_name: Annotated[str | None, Field(title="Bundle Name")] = None
- bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
- relative_fileloc: Annotated[str | None, Field(title="Relative Fileloc")] =
None
- fileloc: Annotated[str, Field(title="Fileloc")]
- description: Annotated[str | None, Field(title="Description")] = None
- timetable_summary: Annotated[str | None, Field(title="Timetable Summary")]
= None
- timetable_description: Annotated[str | None, Field(title="Timetable
Description")] = None
- timetable_partitioned: Annotated[bool, Field(title="Timetable
Partitioned")]
- timetable_periodic: Annotated[bool, Field(title="Timetable Periodic")]
- tags: Annotated[list[DagTagResponse], Field(title="Tags")]
- max_active_tasks: Annotated[int, Field(title="Max Active Tasks")]
- max_active_runs: Annotated[int | None, Field(title="Max Active Runs")] =
None
- max_consecutive_failed_dag_runs: Annotated[int, Field(title="Max
Consecutive Failed Dag Runs")]
- has_task_concurrency_limits: Annotated[bool, Field(title="Has Task
Concurrency Limits")]
- has_import_errors: Annotated[bool, Field(title="Has Import Errors")]
- next_dagrun_logical_date: Annotated[datetime | None, Field(title="Next
Dagrun Logical Date")] = None
- next_dagrun_data_interval_start: Annotated[
- datetime | None, Field(title="Next Dagrun Data Interval Start")
- ] = None
- next_dagrun_data_interval_end: Annotated[
- datetime | None, Field(title="Next Dagrun Data Interval End")
- ] = None
- next_dagrun_run_after: Annotated[datetime | None, Field(title="Next Dagrun
Run After")] = None
- allowed_run_types: Annotated[list[DagRunType] | None, Field(title="Allowed
Run Types")] = None
- owners: Annotated[list[str], Field(title="Owners")]
- catchup: Annotated[bool, Field(title="Catchup")]
- dag_run_timeout: Annotated[timedelta | None, Field(title="Dag Run
Timeout")] = None
- asset_expression: Annotated[dict[str, Any] | None, Field(title="Asset
Expression")] = None
- doc_md: Annotated[str | None, Field(title="Doc Md")] = None
- start_date: Annotated[datetime | None, Field(title="Start Date")] = None
- end_date: Annotated[datetime | None, Field(title="End Date")] = None
- is_paused_upon_creation: Annotated[bool | None, Field(title="Is Paused
Upon Creation")] = None
- params: Annotated[dict[str, Any] | None, Field(title="Params")] = None
- render_template_as_native_obj: Annotated[bool, Field(title="Render
Template As Native Obj")]
- template_search_path: Annotated[list[str] | None, Field(title="Template
Search Path")] = None
- timezone: Annotated[str | None, Field(title="Timezone")] = None
- last_parsed: Annotated[datetime | None, Field(title="Last Parsed")] = None
- default_args: Annotated[dict[str, Any] | None, Field(title="Default
Args")] = None
- rerun_with_latest_version: Annotated[bool | None, Field(title="Rerun With
Latest Version")] = None
- owner_links: Annotated[dict[str, str] | None, Field(title="Owner Links")]
= None
- is_favorite: Annotated[bool | None, Field(title="Is Favorite")] = False
- active_runs_count: Annotated[int | None, Field(title="Active Runs Count")]
= 0
- is_backfillable: Annotated[
- bool, Field(description="Whether this Dag's schedule supports
backfilling.", title="Is Backfillable")
- ]
- file_token: Annotated[str, Field(description="Return file token.",
title="File Token")]
- concurrency: Annotated[
- int,
- Field(
- description="Return max_active_tasks as
concurrency.\n\nDeprecated: Use max_active_tasks instead.",
- title="Concurrency",
- ),
- ]
- latest_dag_version: Annotated[
- DagVersionResponse | None, Field(description="Return the latest
DagVersion.")
- ] = None
-
-
class DAGResponse(BaseModel):
"""
Dag serializer for responses.
@@ -2527,3 +2506,118 @@ class BulkBodyBulkTaskInstanceBody(BaseModel):
],
Field(title="Actions"),
]
+
+
+class AssetExpressionAll(BaseModel):
+ """
+ An "and" node: ``{"all": [...]}`` -- satisfied when all children are
satisfied.
+ """
+
+ all: Annotated[
+ list[
+ AssetExpressionAsset
+ | AssetExpressionAlias
+ | AssetExpressionRef
+ | AssetExpressionAny
+ | AssetExpressionAll
+ ],
+ Field(title="All"),
+ ]
+
+
+class AssetExpressionAny(BaseModel):
+ """
+ An "or" node: ``{"any": [...]}`` -- satisfied when any child is satisfied.
+ """
+
+ any: Annotated[
+ list[
+ AssetExpressionAsset
+ | AssetExpressionAlias
+ | AssetExpressionRef
+ | AssetExpressionAny
+ | AssetExpressionAll
+ ],
+ Field(title="Any"),
+ ]
+
+
+class DAGDetailsResponse(BaseModel):
+ """
+ Specific serializer for Dag Details responses.
+ """
+
+ dag_id: Annotated[str, Field(title="Dag Id")]
+ dag_display_name: Annotated[str, Field(title="Dag Display Name")]
+ is_paused: Annotated[bool, Field(title="Is Paused")]
+ is_stale: Annotated[bool, Field(title="Is Stale")]
+ last_parsed_time: Annotated[datetime | None, Field(title="Last Parsed
Time")] = None
+ last_parse_duration: Annotated[float | None, Field(title="Last Parse
Duration")] = None
+ last_expired: Annotated[datetime | None, Field(title="Last Expired")] =
None
+ bundle_name: Annotated[str | None, Field(title="Bundle Name")] = None
+ bundle_version: Annotated[str | None, Field(title="Bundle Version")] = None
+ relative_fileloc: Annotated[str | None, Field(title="Relative Fileloc")] =
None
+ fileloc: Annotated[str, Field(title="Fileloc")]
+ description: Annotated[str | None, Field(title="Description")] = None
+ timetable_summary: Annotated[str | None, Field(title="Timetable Summary")]
= None
+ timetable_description: Annotated[str | None, Field(title="Timetable
Description")] = None
+ timetable_partitioned: Annotated[bool, Field(title="Timetable
Partitioned")]
+ timetable_periodic: Annotated[bool, Field(title="Timetable Periodic")]
+ tags: Annotated[list[DagTagResponse], Field(title="Tags")]
+ max_active_tasks: Annotated[int, Field(title="Max Active Tasks")]
+ max_active_runs: Annotated[int | None, Field(title="Max Active Runs")] =
None
+ max_consecutive_failed_dag_runs: Annotated[int, Field(title="Max
Consecutive Failed Dag Runs")]
+ has_task_concurrency_limits: Annotated[bool, Field(title="Has Task
Concurrency Limits")]
+ has_import_errors: Annotated[bool, Field(title="Has Import Errors")]
+ next_dagrun_logical_date: Annotated[datetime | None, Field(title="Next
Dagrun Logical Date")] = None
+ next_dagrun_data_interval_start: Annotated[
+ datetime | None, Field(title="Next Dagrun Data Interval Start")
+ ] = None
+ next_dagrun_data_interval_end: Annotated[
+ datetime | None, Field(title="Next Dagrun Data Interval End")
+ ] = None
+ next_dagrun_run_after: Annotated[datetime | None, Field(title="Next Dagrun
Run After")] = None
+ allowed_run_types: Annotated[list[DagRunType] | None, Field(title="Allowed
Run Types")] = None
+ owners: Annotated[list[str], Field(title="Owners")]
+ catchup: Annotated[bool, Field(title="Catchup")]
+ dag_run_timeout: Annotated[timedelta | None, Field(title="Dag Run
Timeout")] = None
+ asset_expression: Annotated[
+ AssetExpressionAsset
+ | AssetExpressionAlias
+ | AssetExpressionRef
+ | AssetExpressionAny
+ | AssetExpressionAll
+ | None,
+ Field(title="Asset Expression"),
+ ] = None
+ doc_md: Annotated[str | None, Field(title="Doc Md")] = None
+ start_date: Annotated[datetime | None, Field(title="Start Date")] = None
+ end_date: Annotated[datetime | None, Field(title="End Date")] = None
+ is_paused_upon_creation: Annotated[bool | None, Field(title="Is Paused
Upon Creation")] = None
+ params: Annotated[dict[str, Any] | None, Field(title="Params")] = None
+ render_template_as_native_obj: Annotated[bool, Field(title="Render
Template As Native Obj")]
+ template_search_path: Annotated[list[str] | None, Field(title="Template
Search Path")] = None
+ timezone: Annotated[str | None, Field(title="Timezone")] = None
+ last_parsed: Annotated[datetime | None, Field(title="Last Parsed")] = None
+ default_args: Annotated[dict[str, Any] | None, Field(title="Default
Args")] = None
+ rerun_with_latest_version: Annotated[bool | None, Field(title="Rerun With
Latest Version")] = None
+ owner_links: Annotated[dict[str, str] | None, Field(title="Owner Links")]
= None
+ is_favorite: Annotated[bool | None, Field(title="Is Favorite")] = False
+ active_runs_count: Annotated[int | None, Field(title="Active Runs Count")]
= 0
+ is_backfillable: Annotated[
+ bool, Field(description="Whether this Dag's schedule supports
backfilling.", title="Is Backfillable")
+ ]
+ file_token: Annotated[str, Field(description="Return file token.",
title="File Token")]
+ concurrency: Annotated[
+ int,
+ Field(
+ description="Return max_active_tasks as
concurrency.\n\nDeprecated: Use max_active_tasks instead.",
+ title="Concurrency",
+ ),
+ ]
+ latest_dag_version: Annotated[
+ DagVersionResponse | None, Field(description="Return the latest
DagVersion.")
+ ] = None
+
+
+AssetExpressionAll.model_rebuild()