From b203fe90467aca4fd4ce8d81b311de1cac8cbb7d Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:15:40 +0100 Subject: [PATCH 1/3] fix(apply): refuse an unusable build id before tofu apply `fluid apply --mode amend-and-build` (and replace-and-build) checked contract.id and builds[].id only in the build runner. On the OpenTofu engine the runner starts after `tofu apply`, so an id the schema admits but the build phase refuses (1orders, more than 128 characters, a trailing newline) was refused after the product was provisioned, and rendered as "Unexpected error". The runner's rule is now one function, build_runners/_ids.py validate_build_ids, and apply runs it right after the schema gate in a build mode, ahead of the apply hooks, tofu and the runner, on both engines. The refusal is a typed CLIError, apply_build_identifier_invalid, that names the field and the value and says nothing was applied. The runner keeps its own check as the backstop. --- fluid_build/_error_catalog.py | 11 + fluid_build/build_runners/_ids.py | 39 +- fluid_build/build_runners/base.py | 12 +- fluid_build/cli/apply.py | 43 +++ tests/cli/test_apply_build_ids_before_tofu.py | 358 ++++++++++++++++++ 5 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 tests/cli/test_apply_build_ids_before_tofu.py diff --git a/fluid_build/_error_catalog.py b/fluid_build/_error_catalog.py index db85b031..8918e49d 100644 --- a/fluid_build/_error_catalog.py +++ b/fluid_build/_error_catalog.py @@ -265,6 +265,17 @@ def slug_for(event: str) -> str: ["Run 'fluid validate ' first to rule out a contract problem"], None, ), + "apply_build_identifier_invalid": ( + [ + "Rename the id to fit, for example orders_v1 instead of 1orders, and apply again", + ( + "A renamed id starts over: its builds get new cursors and watermarks under " + ".fluid/runs/, and a renamed contract.id gets a new OpenTofu workdir under " + ".fluid/iac//" + ), + ], + None, + ), "output_write_failed": ( ["Check that the --out path is writable and the parent directory exists"], None, diff --git a/fluid_build/build_runners/_ids.py b/fluid_build/build_runners/_ids.py index 831263a6..5fdb0f24 100644 --- a/fluid_build/build_runners/_ids.py +++ b/fluid_build/build_runners/_ids.py @@ -37,6 +37,7 @@ from __future__ import annotations import re +from typing import Any, Mapping, Optional # A permissive regex, identical to ``validate_ident`` in # ``providers/_sql_safety``: alphanumerics + dots + dashes + underscores, @@ -48,7 +49,17 @@ class IdentifierViolation(ValueError): - """Raised when a contract identifier fails validation.""" + """Raised when a contract identifier fails validation. + + ``kind`` and ``value`` name the id that failed when the raiser knows it + (:func:`validate_identifier` always does), so a caller can report it + without parsing the message. + """ + + def __init__(self, message: str, *, kind: Optional[str] = None, value: Any = None) -> None: + super().__init__(message) + self.kind = kind + self.value = value def validate_identifier(value: str, *, kind: str) -> str: @@ -65,6 +76,30 @@ def validate_identifier(value: str, *, kind: str) -> str: f"{kind} {value!r} is not a valid identifier " "(must match ^[A-Za-z_][A-Za-z0-9_.\\-]{0,127}$). This guard " "blocks template-injection / path-traversal attacks via " - "attacker-controlled contract metadata." + "attacker-controlled contract metadata.", + kind=kind, + value=value, ) return value + + +def validate_build_ids(contract: Mapping[str, Any]) -> None: + """Validate the ids a contract's build phase uses: ``contract.id`` and each ``builds[].id``. + + The build runner (``base.run_builds_from_args``) runs this before any + build, and ``fluid apply`` runs it before it applies anything, so the two + refuse the same ids. A contract with no builds has no build phase and is + not checked. An empty or absent id is skipped: it cannot traverse, and the + state store falls back to a safe default. A ``builds`` entry that is not a + mapping is left to the schema gate. + + Raises :class:`IdentifierViolation` for the first id that fails. + """ + builds = contract.get("builds") or [] + if not builds: + return + if contract.get("id"): + validate_identifier(contract["id"], kind="contract.id") + for build in builds: + if isinstance(build, Mapping) and build.get("id"): + validate_identifier(build["id"], kind="build.id") diff --git a/fluid_build/build_runners/base.py b/fluid_build/build_runners/base.py index 36db5499..3fd4e3af 100644 --- a/fluid_build/build_runners/base.py +++ b/fluid_build/build_runners/base.py @@ -1393,17 +1393,17 @@ def run_builds_from_args( # rather than traversing the filesystem. The parallel pipeline # (``cli/_acquisition_stage_ext``) already validates the same fields; # this closes the one-sided-guard gap on the runtime path. - from ._ids import validate_identifier + from ._ids import validate_build_ids # Only non-empty ids are checked: an absent/empty id cannot traverse # (``_build_dir`` falls back to a safe "product" default and # ``FileStateStore._confine()`` is the backstop), while a non-empty id # like ``../../../../tmp/escape`` is rejected here before any path is made. - if contract.get("id"): - validate_identifier(contract["id"], kind="contract.id") - for _b in builds: - if _b.get("id"): - validate_identifier(_b["id"], kind="build.id") + # ``fluid apply`` runs the same check before it applies anything + # (``cli/apply.py::_gate_build_ids_for_apply``), so on the OpenTofu engine + # an id is not refused here after ``tofu apply``; this one stays as the + # backstop for any caller that skips it. + validate_build_ids(contract) # The running ``fluid apply``'s run report, if any: each build is recorded on it. from fluid_build.observability.apply_run import current_apply_run diff --git a/fluid_build/cli/apply.py b/fluid_build/cli/apply.py index a4c8d45d..40b80600 100644 --- a/fluid_build/cli/apply.py +++ b/fluid_build/cli/apply.py @@ -308,6 +308,44 @@ def _gate_contract_for_apply(contract: Dict[str, Any], logger: logging.Logger) - _gate_contract_for_plan_or_apply(contract, logger, command="apply") +def _gate_build_ids_for_apply(contract: Dict[str, Any]) -> None: + """Refuse, before anything is applied, an id the build phase would refuse. + + The build phase turns ``contract.id`` and ``builds[].id`` into directory + names, so it takes a narrower grammar than the schema: ``1orders``, or an + id of 129 characters, passes ``fluid validate`` and ``fluid plan``. The + build runner checks the ids itself, but on the OpenTofu engine it runs + after ``tofu apply``, so such an id was refused once the product was + provisioned, as an "Unexpected error". This is the runner's own check + (``build_runners._ids.validate_build_ids``), run ahead of the hooks, + ``tofu`` and the runner on both engines. + + Raises: + CLIError: ``apply_build_identifier_invalid``. + """ + from fluid_build.build_runners._ids import IdentifierViolation, validate_build_ids + + try: + validate_build_ids(contract) + except IdentifierViolation as exc: + raise CLIError( + 1, + "apply_build_identifier_invalid", + { + "field": exc.kind, + "value": repr(exc.value), + "hint": ( + "The build phase keeps each build's run records, cursors and " + "watermarks under .fluid/runs///, so both ids " + "must start with a letter or underscore, use only letters, digits, " + "'_', '.' and '-', and be at most 128 characters. The contract schema " + "allows more, so fluid validate and fluid plan accept it. Nothing was " + "applied." + ), + }, + ) from exc + + def _log_apply_failure(logger: logging.Logger, start_time: float, error: Exception) -> None: """Record a pre-dispatch ``apply_contract`` failure for the structured log.""" log_operation_failure(logger, "apply_contract", error=error, duration=time.time() - start_time) @@ -1923,8 +1961,13 @@ def run(args, logger: logging.Logger) -> int: # IaC plugin clouds) and both engines' ``needs_build`` returns used to # skip it, so a schema-invalid contract reached ``tofu`` or the build # runner and could exit 0. One call per run, on the one loaded copy. + # A build mode also checks the ids the build phase turns into paths, which + # the schema admits more of: on the OpenTofu engine the runner would only + # refuse one after ``tofu apply``. try: _gate_contract_for_apply(source_contract, logger) + if needs_build(resolved_mode): + _gate_build_ids_for_apply(source_contract) except CLIError as exc: _log_apply_failure(logger, start_time, exc) raise diff --git a/tests/cli/test_apply_build_ids_before_tofu.py b/tests/cli/test_apply_build_ids_before_tofu.py new file mode 100644 index 00000000..5e766b47 --- /dev/null +++ b/tests/cli/test_apply_build_ids_before_tofu.py @@ -0,0 +1,358 @@ +# Copyright 2024-2026 Agentics Transformation Ltd +# +# Licensed 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. + +"""``fluid apply`` refuses an id the build phase cannot use before it applies anything. + +The build phase turns ``contract.id`` and ``builds[].id`` into directory names and +scheduler code, so it takes a narrower grammar (``build_runners._ids``) than the +schema's ``$defs.identifier``: a leading digit (``1orders``) or an id longer than +128 characters passes ``fluid validate`` and ``fluid plan``. Only the build runner +checked these ids, and on the OpenTofu engine it runs after ``tofu apply``. So +``--mode amend-and-build`` provisioned the product and then failed with +"Unexpected error", because ``IdentifierViolation`` had no typed mapping. + +These tests drive ``run()`` through the real argparse surface. ``tofu`` and the +apply hooks are recorders; the build runner is the real one, behind a spy, so +"refused first" is asserted as "``tofu``, the hooks and the runner never ran". +""" + +from __future__ import annotations + +import copy +import json +import logging +from pathlib import Path +from typing import Any, Dict, Iterator +from unittest import mock + +import pytest +import yaml + +import fluid_build.cli as cli_mod +from fluid_build.build_runners import _ids +from fluid_build.build_runners import base as runner_base +from fluid_build.build_runners._ids import IdentifierViolation +from fluid_build.cli import build_parser +from fluid_build.cli._common import CLIError +from fluid_build.cli.apply import _gate_contract_for_apply +from fluid_build.cli.apply import run as apply_run +from fluid_build.forge.core.plan_digest import inject_digests + +LOG = logging.getLogger("test.apply_build_ids_before_tofu") + +_CONTRACT: Dict[str, Any] = { + "fluidVersion": "0.7.5", + "kind": "DataProduct", + "id": "orders", + "name": "Orders", + "description": "Inline SQL build.", + "domain": "Demo", + "metadata": {"layer": "Bronze", "owner": {"team": "dp", "email": "dp@example.com"}}, + "builds": [ + { + "id": "make_rows", + "description": "Inline rows.", + "pattern": "embedded-logic", + "engine": "sql", + "properties": {"sql": "SELECT 1 AS id"}, + "outputs": ["rows"], + } + ], + "exposes": [ + { + "exposeId": "rows", + "kind": "table", + "binding": { + "platform": "local", + "format": "parquet", + "location": {"path": "./out/rows.parquet"}, + }, + "contract": {"schema": [{"name": "id", "type": "INTEGER", "required": True}]}, + } + ], +} + +# Ids the schema admits and the build phase refuses: (field, value). +_UNUSABLE = [ + pytest.param("contract.id", "1orders", id="contract-id-leading-digit"), + pytest.param("contract.id", "o" * 129, id="contract-id-129-chars"), + pytest.param("build.id", "1make_rows", id="build-id-leading-digit"), + pytest.param("build.id", "b" * 129, id="build-id-129-chars"), +] + +_BUILD_MODES = [ + pytest.param("amend-and-build", (), id="amend-and-build"), + pytest.param("replace-and-build", ("--allow-data-loss",), id="replace-and-build"), +] + + +def _with(field: str, value: str) -> Dict[str, Any]: + """``_CONTRACT`` with ``value`` as its ``contract.id`` or its build's id.""" + contract = copy.deepcopy(_CONTRACT) + if field == "contract.id": + contract["id"] = value + else: + contract["builds"][0]["id"] = value + return contract + + +@pytest.fixture +def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("FLUID_ENV", raising=False) + (tmp_path / "runtime").mkdir() + return tmp_path + + +def _source(form: str, contract: Dict[str, Any], *, mode: str) -> str: + """Write ``contract`` as the YAML file an operator applies, or as a + digest-bound ``plan.json`` that embeds it.""" + if form == "yaml": + path, text = Path("contract.fluid.yaml"), yaml.safe_dump(contract, sort_keys=False) + else: + plan = {"format_version": "0.7.5", "mode": mode, "actions": [], "contract": contract} + path, text = Path("runtime/plan.json"), json.dumps(inject_digests(plan, bundle_path=None)) + path.write_text(text, encoding="utf-8") + return str(path) + + +def _apply(src: str, *flags: str) -> int: + """``fluid apply --yes`` through the real top-level parser.""" + return apply_run(build_parser().parse_args(["apply", src, *flags, "--yes"]), LOG) + + +@pytest.fixture +def tofu() -> Iterator[mock.Mock]: + """Route ``run()`` onto the OpenTofu engine and record the engine call.""" + with ( + mock.patch( + "fluid_build.cli._apply_opentofu_engine.resolve_apply_engine", + return_value="opentofu", + ), + mock.patch( + "fluid_build.cli._apply_opentofu_engine.apply_via_opentofu", return_value=0 + ) as engine, + ): + yield engine + + +@pytest.fixture +def native() -> Iterator[None]: + with mock.patch( + "fluid_build.cli._apply_opentofu_engine.resolve_apply_engine", return_value="native" + ): + yield + + +@pytest.fixture +def hooks() -> Iterator[mock.Mock]: + with mock.patch("fluid_build.cli.apply._run_apply_hooks", return_value=0) as recorder: + yield recorder + + +@pytest.fixture +def runner() -> Iterator[mock.Mock]: + """The real build runner, behind a spy.""" + with mock.patch( + "fluid_build.build_runners.run_builds_from_args", + wraps=runner_base.run_builds_from_args, + ) as spy: + yield spy + + +@pytest.mark.parametrize("field,value", _UNUSABLE) +def test_the_schema_admits_these_ids(field: str, value: str) -> None: + """Why the refusal came late: these ids pass the gate ``fluid validate`` and + ``fluid plan`` run. If the schema starts refusing them, the schema gate + refuses them first and the tests below need another example.""" + _gate_contract_for_apply(_with(field, value), LOG) + + +@pytest.mark.usefixtures("workspace") +class TestRefusedBeforeAnythingIsApplied: + @pytest.mark.parametrize("form", ["yaml", "plan"]) + @pytest.mark.parametrize("field,value", _UNUSABLE) + @pytest.mark.parametrize("mode,extra", _BUILD_MODES) + def test_refused_before_tofu( + self, + tofu: mock.Mock, + hooks: mock.Mock, + runner: mock.Mock, + form: str, + field: str, + value: str, + mode: str, + extra: Any, + ) -> None: + src = _source(form, _with(field, value), mode=mode) + with pytest.raises(CLIError) as exc: + _apply(src, "--mode", mode, *extra) + tofu.assert_not_called() + hooks.assert_not_called() + runner.assert_not_called() + assert exc.value.event == "apply_build_identifier_invalid" + assert exc.value.context["field"] == field + assert exc.value.context["value"] == repr(value) + + @pytest.mark.usefixtures("native") + @pytest.mark.parametrize("field,value", _UNUSABLE) + def test_native_engine_refuses_with_the_same_typed_error( + self, runner: mock.Mock, field: str, value: str + ) -> None: + """The native engine applies nothing before its builds, but its refusal was + the same untyped ``IdentifierViolation``.""" + src = _source("yaml", _with(field, value), mode="amend-and-build") + with pytest.raises(CLIError) as exc: + _apply(src, "--mode", "amend-and-build") + runner.assert_not_called() + assert exc.value.event == "apply_build_identifier_invalid" + + def test_a_build_the_filter_skips_is_still_checked( + self, tofu: mock.Mock, runner: mock.Mock + ) -> None: + """The runner checks every build's id before ``--build-id`` picks one, so a + check that skipped the other builds would let ``tofu`` run first.""" + contract = copy.deepcopy(_CONTRACT) + other = dict(contract["builds"][0], id="1other") + contract["builds"].append(other) + src = _source("yaml", contract, mode="amend-and-build") + with pytest.raises(CLIError) as exc: + _apply(src, "--mode", "amend-and-build", "--build-id", "make_rows") + tofu.assert_not_called() + assert exc.value.context["value"] == repr("1other") + + def test_the_refusal_says_what_to_change(self, tofu: mock.Mock) -> None: + src = _source("yaml", _with("contract.id", "1orders"), mode="amend-and-build") + with pytest.raises(CLIError) as exc: + _apply(src, "--mode", "amend-and-build") + hint = exc.value.context["hint"] + assert "Nothing was applied" in hint + assert "start with a letter or underscore" in hint + assert "128" in hint + assert exc.value.suggestions, "the catalog gives the event its suggestions" + + def test_the_cli_renders_it_as_a_typed_error( + self, tofu: mock.Mock, capsys: pytest.CaptureFixture[str] + ) -> None: + """Through ``main()``: the slug and the id, not "Unexpected error".""" + src = _source("yaml", _with("contract.id", "1orders"), mode="amend-and-build") + assert cli_mod.main(["apply", src, "--mode", "amend-and-build", "--yes"]) == 1 + out = capsys.readouterr().out + assert "ERR_APPLY_BUILD_IDENTIFIER_INVALID" in out + assert "'1orders'" in out + assert "Unexpected error" not in out + tofu.assert_not_called() + + @pytest.mark.parametrize( + "field,value", + [ + pytest.param("contract.id", "orders\n", id="contract-id"), + pytest.param("build.id", "make_rows\n", id="build-id"), + ], + ) + def test_a_trailing_newline_is_refused_before_tofu( + self, + tofu: mock.Mock, + hooks: mock.Mock, + runner: mock.Mock, + field: str, + value: str, + ) -> None: + """``_IDENT_RE`` ends in ``\\Z``, so the build phase refuses ``orders\\n``. The + schema's ``^...$`` pattern still lets ``re.search`` match before the newline. + This check refuses it until the schema pattern is fixed; after that the schema + gate does. Either way the refusal is typed and comes before ``tofu``.""" + src = _source("yaml", _with(field, value), mode="amend-and-build") + with pytest.raises(CLIError) as exc: + _apply(src, "--mode", "amend-and-build") + tofu.assert_not_called() + hooks.assert_not_called() + runner.assert_not_called() + assert exc.value.event in {"apply_build_identifier_invalid", "apply_contract_invalid"} + + +@pytest.mark.usefixtures("workspace") +class TestNothingNewIsRefused: + """The check refuses exactly what the build runner would refuse.""" + + @pytest.mark.parametrize("form", ["yaml", "plan"]) + @pytest.mark.parametrize( + "field,value", + [ + pytest.param("contract.id", "orders", id="plain"), + pytest.param("contract.id", "o" * 128, id="contract-id-128-chars"), + pytest.param("build.id", "_make_rows", id="build-id-leading-underscore"), + ], + ) + def test_usable_ids_reach_tofu_then_the_runner( + self, tofu: mock.Mock, form: str, field: str, value: str + ) -> None: + with mock.patch("fluid_build.build_runners.run_builds_from_args", return_value=0) as run: + mode = "amend-and-build" + assert _apply(_source(form, _with(field, value), mode=mode), "--mode", mode) == 0 + tofu.assert_called_once() + run.assert_called_once() + + def test_a_mode_without_a_build_phase_is_not_checked( + self, tofu: mock.Mock, runner: mock.Mock + ) -> None: + src = _source("yaml", _with("contract.id", "1orders"), mode="amend") + assert _apply(src, "--mode", "amend") == 0 + tofu.assert_called_once() + runner.assert_not_called() + + def test_a_contract_without_builds_is_not_checked( + self, tofu: mock.Mock, runner: mock.Mock + ) -> None: + """With no builds the runner returns before its own check.""" + contract = _with("contract.id", "1orders") + del contract["builds"] + src = _source("yaml", contract, mode="amend-and-build") + assert _apply(src, "--mode", "amend-and-build") == 0 + tofu.assert_called_once() + runner.assert_called_once() + + +class TestOneRuleForBothCallers: + """``validate_build_ids`` is the rule the runner applies and ``fluid apply`` + runs ahead of it.""" + + @pytest.mark.parametrize("field,value", _UNUSABLE) + def test_it_names_the_id_that_failed(self, field: str, value: str) -> None: + with pytest.raises(IdentifierViolation) as exc: + _ids.validate_build_ids(_with(field, value)) + assert (exc.value.kind, exc.value.value) == (field, value) + + def test_the_runner_still_refuses_on_its_own(self, tmp_path: Path) -> None: + """Kept as the backstop for a caller that skips ``fluid apply``.""" + import argparse + + path = tmp_path / "contract.fluid.yaml" + path.write_text("{}", encoding="utf-8") + args = argparse.Namespace(contract=str(path), env=None, build_id=None) + with pytest.raises(IdentifierViolation, match="contract.id"): + runner_base.run_builds_from_args(args, LOG, contract=_with("contract.id", "1orders")) + + @pytest.mark.parametrize( + "contract", + [ + pytest.param({"id": "1orders"}, id="no-builds"), + pytest.param({"id": "1orders", "builds": []}, id="empty-builds"), + pytest.param({"id": "", "builds": [{"id": ""}]}, id="empty-ids"), + pytest.param({"builds": [{"engine": "sql"}]}, id="absent-ids"), + pytest.param({"id": "orders", "builds": ["make_rows", None]}, id="non-mapping-builds"), + ], + ) + def test_what_it_does_not_check(self, contract: Dict[str, Any]) -> None: + _ids.validate_build_ids(contract) From e7f60572158065a7595d441cfd80408f7beeb9f0 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:15:40 +0100 Subject: [PATCH 2/3] docs(changelog): build ids are checked before tofu apply --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16670680..28daeee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`fluid apply --mode amend-and-build` provisioned a product, then refused its id.** The build + phase keeps run records, cursors and watermarks under `.fluid/runs///`, + so its ids must start with a letter or underscore, use only letters, digits, `_`, `.` and `-`, + and be at most 128 characters. The contract schema allows more (`1orders`, or a longer id), so + `fluid validate` and `fluid plan` passed such an id. Only the build runner checked it, and on the + OpenTofu engine (AWS, GCP, Snowflake, Confluent, Databricks and IaC plugin clouds) the runner + starts after `tofu apply`: the infrastructure was created, and the run then stopped with + "Unexpected error". A build mode (`amend-and-build`, `replace-and-build`) now checks + `contract.id` and every `builds[].id` before the apply hooks, `tofu` and the runner, on both + engines, and refuses with `ERR_APPLY_BUILD_IDENTIFIER_INVALID`, which names the id, says nothing + was applied and suggests a rename. A mode without a build phase, and a contract without builds, + are not checked, as before. + - **`fluid diff --baseline` reported nothing for column changes in a 0.7.x contract.** It read columns from `exposes[].schema`, but a 0.7.x contract keeps them in `exposes[].contract.schema` (as does every `fluid init` template), so a removed column, a type change or a new NOT NULL From 6b3cdadb43482565b1d09cf884fac1a3af417a96 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:48:00 +0100 Subject: [PATCH 3/3] test(apply): import fluid_build.cli one way in the build-id test --- tests/cli/test_apply_build_ids_before_tofu.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/cli/test_apply_build_ids_before_tofu.py b/tests/cli/test_apply_build_ids_before_tofu.py index 5e766b47..a68f329d 100644 --- a/tests/cli/test_apply_build_ids_before_tofu.py +++ b/tests/cli/test_apply_build_ids_before_tofu.py @@ -43,7 +43,6 @@ from fluid_build.build_runners import _ids from fluid_build.build_runners import base as runner_base from fluid_build.build_runners._ids import IdentifierViolation -from fluid_build.cli import build_parser from fluid_build.cli._common import CLIError from fluid_build.cli.apply import _gate_contract_for_apply from fluid_build.cli.apply import run as apply_run @@ -129,7 +128,7 @@ def _source(form: str, contract: Dict[str, Any], *, mode: str) -> str: def _apply(src: str, *flags: str) -> int: """``fluid apply --yes`` through the real top-level parser.""" - return apply_run(build_parser().parse_args(["apply", src, *flags, "--yes"]), LOG) + return apply_run(cli_mod.build_parser().parse_args(["apply", src, *flags, "--yes"]), LOG) @pytest.fixture