Skip to content

Stop format_name_template from mutating the caller substitutions dict#5091

Closed
chuenchen309 wants to merge 1 commit into
zenml-io:developfrom
chuenchen309:fix/format-name-template-mutates-caller
Closed

Stop format_name_template from mutating the caller substitutions dict#5091
chuenchen309 wants to merge 1 commit into
zenml-io:developfrom
chuenchen309:fix/format-name-template-mutates-caller

Conversation

@chuenchen309

Copy link
Copy Markdown

Describe changes

format_name_template mutates the caller's substitutions dict.

substitutions or {} only builds a new dict when the argument is falsy, so a non-empty dict is aliased and then written into by the .update() / .setdefault() calls below:

subs = {"custom": "abc"}
format_name_template("run-{custom}-{date}-{time}", substitutions=subs)
subs
# {'custom': 'abc', 'date': '2026_07_16', 'time': '16_50_23_522789'}

An empty dict masks the bug ({} or {} yields a fresh dict), which is likely why it went unnoticed.

Why it matters

_create_snapshot passes pipeline_configuration.substitutions straight in when deriving a schedule name (pipeline_definition.py:995-998), and that happens before the snapshot is serialized into the PipelineSnapshotRequest (:1085) — so the injected date/time get persisted into the snapshot.

finalize_substitutions then uses setdefault, so the baked-in values win over the real start time from that point on:

cfg = PipelineConfiguration(name="p", substitutions={"env": "prod"})
format_name_template("p-{env}-{date}", substitutions=cfg.substitutions)

cfg.finalize_substitutions(start_time=datetime(2027, 1, 1, 9, 0, 0), inplace=False)["date"]
# '2026_07_16'   <- the future start time is ignored

Every run off that schedule then resolves the same frozen {date}/{time}, i.e. the same run name — the collision the server warns about in sql_zen_store.py. Triggering it needs nothing unusual: a schedule without an explicit name, a non-empty @pipeline(substitutions={...}) (public, documented), and a run_name_template containing {date}/{time} — which is the default (run_utils.py:73).

Changes

One line: copy the dict. Explicit date/time substitutions still take precedence, and the returned name is unchanged.

Two tests added — the mutation guard fails on develop and passes here; the second pins that caller-supplied date/time are still honored. format_name_template had no test coverage, which is why this survived.

Pre-requisites

  • I have read the CONTRIBUTING.md document.
  • I have added tests to cover my changes.
  • I have based my new branch on develop and the open PR is targeting develop.
  • IMPORTANT: I made sure that my changes are reflected properly in the following resources:
    • ZenML Docs — internal helper, no doc change needed
    • Dashboard: not affected
    • Templates: not affected
    • Projects: not affected

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Other (add details above)

Verification

  • pytest tests/unit/utils/test_string_utils.py — 6 passed.
  • ruff check (with --extend-ignore D for tests, per scripts/lint.sh) and ruff format --check clean.
  • mypy src/zenml/utils/string_utils.py reports one pre-existing unused-ignore at line 252, unrelated to this change — it appears identically on a clean develop (verified via git stash).

xref: #5080 also touches string_utils.py, but only adds a new append_random_suffix helper near the top of the file — no overlap with format_name_template.

I could not add a release-notes label myself (external contributors lack label permissions); this looks like no-release-notes to me, but happy to defer.


AI disclosure: drafted with Claude Code (Opus 4.8), including the root-cause trace and the tests. I ran the repro and the tests locally and reviewed the diff before opening.

`substitutions or {}` only builds a new dict when the argument is falsy, so a
non-empty dict is aliased and then has date/time written into it:

    subs = {"custom": "abc"}
    format_name_template("run-{custom}-{date}-{time}", substitutions=subs)
    subs  # {'custom': 'abc', 'date': '2026_07_16', 'time': '16_50_23_522789'}

An empty dict masks this (`{} or {}` yields a new dict), which is likely why
it went unnoticed.

_create_snapshot passes pipeline_configuration.substitutions straight in when
deriving a schedule name, before the snapshot is serialized into the
PipelineSnapshotRequest, so the injected date/time get persisted. Because
finalize_substitutions uses setdefault, the baked-in values then win over the
real start time forever after:

    cfg.finalize_substitutions(start_time=datetime(2027, 1, 1))["date"]
    # '2026_07_16' -- the future start time is ignored

Copy the dict instead. Explicit date/time substitutions still take precedence,
and the returned name is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chuenchen309

Copy link
Copy Markdown
Author

Closing as a duplicate — this is the same fix as #5088, which I opened earlier today and had lost track of across parallel work. Apologies for the noise; #5088 stands.

Sorry for the extra notification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant