Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .dagger/src/mat_vis_ci/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
dagger call bake # per-file hf-bake → atomic HF commit (#136 / ADR-0012)
dagger call smoke-bake # dry-run bake against gerchowl/mat-vis-tst (#136)
dagger call derive # per-file hf-derive (resize) (#204)
dagger call derive-ktx2 # per-file hf-derive-ktx2 (#204)
dagger call derive-ktx-2 # per-file hf-derive-ktx2 (#204; #240)
dagger call integration-test # local end-to-end per-file bake + verify
dagger call probe-sources # verify upstream API connectivity
dagger call test-all # lint + test + smoke + probe
Expand All @@ -20,8 +20,7 @@
dagger call test-client-shell # bash tests for shell reference client
dagger call test-client-rust # cargo test for Rust reference client
dagger call test-clients # all 4 client tests in parallel
dagger call test-e2e # nightly E2E against mat-vis-tst (#193)
dagger call validate-release # verify release assets are complete
dagger call test-e-2-e # nightly E2E against mat-vis-tst (#193; #240)
dagger call preflight # verify GHCR auth before push
dagger call push # preflight + build + push to GHCR
"""
Expand Down Expand Up @@ -850,6 +849,12 @@ async def derive_ktx2(

Safety: defaults to ``gerchowl/mat-vis-tst``; any other target
requires ``--allow-prod=true``.

CLI surface: dagger's Go-side kebab-case conversion splits
letter→digit boundaries, so this Python ``derive_ktx2`` is
exposed as ``dagger call derive-ktx-2`` (not ``derive-ktx2``).
See #240 and the contract test in
``tests/test_dagger_function_names.py``.
"""
self._guard_prod_target(repo_id, allow_prod)
ctr = self._baker_container(context, with_ktx2=True, hf_token=hf_token)
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/derive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
#
# Shape:
# plan — emits matrix sources (#233) + derives target-tier label
# derive — matrix[source] runs the Dagger `derive` or `derive-ktx2`
# derive — matrix[source] runs the Dagger `derive` or `derive-ktx-2`
# function (which wraps mat-vis-baker hf-derive{,-ktx2}).
# #240: dagger's Go CLI splits letter→digit boundaries when
# converting camelCase to kebab, so `deriveKtx2` is exposed
# as `derive-ktx-2` (not `derive-ktx2`). Smoke test in
# tests/test_dagger_function_names.py guards this contract.
# Each material batch is an atomic HF commit; a sentinel
# `.tier_complete` file lands as the final commit per
# (source, target_tier) so clients can probe atomicity
Expand Down Expand Up @@ -207,7 +211,7 @@ jobs:
verb: call
module: .dagger
args: >-
${{ inputs.kind == 'resize' && 'derive' || 'derive-ktx2' }}
${{ inputs.kind == 'resize' && 'derive' || 'derive-ktx-2' }}
--context=.
--source=${{ matrix.source }}
${{ inputs.kind == 'resize' && format('--target-tier={0}', inputs.target-tier) || '' }}
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
# escape hatch.
#
# Reproduce locally with:
# dagger call test-e2e --hf-token=env:HF_TOKEN --context=.
# dagger call test-e-2-e --hf-token=env:HF_TOKEN --context=.
#
# #240: dagger's Go CLI splits letter→digit boundaries when converting
# camelCase to kebab, so `testE2E`/`test_e2e` is exposed as `test-e-2-e`.

name: E2E

Expand All @@ -34,13 +37,13 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: test-e2e
- name: test-e-2-e
uses: dagger/dagger-for-github@v8.4.1
with:
verb: call
module: .dagger
args: >-
test-e2e
test-e-2-e
--context=.
--hf-token=env:HF_TOKEN
--repo-id=gerchowl/mat-vis-tst
Expand Down
155 changes: 155 additions & 0 deletions tests/test_dagger_function_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Regression gate for the Dagger CLI function-name contract (#240).

Dagger's Python SDK converts a snake_case ``@function`` method name to
camelCase before exposing it on the GraphQL/engine surface. The Go-side
``dagger call`` CLI then converts that camelCase to kebab-case, splitting
on **both** lowercase→uppercase and lowercase→digit boundaries. So
``derive_ktx2`` (Python) → ``deriveKtx2`` (GQL) → ``derive-ktx-2`` (CLI),
**not** ``derive-ktx2``.

#240: the ``derive.yml`` workflow used to invoke ``dagger call
derive-ktx2`` and failed in CI with ``unknown command``. This test pins
the kebab name our workflows must use, so any future @function rename
that breaks the contract trips a unit test instead of a CI dispatch.
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

DAGGER_MAIN = Path(__file__).resolve().parent.parent / ".dagger" / "src" / "mat_vis_ci" / "main.py"


def _camel_to_kebab(camel: str) -> str:
"""Replicate dagger's Go-side camelCase → kebab-case conversion.

Inserts a hyphen before any uppercase letter or digit that follows
a lowercase letter, then lowercases the whole thing. Empirically
matches `dagger functions` output on engine v0.20.x for both
letter→letter (``probeSources`` → ``probe-sources``) and
letter→digit (``deriveKtx2`` → ``derive-ktx-2``,
``testE2E`` → ``test-e-2-e``) boundaries.
"""
# Engine treats every letter↔digit and lower→upper transition as a
# word boundary. Empirically (engine v0.20.x) ``testE2E`` splits on
# all four edges → ``test-e-2-e``. Apply each rule in sequence on
# the running result so chained transitions all fire.
s = camel
s = re.sub(r"([a-z])([A-Z])", r"\1-\2", s) # lower → upper
s = re.sub(r"([a-zA-Z])([0-9])", r"\1-\2", s) # letter → digit
s = re.sub(r"([0-9])([a-zA-Z])", r"\1-\2", s) # digit → letter
return s.lower()


def _snake_to_camel(snake: str) -> str:
head, *tail = snake.split("_")
return head + "".join(p.title() for p in tail)


def _snake_to_kebab(snake: str) -> str:
return _camel_to_kebab(_snake_to_camel(snake))


@pytest.fixture(scope="module")
def function_python_names() -> list[str]:
"""Parse @function-decorated method names out of .dagger/.../main.py.

Lightweight regex scan — avoids importing the dagger SDK (heavy
runtime + GraphQL engine deps) just to introspect names.
"""
src = DAGGER_MAIN.read_text()
# Match @function (with or without args) immediately followed by an
# async def or def line.
pattern = re.compile(
r"@function(?:\([^)]*\))?\s*\n\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(",
re.MULTILINE,
)
names = pattern.findall(src)
assert names, f"no @function-decorated methods found in {DAGGER_MAIN}"
return names


class TestKebabConversion:
"""Pure-function tests for the kebab conversion helper."""

@pytest.mark.parametrize(
("snake", "expected_kebab"),
[
# Simple, no digits
("derive", "derive"),
("probe_sources", "probe-sources"),
("build_materialx", "build-materialx"),
# #240: letter→digit boundary splits
("derive_ktx2", "derive-ktx-2"),
("test_e2e", "test-e-2-e"),
# Hypothetical future drift cases
("foo_512", "foo-512"), # snake-cased digit segment
("foo_bar2", "foo-bar-2"),
("foo2_bar", "foo-2-bar"),
],
)
def test_snake_to_kebab(self, snake: str, expected_kebab: str) -> None:
assert _snake_to_kebab(snake) == expected_kebab


class TestDaggerFunctionNames:
"""Pin the kebab CLI name our workflows invoke for each @function.

If a future refactor renames ``derive_ktx2`` (and forgets to update
``derive.yml``), this test fails in unit tests — long before a CI
dispatch hits the same ``unknown command`` error #240 chased.
"""

def test_derive_ktx2_is_exposed_as_kebab_with_split_digit(
self, function_python_names: list[str]
) -> None:
assert "derive_ktx2" in function_python_names, (
"derive_ktx2 @function disappeared from .dagger/src/mat_vis_ci/main.py — "
"if it was renamed, update .github/workflows/derive.yml line 14 + 210"
)
assert _snake_to_kebab("derive_ktx2") == "derive-ktx-2"

def test_test_e2e_is_exposed_as_kebab_with_split_digit(
self, function_python_names: list[str]
) -> None:
assert "test_e2e" in function_python_names
assert _snake_to_kebab("test_e2e") == "test-e-2-e"

def test_workflow_derive_yml_uses_correct_kebab_name(self) -> None:
"""The ternary in derive.yml must match the kebab name above."""
derive_yml = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "derive.yml"
text = derive_yml.read_text()
assert "'derive-ktx-2'" in text, (
"derive.yml ternary must invoke 'derive-ktx-2' (kebab with split "
"digit, per #240). Found:\n"
+ "\n".join(line for line in text.splitlines() if "derive-ktx" in line)
)
assert "'derive-ktx2'" not in text, (
"derive.yml still references the broken 'derive-ktx2' literal — "
"update the ternary AND the header comment (#240)."
)

def test_workflow_e2e_yml_uses_correct_kebab_name(self) -> None:
e2e_yml = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "e2e.yml"
text = e2e_yml.read_text()
# `args:` block must invoke `test-e-2-e`. Header comment may
# also include the kebab form for documentation.
assert "test-e-2-e" in text, (
"e2e.yml must invoke `test-e-2-e` (kebab with split digit, #240)"
)
# The literal broken form should not appear as a CLI invocation.
# (Allow it inside a code reference like `test_e2e` Python name.)
assert re.search(r"\btest-e2e\b", text) is None, (
"e2e.yml still references the broken `test-e2e` literal (#240)."
)

def test_all_decorated_functions_have_unique_kebab_names(
self, function_python_names: list[str]
) -> None:
"""Sanity check: no two @functions collide on their CLI name."""
kebabs = [_snake_to_kebab(n) for n in function_python_names]
dupes = {k for k in kebabs if kebabs.count(k) > 1}
assert not dupes, f"duplicate kebab CLI names across @functions: {dupes}"
Loading