This is an automated email from the ASF dual-hosted git repository.
jason810496 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 e6a9de5d832 Go SDK: bring the coordinator-protocol models back to the
supervisor schema (#73963)
e6a9de5d832 is described below
commit e6a9de5d8326f0314618fe80972537739c5d4a69
Author: Henry Chen <[email protected]>
AuthorDate: Thu Oct 1 19:40:34 2026 +0800
Go SDK: bring the coordinator-protocol models back to the supervisor schema
(#73963)
* Go SDK: bring the coordinator-protocol models back to the supervisor
schema
The models are generated from the supervisor wire-schema snapshot the Python
Task SDK owns, and they had fallen two fields behind it: a snapshot can
grow a
property without the api_version moving, and the version constant's test is
the
only thing that was watching. Nothing else regenerated or verified the
committed
file, so the drift survived two releases in silence — msgpack drops a field
the
struct does not declare, so the supervisor sent multi_team and retry_reason
and
the Go SDK could not see them.
The Dag specs had the same exposure and got a check for it. Making that
check
cover both generated surfaces is what keeps this from happening again, and
is
cheaper than watching one of them and hoping about the other.
closes: #73954
* Go SDK: name the generated model files the drift check watches
The models target pointed `git diff` at the whole genmodels package, but
that
package also holds hand-written code: gen.go carries the //go:generate
directives, gen/ the custom generator, plus two test files. The hook's own
files: regex fires on edits to exactly those, so editing a generator made
the
check report the author's own uncommitted gen.go edit as generator drift,
and
told them to `git add` the package directory, staging unrelated work with
it.
A directory also defeats the missing-file guard, which the specs target gets
for free from naming a file: a directory outlives every generated file in
it,
so a moved or deleted models.gen.go passed silently — the drift the guard
exists to catch.
Naming the files also lets a report say which one drifted rather than which
package, which is the one thing it has to get across. Three of them do not
fit
the singular wording the reports had, and a comma-joined list is not a
runnable
`git add`, so both follow from the same change.
---
.pre-commit-config.yaml | 12 +-
go-sdk/README.md | 7 +-
go-sdk/pkg/execution/genmodels/models.gen.go | 143 ++++++++--------
scripts/ci/prek/check_go_sdk_generated_drift.py | 187 +++++++++++++++++++++
scripts/ci/prek/check_go_sdk_spec_drift.py | 140 ---------------
...ift.py => test_check_go_sdk_generated_drift.py} | 83 ++++++---
6 files changed, 334 insertions(+), 238 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9a38259abcf..1cfb2b430ea 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -348,21 +348,23 @@ repos:
# prek run sync-java-sdk-dag-schema --hook-stage manual
stages: ['manual']
files: ^java-sdk/sdk/schema/dag-schema\.json$
- - id: check-go-sdk-spec-drift
- name: Check Go SDK Dag spec structs against the serialization schema
- description: "Regenerate go-sdk/airflow/spec.gen.go and fail when the
committed file differs"
- entry: ./scripts/ci/prek/check_go_sdk_spec_drift.py
+ - id: check-go-sdk-generated-drift
+ name: Check Go SDK generated files against the schemas they generate
from
+ description: "Regenerate the Dag specs and the coordinator-protocol
models, and fail when a committed file differs"
+ entry: ./scripts/ci/prek/check_go_sdk_generated_drift.py
# golang so prek provisions the toolchain the generators need, the way
the
# other checks that shell out to `go` in go-sdk get theirs.
language: golang
files: >
(?x)
^airflow-core/src/airflow/serialization/schema\.json$|
+ ^task-sdk/src/airflow/sdk/execution_time/schema/schema\.json$|
^go-sdk/airflow/spec\.gen\.go$|
^go-sdk/airflow/spec\.go$|
^go-sdk/internal/genspec/.*\.go$|
^go-sdk/internal/genlicense/.*\.go$|
- ^scripts/ci/prek/check_go_sdk_spec_drift\.py$
+ ^go-sdk/pkg/execution/genmodels/.*\.go$|
+ ^scripts/ci/prek/check_go_sdk_generated_drift\.py$
pass_filenames: false
require_serial: true
# check-ts-sdk-serialization-conformance in ts-sdk/ covers the SDK's own
files; this runs the same
diff --git a/go-sdk/README.md b/go-sdk/README.md
index c4b32712b26..a6dedde0804 100644
--- a/go-sdk/README.md
+++ b/go-sdk/README.md
@@ -391,7 +391,10 @@ SDK to a newer schema version:
2. Run `just generate-models`.
`TestSupervisorSchemaVersionMatchesSnapshot` fails when the constant and the
snapshot's `api_version`
-drift, so a missed bump is caught by `go test` instead of needing a dedicated
prek hook.
+drift, so a missed bump is caught by `go test`. A snapshot can also grow a
field without the
+`api_version` moving, which leaves the models behind with nothing failing —
msgpack drops a field the
+struct does not declare — so the `check-go-sdk-generated-drift` prek hook
regenerates them and fails
+when the committed files differ.
## Regenerating the Dag and task specs
@@ -417,7 +420,7 @@ it into the authoring shape, each entry carrying the reason
it exists:
are hand-written in [`airflow/spec.go`](./airflow/spec.go);
`TestTriggerRuleConstantsMatchPython` is their tripwire against Airflow's own
enum.
-The `check-go-sdk-spec-drift` prek hook regenerates the file and fails when
the committed one
+The `check-go-sdk-generated-drift` prek hook regenerates the file and fails
when the committed one
differs, so a schema change that never reached Go cannot merge.
## Architectural decisions
diff --git a/go-sdk/pkg/execution/genmodels/models.gen.go
b/go-sdk/pkg/execution/genmodels/models.gen.go
index 698f6c097f3..22741803d83 100644
--- a/go-sdk/pkg/execution/genmodels/models.gen.go
+++ b/go-sdk/pkg/execution/genmodels/models.gen.go
@@ -1624,6 +1624,9 @@ type TIRunContext struct {
// MaxTries corresponds to the JSON schema field "max_tries".
MaxTries int `msgpack:"max_tries"`
+ // MultiTeam corresponds to the JSON schema field "multi_team".
+ MultiTeam bool `msgpack:"multi_team,omitempty"`
+
// NextKwargs corresponds to the JSON schema field "next_kwargs".
NextKwargs *NextKwargs `msgpack:"next_kwargs,omitempty"`
@@ -1649,18 +1652,50 @@ type TIRunContext struct {
type TaskArgBinding interface{}
-type TriggerKwargs map[string]interface{}
-
-// Variable schema for responses with fields that are needed for Runtime.
-type VariableResponse struct {
- // Key corresponds to the JSON schema field "key".
- Key string `msgpack:"key"`
+type TaskBreadcrumbsResult struct {
+ // Breadcrumbs corresponds to the JSON schema field "breadcrumbs".
+ Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem
`msgpack:"breadcrumbs"`
- // Value corresponds to the JSON schema field "value".
- Value interface{} `msgpack:"value"`
+ // Type corresponds to the JSON schema field "type".
+ Type string `msgpack:"type,omitempty"`
}
-type Warnings []interface{}
+type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{}
+
+// Task callback status information.
+//
+// A Class with information about the success/failure TI callback to be
executed.
+// Currently, only failure
+// callbacks when tasks are externally killed or experience heartbeat timeouts
are
+// run via DagFileProcessorProcess.
+type TaskCallbackRequest struct {
+ // BundleName corresponds to the JSON schema field "bundle_name".
+ BundleName string `msgpack:"bundle_name"`
+
+ // BundleVersion corresponds to the JSON schema field "bundle_version".
+ BundleVersion interface{} `msgpack:"bundle_version"`
+
+ // ContextFromServer corresponds to the JSON schema field
"context_from_server".
+ ContextFromServer *TIRunContext
`msgpack:"context_from_server,omitempty"`
+
+ // Filepath corresponds to the JSON schema field "filepath".
+ Filepath string `msgpack:"filepath"`
+
+ // Msg corresponds to the JSON schema field "msg".
+ Msg interface{} `msgpack:"msg,omitempty"`
+
+ // TaskCallbackType corresponds to the JSON schema field
"task_callback_type".
+ TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"`
+
+ // TI corresponds to the JSON schema field "ti".
+ TI TaskInstance `msgpack:"ti"`
+
+ // Type corresponds to the JSON schema field "type".
+ Type string `msgpack:"type,omitempty"`
+
+ // VersionData corresponds to the JSON schema field "version_data".
+ VersionData *VersionData `msgpack:"version_data,omitempty"`
+}
type TaskIds []string
@@ -1697,70 +1732,24 @@ type TaskInstance struct {
TryNumber int `msgpack:"try_number"`
}
+type TaskInstanceState string
+
const TaskInstanceStateAwaitingInput TaskInstanceState = "awaiting_input"
const TaskInstanceStateDeferred TaskInstanceState = "deferred"
const TaskInstanceStateFailed TaskInstanceState = "failed"
-const TaskInstanceStateSkipped TaskInstanceState = "skipped"
-const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule"
+const TaskInstanceStateQueued TaskInstanceState = "queued"
+const TaskInstanceStateRemoved TaskInstanceState = "removed"
const TaskInstanceStateRestarting TaskInstanceState = "restarting"
-const TaskInstanceStateSuccess TaskInstanceState = "success"
const TaskInstanceStateRunning TaskInstanceState = "running"
-const TaskInstanceStateQueued TaskInstanceState = "queued"
const TaskInstanceStateScheduled TaskInstanceState = "scheduled"
+const TaskInstanceStateSkipped TaskInstanceState = "skipped"
+const TaskInstanceStateSuccess TaskInstanceState = "success"
+const TaskInstanceStateUpForReschedule TaskInstanceState = "up_for_reschedule"
const TaskInstanceStateUpForRetry TaskInstanceState = "up_for_retry"
const TaskInstanceStateUpstreamFailed TaskInstanceState = "upstream_failed"
type TaskOutlets []AssetProfile
-const TaskInstanceStateRemoved TaskInstanceState = "removed"
-
-type TaskInstanceState string
-
-type TaskBreadcrumbsResultBreadcrumbsElem map[string]interface{}
-
-type TaskBreadcrumbsResult struct {
- // Breadcrumbs corresponds to the JSON schema field "breadcrumbs".
- Breadcrumbs []TaskBreadcrumbsResultBreadcrumbsElem
`msgpack:"breadcrumbs"`
-
- // Type corresponds to the JSON schema field "type".
- Type string `msgpack:"type,omitempty"`
-}
-
-// Task callback status information.
-//
-// A Class with information about the success/failure TI callback to be
executed.
-// Currently, only failure
-// callbacks when tasks are externally killed or experience heartbeat timeouts
are
-// run via DagFileProcessorProcess.
-type TaskCallbackRequest struct {
- // BundleName corresponds to the JSON schema field "bundle_name".
- BundleName string `msgpack:"bundle_name"`
-
- // BundleVersion corresponds to the JSON schema field "bundle_version".
- BundleVersion interface{} `msgpack:"bundle_version"`
-
- // ContextFromServer corresponds to the JSON schema field
"context_from_server".
- ContextFromServer *TIRunContext
`msgpack:"context_from_server,omitempty"`
-
- // Filepath corresponds to the JSON schema field "filepath".
- Filepath string `msgpack:"filepath"`
-
- // Msg corresponds to the JSON schema field "msg".
- Msg interface{} `msgpack:"msg,omitempty"`
-
- // TaskCallbackType corresponds to the JSON schema field
"task_callback_type".
- TaskCallbackType interface{} `msgpack:"task_callback_type,omitempty"`
-
- // TI corresponds to the JSON schema field "ti".
- TI TaskInstance `msgpack:"ti"`
-
- // Type corresponds to the JSON schema field "type".
- Type string `msgpack:"type,omitempty"`
-
- // VersionData corresponds to the JSON schema field "version_data".
- VersionData *VersionData `msgpack:"version_data,omitempty"`
-}
-
// Response containing the first reschedule date for a task instance.
type TaskRescheduleStartDate struct {
// StartDate corresponds to the JSON schema field "start_date".
@@ -1770,12 +1759,6 @@ type TaskRescheduleStartDate struct {
Type string `msgpack:"type,omitempty"`
}
-type TaskStateState string
-
-const TaskStateStateFailed TaskStateState = "failed"
-const TaskStateStateSkipped TaskStateState = "skipped"
-const TaskStateStateRemoved TaskStateState = "removed"
-
// Update a task's state.
//
// If a process exits without sending one of these the state will be derived
from
@@ -1789,6 +1772,9 @@ type TaskState struct {
// RenderedMapIndex corresponds to the JSON schema field
"rendered_map_index".
RenderedMapIndex interface{} `msgpack:"rendered_map_index,omitempty"`
+ // RetryReason corresponds to the JSON schema field "retry_reason".
+ RetryReason interface{} `msgpack:"retry_reason,omitempty"`
+
// State corresponds to the JSON schema field "state".
State TaskStateState `msgpack:"state"`
@@ -1796,6 +1782,12 @@ type TaskState struct {
Type string `msgpack:"type,omitempty"`
}
+type TaskStateState string
+
+const TaskStateStateFailed TaskStateState = "failed"
+const TaskStateStateRemoved TaskStateState = "removed"
+const TaskStateStateSkipped TaskStateState = "skipped"
+
// Response to GetTaskStateStore; wraps the generated API response for
supervisor
// to worker comms.
type TaskStateStoreResult struct {
@@ -1845,7 +1837,7 @@ type TriggerDagRun struct {
Type string `msgpack:"type,omitempty"`
}
-type VersionData map[string]interface{}
+type TriggerKwargs map[string]interface{}
// Update the response content part of an existing Human-in-the-loop response.
type UpdateHITLDetail struct {
@@ -1881,6 +1873,15 @@ type VariableKeysResult struct {
Type string `msgpack:"type,omitempty"`
}
+// Variable schema for responses with fields that are needed for Runtime.
+type VariableResponse struct {
+ // Key corresponds to the JSON schema field "key".
+ Key string `msgpack:"key"`
+
+ // Value corresponds to the JSON schema field "value".
+ Value interface{} `msgpack:"value"`
+}
+
type VariableResult struct {
// Key corresponds to the JSON schema field "key".
Key string `msgpack:"key"`
@@ -1892,6 +1893,10 @@ type VariableResult struct {
Value interface{} `msgpack:"value"`
}
+type VersionData map[string]interface{}
+
+type Warnings []interface{}
+
// One positional stub-task argument pulled from an upstream task's XCom.
type XComArgBinding struct {
// Kind corresponds to the JSON schema field "kind".
diff --git a/scripts/ci/prek/check_go_sdk_generated_drift.py
b/scripts/ci/prek/check_go_sdk_generated_drift.py
new file mode 100755
index 00000000000..f66dcae71b9
--- /dev/null
+++ b/scripts/ci/prek/check_go_sdk_generated_drift.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+# 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.
+"""
+Keep the Go SDK's generated files in step with the schemas they generate from.
+
+Two of the Go SDK's surfaces are generated from schemas Python owns, and both
are
+committed, so nothing regenerates them when the schema on the Python side
moves:
+
+* ``go-sdk/airflow/spec.gen.go`` — ``airflow.DagSpec`` and
``airflow.TaskSpec``, the
+ structs a Dag author fills in, from ``airflow-core``'s Dag serialization
schema.
+* ``go-sdk/pkg/execution/genmodels/*.gen.go`` — the coordinator-protocol
messages,
+ from the supervisor wire-schema snapshot the Python Task SDK owns.
+
+Without this check a property added, renamed or retyped on the Python side
leaves the
+Go side silently behind: a Dag authored in Go keeps serializing the old shape,
and
+msgpack drops a message field the Go struct does not declare. That is not
theoretical
+— ``models.gen.go`` sat two fields behind its snapshot across two releases,
which is
+what #73954 is about.
+
+The check regenerates each target and asks Git whether it changed. A drifted
file is
+left regenerated in the working tree, so the fix is to commit it.
+
+Run from the repo root:
+
+ uv run --project scripts python
scripts/ci/prek/check_go_sdk_generated_drift.py
+
+Exits 0 if every committed file matches its schema, 1 otherwise.
+"""
+
+from __future__ import annotations
+
+import os
+import pathlib
+import shutil
+import subprocess
+import sys
+from typing import NamedTuple
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
+GO_SDK_MODULE = pathlib.Path("go-sdk")
+
+
+class Target(NamedTuple):
+ """One `go generate` target and the committed files it writes."""
+
+ package: str
+ committed: tuple[pathlib.Path, ...]
+ schema: str
+ # remedy is what to weigh before committing the regenerated file, or ""
when there
+ # is nothing to decide and the file just has to be brought forward.
+ remedy: str
+
+
+TARGETS = (
+ Target(
+ package="./airflow/...",
+ committed=(GO_SDK_MODULE / "airflow" / "spec.gen.go",),
+ schema="airflow-core/src/airflow/serialization/schema.json",
+ remedy=(
+ "Review it — a property that should not reach an author belongs in
the "
+ "exclusion list in go-sdk/internal/genspec/authoring.go — then
commit it:"
+ ),
+ ),
+ Target(
+ package="./pkg/execution/genmodels/...",
+ committed=(
+ GO_SDK_MODULE / "pkg" / "execution" / "genmodels" /
"models.gen.go",
+ GO_SDK_MODULE / "pkg" / "execution" / "genmodels" /
"discriminators.gen.go",
+ GO_SDK_MODULE / "pkg" / "execution" / "genmodels" /
"defaults.gen.go",
+ ),
+ schema="task-sdk/src/airflow/sdk/execution_time/schema/schema.json",
+ remedy="Commit it:",
+ ),
+)
+
+
+def regenerate(module_dir: pathlib.Path, package: str, go_binary: str = "go")
-> tuple[int, str]:
+ """Run one target's generators. Returns ``(returncode,
combined_output)``."""
+ completed = subprocess.run(
+ [go_binary, "generate", package],
+ cwd=module_dir,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ return completed.returncode, (completed.stdout + completed.stderr).strip()
+
+
+def read_drift(
+ repo_root: pathlib.Path, paths: tuple[pathlib.Path, ...], git_binary: str
= "git"
+) -> tuple[int, str]:
+ """Ask Git what regeneration changed. Returns ``(returncode, diff)``."""
+ completed = subprocess.run(
+ [git_binary, "diff", "--", *(str(path) for path in paths)],
+ cwd=repo_root,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ return completed.returncode, completed.stdout.strip()
+
+
+def format_report(
+ target: Target, generate_returncode: int, generate_output: str,
diff_returncode: int, diff: str
+) -> tuple[int, str]:
+ """Turn one target's regeneration result and the diff that followed into
``(exit_code, report)``."""
+ written = ", ".join(str(path) for path in target.committed)
+ stageable = " ".join(str(path) for path in target.committed)
+ if generate_returncode != 0:
+ return 1, "\n".join(
+ [
+ f"ERROR: regenerating {written} failed.",
+ "",
+ "A generator fails on a schema construct it has no rule for,
which is how a",
+ "change on the Python side that needs a new rule surfaces; the
generators also",
+ "need the Go toolchain and the network to fetch
go-jsonschema.",
+ "`go generate` reported:",
+ "",
+ generate_output or "(no output)",
+ ]
+ )
+ # An unreadable diff is not an absent one: reporting success here would
let the
+ # check pass on every drift.
+ if diff_returncode != 0:
+ return 1, f"ERROR: `git diff` failed, so whether {written} drifted is
unknown."
+ if not diff:
+ return 0, f"OK: {written} — up to date with {target.schema}."
+ return 1, "\n".join(
+ [
+ f"ERROR: out of date: {written}.",
+ "",
+ f"The committed output no longer matches what {target.schema} and
the",
+ "generators produce. The regenerated output is in your working
tree.",
+ "",
+ target.remedy,
+ "",
+ f" git add {stageable}",
+ "",
+ "Regeneration changed:",
+ "",
+ diff,
+ ]
+ )
+
+
+def main() -> int:
+ module_dir = REPO_ROOT / GO_SDK_MODULE
+ for target in TARGETS:
+ for path in target.committed:
+ if not (REPO_ROOT / path).exists():
+ print(f"ERROR: {path} not found — has the generated file
moved?")
+ return 1
+ if shutil.which("go") is None:
+ if os.environ.get("CI"):
+ print("ERROR: `go` is not on PATH but this is a CI run — the
toolchain is required here.")
+ return 1
+ print("SKIPPED: `go` is not on PATH, cannot verify that the generated
files are current.")
+ return 0
+ exit_code = 0
+ for target in TARGETS:
+ generate_returncode, generate_output = regenerate(module_dir,
target.package)
+ diff_returncode, diff = read_drift(REPO_ROOT, target.committed)
+ target_exit_code, report = format_report(
+ target, generate_returncode, generate_output, diff_returncode, diff
+ )
+ print(report)
+ exit_code = exit_code or target_exit_code
+ return exit_code
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/ci/prek/check_go_sdk_spec_drift.py
b/scripts/ci/prek/check_go_sdk_spec_drift.py
deleted file mode 100755
index 9e5dfd0a310..00000000000
--- a/scripts/ci/prek/check_go_sdk_spec_drift.py
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-# 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.
-"""
-Keep the Go SDK's Dag and task spec structs in step with the serialization
schema.
-
-``go-sdk/airflow/spec.gen.go`` declares ``airflow.DagSpec`` and
-``airflow.TaskSpec``, the two structs a Dag author fills in. Both are generated
-from ``airflow-core/src/airflow/serialization/schema.json``, which Python owns:
-``go-sdk/internal/genspec`` rewrites that schema into the authoring shape and
-go-jsonschema writes the structs from it.
-
-The generated file is committed, so nothing regenerates it when the schema
moves
-on the Python side. Without this check a property added, renamed or retyped
there
-would leave the Go structs silently behind, and a Dag authored in Go would keep
-serializing the old shape.
-
-The check regenerates the file and asks Git whether it changed. A drifted file
is
-left regenerated in the working tree, so the fix is to commit it.
-
-Run from the repo root:
-
- uv run --project scripts python scripts/ci/prek/check_go_sdk_spec_drift.py
-
-Exits 0 if the committed structs match the schema, 1 otherwise.
-"""
-
-from __future__ import annotations
-
-import os
-import pathlib
-import shutil
-import subprocess
-import sys
-
-REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
-GO_SDK_MODULE = pathlib.Path("go-sdk")
-GENERATED_SPECS = GO_SDK_MODULE / "airflow" / "spec.gen.go"
-
-
-def regenerate_specs(module_dir: pathlib.Path, go_binary: str = "go") ->
tuple[int, str]:
- """Run the spec generators. Returns ``(returncode, combined_output)``."""
- completed = subprocess.run(
- [go_binary, "generate", "./airflow/..."],
- cwd=module_dir,
- capture_output=True,
- text=True,
- check=False,
- )
- return completed.returncode, (completed.stdout + completed.stderr).strip()
-
-
-def read_drift(repo_root: pathlib.Path, git_binary: str = "git") -> tuple[int,
str]:
- """Ask Git what regeneration changed. Returns ``(returncode, diff)``."""
- completed = subprocess.run(
- [git_binary, "diff", "--", str(GENERATED_SPECS)],
- cwd=repo_root,
- capture_output=True,
- text=True,
- check=False,
- )
- return completed.returncode, completed.stdout.strip()
-
-
-def format_report(
- generate_returncode: int, generate_output: str, diff_returncode: int,
diff: str
-) -> tuple[int, str]:
- """Turn a regeneration result and the diff that followed it into
``(exit_code, report)``."""
- if generate_returncode != 0:
- return 1, "\n".join(
- [
- f"ERROR: regenerating {GENERATED_SPECS} failed.",
- "",
- "genspec fails on a schema construct it has no rule for, which
is how a change on",
- "the Python side that needs a new rule surfaces; the
generators also need the Go",
- "toolchain and the network to fetch go-jsonschema. `go
generate` reported:",
- "",
- generate_output or "(no output)",
- ]
- )
- # An unreadable diff is not an absent one: reporting success here would
let the
- # check pass on every drift.
- if diff_returncode != 0:
- return 1, f"ERROR: `git diff` failed, so whether {GENERATED_SPECS}
drifted is unknown."
- if not diff:
- return 0, f"OK: {GENERATED_SPECS} matches the serialization schema."
- return 1, "\n".join(
- [
- f"ERROR: {GENERATED_SPECS} is out of date.",
- "",
- "It is generated from
airflow-core/src/airflow/serialization/schema.json by",
- "go-sdk/internal/genspec, and one of the two has moved since the
file was committed.",
- "The regenerated file is in your working tree.",
- "",
- "Review it — a property that should not reach an author belongs in
the exclusion",
- "list in go-sdk/internal/genspec/authoring.go — then commit it:",
- "",
- f" git add {GENERATED_SPECS}",
- "",
- "Regeneration changed:",
- "",
- diff,
- ]
- )
-
-
-def main() -> int:
- module_dir = REPO_ROOT / GO_SDK_MODULE
- if not (REPO_ROOT / GENERATED_SPECS).is_file():
- print(f"ERROR: {GENERATED_SPECS} not found — has the generated file
moved?")
- return 1
- if shutil.which("go") is None:
- if os.environ.get("CI"):
- print("ERROR: `go` is not on PATH but this is a CI run — the
toolchain is required here.")
- return 1
- print(f"SKIPPED: `go` is not on PATH, cannot verify that
{GENERATED_SPECS} is current.")
- return 0
- generate_returncode, generate_output = regenerate_specs(module_dir)
- diff_returncode, diff = read_drift(REPO_ROOT)
- exit_code, report = format_report(generate_returncode, generate_output,
diff_returncode, diff)
- print(report)
- return exit_code
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_check_go_sdk_spec_drift.py
b/scripts/tests/ci/prek/test_check_go_sdk_generated_drift.py
similarity index 52%
rename from scripts/tests/ci/prek/test_check_go_sdk_spec_drift.py
rename to scripts/tests/ci/prek/test_check_go_sdk_generated_drift.py
index fffbd80c399..de4efb55788 100644
--- a/scripts/tests/ci/prek/test_check_go_sdk_spec_drift.py
+++ b/scripts/tests/ci/prek/test_check_go_sdk_generated_drift.py
@@ -17,41 +17,80 @@
# under the License.
from __future__ import annotations
+import pathlib
import subprocess
from unittest import mock
-import check_go_sdk_spec_drift as checker
+import check_go_sdk_generated_drift as checker
import pytest
+SPECS, MODELS = checker.TARGETS
+
SPEC_DRIFT_DIFF = """\
diff --git a/go-sdk/airflow/spec.gen.go b/go-sdk/airflow/spec.gen.go
--- a/go-sdk/airflow/spec.gen.go
+++ b/go-sdk/airflow/spec.gen.go
@@ -40,6 +40,9 @@ type DagSpec struct {
+ // Deadline corresponds to the JSON schema field "deadline".
-+ Deadline string `json:"deadline,omitempty,omitzero"`
++ Deadline string
+"""
+
+MODELS_DRIFT_DIFF = """\
+diff --git a/go-sdk/pkg/execution/genmodels/models.gen.go
b/go-sdk/pkg/execution/genmodels/models.gen.go
+--- a/go-sdk/pkg/execution/genmodels/models.gen.go
++++ b/go-sdk/pkg/execution/genmodels/models.gen.go
+@@ -1624,6 +1624,9 @@ type TIRunContext struct {
++ MultiTeam bool `msgpack:"multi_team,omitempty"`
"""
-def test_current_specs_pass():
- exit_code, report = checker.format_report(0, "", 0, "")
+def
test_both_targets_are_checked_and_name_generated_files_not_a_package_directory():
+ assert [target.package for target in checker.TARGETS] == [
+ "./airflow/...",
+ "./pkg/execution/genmodels/...",
+ ]
+ # A package directory would widen `git diff` onto the hand-written gen.go
beside the
+ # generated files, and hide a deleted one from the missing-file guard in
main().
+ for target in checker.TARGETS:
+ assert target.committed
+ assert all(path.name.endswith(".gen.go") for path in
target.committed), target.committed
+
+
+def test_current_files_pass():
+ exit_code, report = checker.format_report(SPECS, 0, "", 0, "")
assert exit_code == 0
- assert "matches the serialization schema" in report
+ assert "up to date with
airflow-core/src/airflow/serialization/schema.json" in report
def
test_drifted_specs_fail_with_the_diff_and_where_to_decide_about_a_property():
- exit_code, report = checker.format_report(0, "", 0, SPEC_DRIFT_DIFF)
+ exit_code, report = checker.format_report(SPECS, 0, "", 0, SPEC_DRIFT_DIFF)
assert exit_code == 1
- assert "is out of date" in report
+ assert "out of date" in report
assert "go-sdk/internal/genspec/authoring.go" in report
assert "git add go-sdk/airflow/spec.gen.go" in report
- assert 'Deadline string `json:"deadline,omitempty,omitzero"`' in report
+ assert "Deadline string" in report
+
+
+def
test_drifted_models_name_the_supervisor_snapshot_and_have_nothing_to_decide():
+ exit_code, report = checker.format_report(MODELS, 0, "", 0,
MODELS_DRIFT_DIFF)
+
+ assert exit_code == 1
+ assert "task-sdk/src/airflow/sdk/execution_time/schema/schema.json" in
report
+ assert (
+ "git add go-sdk/pkg/execution/genmodels/models.gen.go "
+ "go-sdk/pkg/execution/genmodels/discriminators.gen.go "
+ "go-sdk/pkg/execution/genmodels/defaults.gen.go" in report
+ )
+ # Nothing is excluded from the models, so there is no list to weigh a
field against.
+ assert "authoring.go" not in report
+ assert "MultiTeam" in report
def test_failed_generation_reports_the_generator_output_instead_of_a_diff():
exit_code, report = checker.format_report(
+ SPECS,
1,
"genspec: shaping schema.json for authoring:
definitions/dag/properties/fileloc is excluded",
0,
@@ -64,47 +103,47 @@ def
test_failed_generation_reports_the_generator_output_instead_of_a_diff():
def test_failed_generation_without_output_still_reports():
- exit_code, report = checker.format_report(1, "", 0, "")
+ exit_code, report = checker.format_report(SPECS, 1, "", 0, "")
assert exit_code == 1
assert "(no output)" in report
def test_unreadable_diff_fails_instead_of_passing_as_no_drift():
- exit_code, report = checker.format_report(0, "", 128, "")
+ exit_code, report = checker.format_report(SPECS, 0, "", 128, "")
assert exit_code == 1
assert "is unknown" in report
[email protected]("check_go_sdk_spec_drift.subprocess.run", autospec=True)
-def test_regeneration_runs_the_generators_in_the_go_sdk_module(mock_run,
tmp_path):
[email protected]("check_go_sdk_generated_drift.subprocess.run", autospec=True)
+def
test_regeneration_runs_one_targets_generators_in_the_go_sdk_module(mock_run,
tmp_path):
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0,
stdout="", stderr="")
- checker.regenerate_specs(tmp_path)
+ checker.regenerate(tmp_path, MODELS.package)
- assert mock_run.call_args.args[0] == ["go", "generate", "./airflow/..."]
+ assert mock_run.call_args.args[0] == ["go", "generate",
"./pkg/execution/genmodels/..."]
assert mock_run.call_args.kwargs["cwd"] == tmp_path
[email protected]("check_go_sdk_spec_drift.subprocess.run", autospec=True)
[email protected]("check_go_sdk_generated_drift.subprocess.run", autospec=True)
def test_regeneration_combines_stdout_and_stderr(mock_run, tmp_path):
mock_run.return_value = subprocess.CompletedProcess(
args=[], returncode=1, stdout="genspec: shaping failed\n",
stderr="exit status 1\n"
)
- returncode, output = checker.regenerate_specs(tmp_path)
+ returncode, output = checker.regenerate(tmp_path, SPECS.package)
assert returncode == 1
assert "genspec: shaping failed" in output
assert "exit status 1" in output
[email protected]("check_go_sdk_spec_drift.subprocess.run", autospec=True)
-def test_read_drift_asks_git_only_about_the_generated_file(mock_run, tmp_path):
[email protected]("check_go_sdk_generated_drift.subprocess.run", autospec=True)
+def test_read_drift_asks_git_only_about_one_targets_files(mock_run, tmp_path):
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0,
stdout="", stderr="")
- checker.read_drift(tmp_path)
+ checker.read_drift(tmp_path, (pathlib.Path("go-sdk/airflow/spec.gen.go"),))
assert mock_run.call_args.args[0] == ["git", "diff", "--",
"go-sdk/airflow/spec.gen.go"]
assert mock_run.call_args.kwargs["cwd"] == tmp_path
@@ -117,7 +156,7 @@ def
test_read_drift_asks_git_only_about_the_generated_file(mock_run, tmp_path):
pytest.param({}, 0, "SKIPPED", id="local-skips"),
],
)
[email protected]("check_go_sdk_spec_drift.shutil.which", autospec=True,
return_value=None)
[email protected]("check_go_sdk_generated_drift.shutil.which", autospec=True,
return_value=None)
def test_missing_go_toolchain(mock_which, ci_env, expected_exit,
expected_text, monkeypatch, capsys):
monkeypatch.delenv("CI", raising=False)
for key, value in ci_env.items():
@@ -127,7 +166,7 @@ def test_missing_go_toolchain(mock_which, ci_env,
expected_exit, expected_text,
assert expected_text in capsys.readouterr().out
[email protected]("check_go_sdk_spec_drift.pathlib.Path.is_file", autospec=True,
return_value=False)
-def test_missing_generated_file_fails(mock_is_file, capsys):
[email protected]("check_go_sdk_generated_drift.pathlib.Path.exists", autospec=True,
return_value=False)
+def test_missing_generated_file_fails(mock_exists, capsys):
assert checker.main() == 1
assert "not found" in capsys.readouterr().out