Skip to content

Commit 37cae94

Browse files
EngHabuclaude
andcommitted
artifacts: parent lineage, duck-typed declaration protocol, content-hash versions
- Metadata gains parents (ordered ArtifactParent | bare-version entries, serialized to the new repeated parent_artifacts on both ProducedArtifact and ArtifactSpec) and version_from_content (opt-in: an empty version resolves to the literal's content hash at conversion/create time, making republish of identical content idempotent). - Output conversion duck-types get_flyte_metadata() on top-level outputs so offloaded-asset types outside flyte.io (e.g. plugin volumes) can declare themselves; a new current_output_declares_artifact contextvar tells the value's transformer when a declaration is actually being emitted. - ensure_artifactable and Artifact.create accept protocol-carrying values; create() gains parents= and seeds defaults from duck metadata like it does from the wrapper. - flyteidl2 pinned to 2.0.48 (repeated parent_artifacts), incl. rs_controller. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N415hnSCNmZ2e7WrwEPJo7 Signed-off-by: Haytham Abuelfutuh <haytham@union.ai>
1 parent b0bcb4e commit 37cae94

12 files changed

Lines changed: 412 additions & 92 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ dependencies = [
2626
"async-lru>=2.0.5",
2727
"mashumaro>=3.15",
2828
"aiolimiter>=1.2.1",
29-
"flyteidl2==2.0.45",
29+
"flyteidl2==2.0.48",
3030
"packaging",
3131
"sentry-sdk>=2.0",
3232
"pyOpenSSL>=24.0.0",

rs_controller/Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs_controller/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ async-trait = "0.1"
5656
thiserror = "1.0"
5757
# Uncomment this if you need to use local flyteidl2
5858
#flyteidl2 = { path = "/Users/ytong/go/src/github.com/flyteorg/flyte/gen/rust" }
59-
flyteidl2 = "=2.0.45"
59+
flyteidl2 = "=2.0.48"
6060
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
6161
serde = { version = "1.0", features = ["derive"] }
6262
serde_json = "1.0"

rs_controller/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ description = "Rust controller for Union"
99
requires-python = ">=3.10"
1010
classifiers = ["Programming Language :: Python", "Programming Language :: Rust"]
1111
dependencies = [
12-
"flyteidl2==2.0.45",
12+
"flyteidl2==2.0.48",
1313
]
1414
[tool.maturin]
1515
module-name = "flyte_controller_base"

src/flyte/_internal/runtime/convert.py

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,26 @@ def current_output_name() -> Optional[str]:
4242
return _output_name_var.get()
4343

4444

45+
# True only while converting a top-level output for which
46+
# ``convert_from_native_to_outputs`` captured artifact metadata and will emit a
47+
# ``ProducedArtifact`` declaration. Lets a TypeTransformer know the value it is
48+
# serializing is being registered as an artifact — e.g. to stamp the version
49+
# it will be registered under into the serialized value itself. Deliberately
50+
# NOT set for values nested inside containers/models on the same output: those
51+
# are never declared.
52+
_output_declares_artifact_var: contextvars.ContextVar[bool] = contextvars.ContextVar(
53+
"flyte_current_output_declares_artifact", default=False
54+
)
55+
56+
57+
def current_output_declares_artifact() -> bool:
58+
"""Whether the output being converted right now will be declared as a
59+
produced artifact on the Outputs envelope. See
60+
`_output_declares_artifact_var`.
61+
"""
62+
return _output_declares_artifact_var.get()
63+
64+
4565
@dataclass(frozen=True)
4666
class Inputs:
4767
proto_inputs: common_pb2.Inputs
@@ -429,7 +449,7 @@ async def convert_from_native_to_outputs(o: Any, interface: NativeInterface, tas
429449
f"Received {len(o)} outputs but return annotation has {len(interface.outputs)} outputs specified. "
430450
)
431451
from flyte.artifacts._metadata import to_produced_artifact
432-
from flyte.artifacts._wrapper import ArtifactWrapper, raise_if_nested_wrapper
452+
from flyte.artifacts._wrapper import raise_if_nested_wrapper
433453

434454
named = []
435455
produced: list[common_pb2.ProducedArtifact] = []
@@ -438,22 +458,40 @@ async def convert_from_native_to_outputs(o: Any, interface: NativeInterface, tas
438458
# the wrapper and discards it, then emit a ProducedArtifact declaration on the Outputs
439459
# envelope so the backend can register the artifact. The declaration carries the
440460
# declared output type (this SDK is authoritative for it).
461+
#
462+
# The check is a protocol, not the wrapper class: any top-level output
463+
# exposing ``get_flyte_metadata() -> Metadata | None`` participates, so
464+
# offloaded-asset types outside flyte.io (e.g. plugin-provided volumes)
465+
# can declare themselves without being wrappable.
441466
raise_if_nested_wrapper(v)
442-
produced_md = v.get_flyte_metadata() if isinstance(v, ArtifactWrapper) else None
467+
produced_md = None
468+
md_getter = getattr(v, "get_flyte_metadata", None)
469+
if callable(md_getter):
470+
produced_md = md_getter()
443471

444472
# Expose the output slot name to transformers for the duration of this
445473
# single conversion (see ``current_output_name``), then always clear it.
446474
tok = _output_name_var.set(output_name)
475+
decl_tok = _output_declares_artifact_var.set(produced_md is not None)
447476
try:
448477
literal_type = TypeEngine.to_literal_type(python_type)
449478
lit = await TypeEngine.to_literal(v, python_type, literal_type)
450479
if produced_md is not None:
451-
produced.append(to_produced_artifact(produced_md, output=output_name, literal_type=literal_type))
480+
pa = to_produced_artifact(produced_md, output=output_name, literal_type=literal_type)
481+
# Content-addressed default version: when the metadata opts in and
482+
# the transformer stamped a content hash on the literal, use it
483+
# instead of leaving the version to the backend's
484+
# run-action-attempt default. Deterministic versions make
485+
# re-declaring the same content idempotent (AlreadyExists).
486+
if not pa.version and produced_md.version_from_content and lit.hash:
487+
pa.version = lit.hash
488+
produced.append(pa)
452489
named.append(common_pb2.NamedLiteral(name=output_name, value=lit))
453490
except TypeTransformerFailedError as e:
454491
raise flyte.errors.RuntimeDataValidationError(output_name, e, task_name)
455492
finally:
456493
_output_name_var.reset(tok)
494+
_output_declares_artifact_var.reset(decl_tok)
457495

458496
return Outputs(proto_outputs=common_pb2.Outputs(literals=named, produced_artifacts=produced))
459497

src/flyte/artifacts/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ async def my_task() -> File:
5252
"""
5353

5454
from ._card import Card, CardFormat, CardType
55-
from ._metadata import KIND_KEY, Kind, Metadata
55+
from ._metadata import KIND_KEY, ArtifactParent, Kind, Metadata
5656
from ._wrapper import Artifact, new
5757

58-
__all__ = ["KIND_KEY", "Artifact", "Card", "CardFormat", "CardType", "Kind", "Metadata", "new"]
58+
__all__ = ["KIND_KEY", "Artifact", "ArtifactParent", "Card", "CardFormat", "CardType", "Kind", "Metadata", "new"]

src/flyte/artifacts/_metadata.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@
2727
Kind = Literal["model", "data", "generic"]
2828

2929

30+
@dataclass(frozen=True, kw_only=True)
31+
class ArtifactParent:
32+
"""One parent edge for an artifact version's lineage (`Metadata.parents`).
33+
34+
Every field except `version` defaults to "inherit from the child being
35+
published": an empty `name` means a same-name parent (an earlier version of
36+
this artifact), and empty scope fields inherit the child's
37+
org/project/domain. A bare version string is accepted anywhere an
38+
`ArtifactParent` is — it is shorthand for `ArtifactParent(version=...)`.
39+
40+
Parents are stored as given and never resolved: a parent that hasn't been
41+
(or never will be) published is legal, like a git remote missing commits
42+
that were never pushed. Only a direct self-reference is rejected by the
43+
service.
44+
"""
45+
46+
version: str
47+
name: Optional[str] = None
48+
project: Optional[str] = None
49+
domain: Optional[str] = None
50+
org: Optional[str] = None
51+
52+
3053
@dataclass(frozen=True, kw_only=True)
3154
class Metadata:
3255
"""Structured metadata for Flyte artifacts."""
@@ -41,6 +64,18 @@ class Metadata:
4164
#: serialization time; an explicit `attrs["kind"]` wins, so a caller who
4265
#: sets the key by hand is never silently overridden.
4366
kind: Optional[Kind] = None
67+
#: Lineage: the artifact versions this version derives from, ordered with
68+
#: the primary parent first (git-style merge lineage; up to 32). Each entry
69+
#: is an `ArtifactParent` or a bare version string (shorthand for a
70+
#: same-name parent).
71+
parents: Optional[Tuple[typing.Union[str, ArtifactParent], ...]] = None
72+
#: When no explicit `version` is given, publish under the content hash the
73+
#: type transformer stamped on the literal (`Literal.hash`) instead of the
74+
#: backend's run-action-attempt default. Opt-in: content-addressed versions
75+
#: make re-publishing identical content idempotent, but they also mean
76+
#: unchanged content produces NO new version — only types whose literals
77+
#: carry a meaningful content hash should set this.
78+
version_from_content: bool = False
4479

4580
@classmethod
4681
def create_model_metadata(
@@ -101,6 +136,31 @@ def resolve_attrs(md: Metadata) -> dict[str, str]:
101136
return attrs
102137

103138

139+
def parents_to_pb2(
140+
parents: Optional[typing.Sequence[typing.Union[str, ArtifactParent]]],
141+
) -> list[artifact_id_pb2.ArtifactVersionId]:
142+
"""
143+
Serialize parent edges for the wire (`ArtifactSpec.parent_artifacts` /
144+
`ProducedArtifact.parent_artifacts`). A bare string becomes a keyless
145+
entry — the service inherits the child's name and scope for empty key
146+
fields — and an `ArtifactParent` carries a key only when it overrides
147+
something, keeping the common same-name case terse on the wire.
148+
"""
149+
out: list[artifact_id_pb2.ArtifactVersionId] = []
150+
for entry in parents or ():
151+
parent = ArtifactParent(version=entry) if isinstance(entry, str) else entry
152+
key = None
153+
if parent.name or parent.project or parent.domain or parent.org:
154+
key = artifact_id_pb2.ArtifactKey(
155+
org=parent.org or "",
156+
project=parent.project or "",
157+
domain=parent.domain or "",
158+
name=parent.name or "",
159+
)
160+
out.append(artifact_id_pb2.ArtifactVersionId(key=key, version=parent.version))
161+
return out
162+
163+
104164
def to_produced_artifact(
105165
md: Metadata,
106166
*,
@@ -127,4 +187,5 @@ def to_produced_artifact(
127187
version=md.version or "",
128188
info=info,
129189
type=literal_type,
190+
parent_artifacts=parents_to_pb2(md.parents) or None,
130191
)

src/flyte/artifacts/_wrapper.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,23 @@ def __contains__(self, item):
108108
def ensure_artifactable(obj: Any) -> None:
109109
"""
110110
Validate that a value is allowed to be an artifact. Artifacts are offloaded
111-
assets only — flyte.io File, Dir, or DataFrame. Everything else (primitives,
112-
bytes, dataclasses, pydantic models, arbitrary objects) raises TypeError.
111+
assets only — flyte.io File, Dir, or DataFrame, plus any type that opts in
112+
by exposing the artifact-metadata protocol (`get_flyte_metadata() ->
113+
Metadata | None`, e.g. plugin-provided volumes). Everything else
114+
(primitives, bytes, dataclasses, pydantic models, arbitrary objects)
115+
raises TypeError.
113116
"""
114117
from flyte.io import DataFrame, Dir, File
115118

116-
if not isinstance(obj, (File, Dir, DataFrame)):
117-
raise TypeError(
118-
f"values of type {type(obj).__name__!r} cannot be artifacts; artifacts are offloaded "
119-
"assets: flyte.io.File, flyte.io.Dir, or flyte.io.DataFrame "
120-
"(wrap a raw dataframe with DataFrame.from_df())"
121-
)
119+
if isinstance(obj, (File, Dir, DataFrame)):
120+
return
121+
if not isinstance(obj, type) and callable(getattr(obj, "get_flyte_metadata", None)):
122+
return
123+
raise TypeError(
124+
f"values of type {type(obj).__name__!r} cannot be artifacts; artifacts are offloaded "
125+
"assets: flyte.io.File, flyte.io.Dir, or flyte.io.DataFrame "
126+
"(wrap a raw dataframe with DataFrame.from_df())"
127+
)
122128

123129

124130
def raise_if_nested_wrapper(obj: Any, _depth: int = 0) -> None:

src/flyte/remote/_artifact.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from flyte._initialize import ensure_client, get_client, get_init_config
1414
from flyte.artifacts._card import Card as CoreCard
15-
from flyte.artifacts._metadata import KIND_KEY, Kind, Metadata, resolve_attrs
15+
from flyte.artifacts._metadata import KIND_KEY, ArtifactParent, Kind, Metadata, parents_to_pb2, resolve_attrs
1616
from flyte.artifacts._wrapper import ArtifactWrapper, ensure_artifactable
1717
from flyte.remote._common import ToJSONMixin
1818
from flyte.syncify import syncify
@@ -221,6 +221,7 @@ async def create(
221221
project: str | None = None,
222222
domain: str | None = None,
223223
external_ref: str | None = None,
224+
parents: Sequence[str | ArtifactParent] | None = None,
224225
) -> Artifact:
225226
"""
226227
Publish an artifact from the local machine.
@@ -248,6 +249,11 @@ async def create(
248249
model id, dataset id, ...) recorded as the artifact's source. When omitted
249250
and called from inside a running task, the producing task action is
250251
recorded automatically instead.
252+
parents: Lineage — the artifact versions this one derives from, ordered
253+
with the primary parent first. Each entry is an
254+
`flyte.artifacts.ArtifactParent` or a bare version string (a same-name
255+
parent). Stored as given, never resolved; a not-yet-published parent
256+
is legal.
251257
252258
Returns:
253259
The published Artifact.
@@ -258,16 +264,25 @@ async def create(
258264
cfg = get_init_config()
259265

260266
obj = value
267+
md: Metadata | None = None
261268
if type(value) is ArtifactWrapper:
262-
md: Metadata = value.get_flyte_metadata()
269+
md = value.get_flyte_metadata()
263270
obj = value._obj
271+
elif callable(getattr(value, "get_flyte_metadata", None)):
272+
# The artifact-metadata protocol (see ensure_artifactable): a value
273+
# that carries its own metadata seeds the same defaults a wrapper
274+
# does. May legitimately return None (the type participates in the
275+
# protocol but this instance declares nothing).
276+
md = value.get_flyte_metadata()
277+
if md is not None:
264278
name = name or md.name
265279
version = version or md.version
266280
description = description if description is not None else md.description
267281
# resolve_attrs folds the wrapper's kind= into attrs; reading md.attrs
268282
# directly would drop it for values wrapped by flyte.artifacts.new().
269283
attrs = attrs if attrs is not None else resolve_attrs(md)
270284
card = card if card is not None else md.card
285+
parents = parents if parents is not None else md.parents
271286
if kind is not None:
272287
# Same precedence as Metadata: an explicit reserved key already in attrs
273288
# is deliberate and wins.
@@ -282,6 +297,11 @@ async def create(
282297
pt = python_type or type(obj)
283298
lt = TypeEngine.to_literal_type(pt)
284299
lit = await TypeEngine.to_literal(obj, pt, lt)
300+
# Content-addressed default version (same rule as the declarative path in
301+
# convert.py): metadata opted in and the transformer stamped a content
302+
# hash — use it, so republishing identical content is idempotent.
303+
if not version and md is not None and md.version_from_content and lit.hash:
304+
version = lit.hash
285305

286306
if external_ref is not None:
287307
source: artifact_pb2.ArtifactSource | None = artifact_pb2.ArtifactSource(external_ref=external_ref)
@@ -307,6 +327,7 @@ async def create(
307327
card=_card_to_pb2(card),
308328
),
309329
source=source,
330+
parent_artifacts=parents_to_pb2(parents) or None,
310331
),
311332
)
312333
resp = await get_client().artifact_service.create_artifact(request)

tests/flyte/remote/test_artifact.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,3 +493,70 @@ async def test_limit_stops_early(self):
493493
got = [g async for g in Artifact.list_names.aio(limit=2)]
494494

495495
assert len(got) == 2
496+
497+
498+
class TestCreateParents:
499+
"""`create(parents=...)` and metadata-protocol seeding land on
500+
`ArtifactSpec.parent_artifacts` with the same shapes as declarations."""
501+
502+
@staticmethod
503+
def _client():
504+
client = MagicMock()
505+
506+
async def _create(req):
507+
return artifact_service_pb2.CreateArtifactResponse(artifact=await _stored_artifact("v"))
508+
509+
client.artifact_service.create_artifact = AsyncMock(side_effect=_create)
510+
return client
511+
512+
@pytest.mark.asyncio
513+
async def test_explicit_parents(self):
514+
client = self._client()
515+
p1, p2, p3 = _patched(client)
516+
with p1, p2, p3:
517+
await Artifact.create.aio(
518+
_payload(),
519+
name="child",
520+
version="v2",
521+
parents=["v1", artifacts.ArtifactParent(version="v0", name="base")],
522+
)
523+
req = client.artifact_service.create_artifact.await_args[0][0]
524+
assert [p.version for p in req.spec.parent_artifacts] == ["v1", "v0"]
525+
assert not req.spec.parent_artifacts[0].HasField("key")
526+
assert req.spec.parent_artifacts[1].key.name == "base"
527+
528+
@pytest.mark.asyncio
529+
async def test_no_parents_leaves_field_empty(self):
530+
client = self._client()
531+
p1, p2, p3 = _patched(client)
532+
with p1, p2, p3:
533+
await Artifact.create.aio(_payload(), name="solo", version="v1")
534+
req = client.artifact_service.create_artifact.await_args[0][0]
535+
assert len(req.spec.parent_artifacts) == 0
536+
537+
@pytest.mark.asyncio
538+
async def test_wrapper_metadata_seeds_parents(self):
539+
client = self._client()
540+
md = artifacts.Metadata(name="wrapped", version="2.0", parents=("v1",))
541+
p1, p2, p3 = _patched(client)
542+
with p1, p2, p3:
543+
await Artifact.create.aio(artifacts.new(_payload(), md))
544+
req = client.artifact_service.create_artifact.await_args[0][0]
545+
assert [p.version for p in req.spec.parent_artifacts] == ["v1"]
546+
547+
@pytest.mark.asyncio
548+
async def test_duck_metadata_seeds_and_hash_versions(self):
549+
# A protocol-carrying value (no wrapper) seeds name/parents, and
550+
# version_from_content resolves to the literal's content hash.
551+
class DuckFile(File):
552+
def get_flyte_metadata(self):
553+
return artifacts.Metadata(name="ducked", parents=("v1",), version_from_content=True)
554+
555+
client = self._client()
556+
p1, p2, p3 = _patched(client)
557+
with p1, p2, p3:
558+
await Artifact.create.aio(DuckFile(path="s3://bucket/w.pt", hash="cafe"), python_type=File)
559+
req = client.artifact_service.create_artifact.await_args[0][0]
560+
assert req.artifact_id.name.name == "ducked"
561+
assert req.artifact_id.version == "cafe"
562+
assert [p.version for p in req.spec.parent_artifacts] == ["v1"]

0 commit comments

Comments
 (0)