Skip to content

Commit 4a88dc7

Browse files
committed
Keep the Java SDK capability manifest internal to the build
The manifest model sat at default (public) visibility in the stable org.apache.airflow.sdk.conformance package, so a type that exists purely to emit a build-time JSON file shipped in the published artifact and in the Dokka API reference. That would make every later change to the manifest schema a source and binary compatibility question for SDK users, for no benefit — nothing outside the Gradle dump task consumes it. Marking it internal removes it from both surfaces, and the generically named Document becomes CapabilityManifest now that the name is no longer public. The rest hardens the surrounding tooling against ways it could mislead: the sync hook now re-triggers when its own implementation changes, so editing the checker cannot leave the manifest unverified; an unsupported dimension carrying a "since" version is rejected rather than silently rendering as unversioned; the manifest is decoded as the first complete JSON value instead of slicing to the last brace, which mis-parses if Gradle ever frames the output; and the JDK gate fails closed when CI is set but empty. The Dokka column-width rules now only raise a minimum width rather than forbidding wrapping, so they cannot cause overflow in unrelated tables that a Markdown-generated table gives no way to exclude.
1 parent f9cb4fd commit 4a88dc7

6 files changed

Lines changed: 77 additions & 24 deletions

File tree

‎.pre-commit-config.yaml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ repos:
292292
^java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/conformance/.*\.kt$|
293293
^java-sdk/sdk/build\.gradle\.kts$|
294294
^java-sdk/gradle\.properties$|
295-
^java-sdk/generated/lang-sdk/capabilities\.json$
295+
^java-sdk/generated/lang-sdk/capabilities\.json$|
296+
^scripts/ci/prek/check_java_sdk_capabilities_in_sync\.py$|
297+
^scripts/ci/prek/lang_sdk_compat_matrix\.py$
296298
pass_filenames: false
297299
require_serial: true
298300
- id: update-java-sdk-readme-matrix

‎java-sdk/sdk/dokka/matrix.css‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,26 @@
1717
* under the License.
1818
*/
1919

20-
/* Dokka sizes content-table cells with `min-width: 40px` and ~31px of horizontal padding, so the
20+
/* Dokka sizes content-table cells with `min-width: 40px` against ~31px of horizontal padding, so the
2121
narrow columns of the Language SDK compatibility matrix collapse until their headers and values
22-
break mid-word ("Supported", "MUST †"). Keeping every column except Dimension and Notes on a
23-
single line makes each one size to its own content instead. */
22+
break mid-word ("Supported", "MUST †").
23+
24+
These selectors necessarily apply to every content table in the API reference, because a table
25+
generated from Markdown carries no class of its own. They are therefore deliberately written to
26+
only *raise a floor*, never to forbid wrapping: a header stays on one line (headers are short in
27+
any table), and the narrow value columns get a minimum width instead of `nowrap`. An unrelated
28+
table with long prose in those columns still wraps as before rather than overflowing. */
2429

2530
.table--container th {
2631
white-space: nowrap;
2732
}
2833

34+
/* Tier ("SHOULD †"), Supported ("n/a") and Since ("3.3") in the compatibility matrix. */
2935
.table--container td:nth-child(2),
30-
.table--container td:nth-child(3),
36+
.table--container td:nth-child(3) {
37+
min-width: 9ch;
38+
}
39+
3140
.table--container td:nth-child(4) {
32-
white-space: nowrap;
41+
min-width: 6ch;
3342
}

‎java-sdk/sdk/src/main/kotlin/org/apache/airflow/sdk/conformance/Capabilities.kt‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,21 @@ import kotlinx.serialization.Serializable
2222
import kotlinx.serialization.encodeToString
2323
import kotlinx.serialization.json.Json
2424

25+
// This model exists only to emit the build-time manifest, so it is deliberately `internal`: it is
26+
// not part of the SDK's published surface, and keeping it out means changing the manifest schema
27+
// never becomes a source/binary compatibility concern for SDK users.
28+
2529
/** Support for a single Language SDK conformance dimension. */
2630
@Serializable
27-
data class CapabilityEntry(
31+
internal data class CapabilityEntry(
2832
val supported: Boolean,
2933
val since: String? = null,
3034
val note: String = "",
3135
)
3236

3337
/** The machine-readable capability manifest serialised to java-sdk/generated/lang-sdk/capabilities.json. */
3438
@Serializable
35-
data class Document(
39+
internal data class CapabilityManifest(
3640
val sdk: String,
3741
@SerialName("supervisor_schema_version") val supervisorSchemaVersion: String,
3842
@SerialName("min_airflow_version") val minAirflowVersion: String,
@@ -57,8 +61,8 @@ private fun no(note: String = ""): CapabilityEntry = CapabilityEntry(supported =
5761
* The runtime terminates a task with SucceedTask, RetryTask, or TaskState (failed/removed); it does not
5862
* yet emit skipped, DeferTask, RescheduleTask, or AwaitInputTask.
5963
*/
60-
val Capabilities: Document =
61-
Document(
64+
internal val Capabilities: CapabilityManifest =
65+
CapabilityManifest(
6266
sdk = "java",
6367
supervisorSchemaVersion = SUPERVISOR_SCHEMA_VERSION,
6468
minAirflowVersion = MIN_AIRFLOW_VERSION,
@@ -102,7 +106,7 @@ val Capabilities: Document =
102106
)
103107

104108
/** Print the capability manifest as JSON on stdout; wired to the `:sdk:dumpCapabilities` Gradle task. */
105-
fun main() {
109+
internal fun main() {
106110
val json =
107111
Json {
108112
prettyPrint = true

‎scripts/ci/prek/check_java_sdk_capabilities_in_sync.py‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,19 @@
4141

4242
sys.path.insert(0, str(Path(__file__).resolve().parent))
4343

44-
from lang_sdk_compat_matrix import AIRFLOW_ROOT_PATH, validate_capabilities
44+
from lang_sdk_compat_matrix import AIRFLOW_ROOT_PATH, CapabilitiesDoc, validate_capabilities
4545

4646
JAVA_SDK = AIRFLOW_ROOT_PATH / "java-sdk"
4747
GRADLEW = JAVA_SDK / "gradlew"
4848
GRADLE_PROPERTIES = JAVA_SDK / "gradle.properties"
4949
CAPABILITIES_JSON = JAVA_SDK / "generated" / "lang-sdk" / "capabilities.json"
5050

5151

52-
def canonical(data: dict) -> str:
53-
return json.dumps(data, indent=2) + "\n"
52+
def format_canonical_json(doc: CapabilitiesDoc) -> str:
53+
return json.dumps(doc, indent=2) + "\n"
5454

5555

56-
def gradle_schema_version() -> str | None:
56+
def read_gradle_schema_version() -> str | None:
5757
"""The ``airflowSupervisorSchemaVersion`` from gradle.properties (source of truth for the JAR)."""
5858
for line in GRADLE_PROPERTIES.read_text().splitlines():
5959
key, sep, value = line.partition("=")
@@ -62,7 +62,7 @@ def gradle_schema_version() -> str | None:
6262
return None
6363

6464

65-
def generate() -> dict:
65+
def generate_manifest() -> CapabilitiesDoc:
6666
"""Run the Gradle dump task and parse the capability manifest it prints."""
6767
completed = subprocess.run(
6868
[str(GRADLEW), "-q", "-p", "java-sdk", ":sdk:dumpCapabilities"],
@@ -72,16 +72,18 @@ def generate() -> dict:
7272
check=True,
7373
)
7474
out = completed.stdout
75-
# Gradle may prepend warnings; the manifest is the JSON object in the output.
76-
start, end = out.find("{"), out.rfind("}")
77-
if start == -1 or end == -1:
75+
# Gradle may prepend warnings, so decode the first complete JSON value rather than slicing to
76+
# the last "}" — a stray brace in surrounding output would make that span the wrong text.
77+
start = out.find("{")
78+
if start == -1:
7879
raise ValueError(f"no JSON object found in dumpCapabilities output:\n{out}")
79-
return json.loads(out[start : end + 1])
80+
doc, _ = json.JSONDecoder().raw_decode(out[start:])
81+
return doc
8082

8183

8284
def main() -> int:
8385
if not GRADLEW.exists() or shutil.which("java") is None:
84-
if os.environ.get("CI"):
86+
if "CI" in os.environ:
8587
print("ERROR: java/gradlew is required in CI but was not found.")
8688
return 1
8789
print(
@@ -90,13 +92,13 @@ def main() -> int:
9092
)
9193
return 0
9294
try:
93-
generated = generate()
95+
generated = generate_manifest()
9496
except (subprocess.CalledProcessError, ValueError) as error:
9597
detail = getattr(error, "stderr", None) or str(error)
9698
print(f"ERROR: could not run `:sdk:dumpCapabilities`:\n{detail}")
9799
return 1
98100
validate_capabilities(generated, source=":sdk:dumpCapabilities", expected_sdk="java")
99-
gradle_version = gradle_schema_version()
101+
gradle_version = read_gradle_schema_version()
100102
if gradle_version is not None and generated["supervisor_schema_version"] != gradle_version:
101103
print(
102104
f"ERROR: supervisor_schema_version mismatch — the Kotlin constant emits "
@@ -110,7 +112,7 @@ def main() -> int:
110112
print("OK: java-sdk/generated/lang-sdk/capabilities.json is in sync with conformance.Capabilities.")
111113
return 0
112114
CAPABILITIES_JSON.parent.mkdir(parents=True, exist_ok=True)
113-
CAPABILITIES_JSON.write_text(canonical(generated))
115+
CAPABILITIES_JSON.write_text(format_canonical_json(generated))
114116
print(
115117
"ERROR: java-sdk/generated/lang-sdk/capabilities.json was stale and has been regenerated from "
116118
"conformance.Capabilities. Re-stage the file."

‎scripts/ci/prek/lang_sdk_compat_matrix.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ class LangSdk(TypedDict):
118118
# writes its manifest to generated/lang-sdk/capabilities.json under the SDK root (a generated
119119
# artifact kept out of the hand-authored source tree). Only the Java SDK publishes one so far; the
120120
# Go and TypeScript entries record where theirs go when those runtimes declare their capabilities.
121+
# Because of that, `capabilities_json` is a declared location and not a promise the file exists — a
122+
# consumer walking the whole registry must check `.exists()` before calling load_capabilities().
121123
LANG_SDKS: list[LangSdk] = [
122124
{
123125
"id": "go",
@@ -214,6 +216,14 @@ def _validate_entries(entries: object, *, expected: set[str], kind: str, source:
214216
raise CapabilitiesError(f"{source}: {kind}.{name} must be an object with a boolean 'supported'")
215217
if not isinstance(entry.get("since", None), (str, type(None))):
216218
raise CapabilitiesError(f"{source}: {kind}.{name}.since must be a string or null")
219+
if not entry["supported"] and entry.get("since") is not None:
220+
# "Since" means "supported since"; carrying one while unsupported is contradictory and
221+
# would silently render as the not-supported placeholder. Supported *without* a version
222+
# stays legal — an SDK may not know which release first shipped a dimension.
223+
raise CapabilitiesError(
224+
f"{source}: {kind}.{name} is not supported but carries since="
225+
f"{entry['since']!r}; drop the version or mark it supported"
226+
)
217227
if not isinstance(entry.get("note", ""), str):
218228
raise CapabilitiesError(f"{source}: {kind}.{name}.note must be a string")
219229

‎scripts/tests/ci/prek/test_lang_sdk_compat_matrix.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ def test_non_string_schema_version_raises(self):
9999
with pytest.raises(matrix.CapabilitiesError, match="supervisor_schema_version must be a string"):
100100
matrix.validate_capabilities(_doc(supervisor_schema_version=123), source="test")
101101

102+
def test_unsupported_entry_carrying_since_raises(self):
103+
doc = _doc()
104+
doc["capabilities"]["branching"] = _entry(False, since="3.3")
105+
with pytest.raises(matrix.CapabilitiesError, match="not supported but carries since"):
106+
matrix.validate_capabilities(doc, source="test")
107+
108+
def test_supported_entry_without_since_passes(self):
109+
doc = _doc()
110+
doc["states"]["success"] = _entry(True, since=None)
111+
matrix.validate_capabilities(doc, source="test")
112+
102113

103114
class TestRenderMarkdownTable:
104115
def test_rows_cover_every_dimension(self):
@@ -127,6 +138,21 @@ def test_gated_capabilities_are_na_without_the_native_dag_gate(self):
127138
if cap.gated:
128139
assert f"| capability: `{cap.name}` | {cap.tier} † | {matrix.NA_MARK} |" in rendered
129140

141+
def test_gated_capabilities_resolve_to_their_own_mark_once_the_gate_is_supported(self):
142+
doc = _doc()
143+
doc["capabilities"]["branching"] = _entry(False, note="no branch construct")
144+
rendered = "".join(matrix.render_markdown_table(doc))
145+
gated = [cap for cap in matrix.CAPABILITY_DIMENSIONS if cap.gated]
146+
assert gated, "expected at least one gated capability"
147+
# With the gate supported, a gated capability reports its real support, never n/a.
148+
for cap in gated:
149+
if cap.name != "branching":
150+
assert (
151+
f"| capability: `{cap.name}` | {cap.tier} † | {matrix.SUPPORTED_MARK} | 3.3 |" in rendered
152+
)
153+
assert f"| capability: `branching` | SHOULD † | {matrix.UNSUPPORTED_MARK} |" in rendered
154+
assert matrix.NA_MARK not in rendered.replace(matrix.LEGEND, "")
155+
130156
def test_pipe_in_note_is_escaped(self):
131157
doc = _doc()
132158
doc["capabilities"]["xcom-read-write"] = _entry(True, since="3.3", note="read | write")

0 commit comments

Comments
 (0)