Skip to content

Commit d19cec5

Browse files
committed
[card] current.card.copy
- cli command to copy cards from other tasks - Exposing the interface in `current.card` so that cards can be copied over during runtime. This also allows users the flexibility to select which cards to copy during runtime. - useful for the cases when users need to showcase the model card in downstream steps without having to re-render the card everytime. - Example flow: ```python from metaflow import FlowSpec, step, card, current class CardCopyTestFlow(FlowSpec): """ A flow that demonstrates the card copy functionality. """ @card(type="blank") @step def start(self): """ Create a card in the start step. """ from metaflow.cards import Markdown current.card.append(Markdown("# Original Card")) current.card.append(Markdown("This card was created in the start step.")) self.pathspec = current.pathspec self.next(self.copy_card) @card(type="blank") @step def copy_card(self): """ Copy the card from the start step. """ from metaflow.cards import Markdown # First, create a new card for this step current.card.append(Markdown("# Destination Card")) current.card.append(Markdown("This card was created in the copy_card step.")) # Now, copy the card from the start step # Get the pathspec for the start step # Copy the card from the start step success = current.card.copy(self.pathspec) if success: current.card.append(Markdown("## Card Copy Success")) current.card.append(Markdown(f"Successfully copied card from {self.pathspec}")) else: current.card.append(Markdown("## Card Copy Failed")) current.card.append(Markdown(f"Failed to copy card from {self.pathspec}")) self.next(self.end) @step def end(self): """ End the flow. """ print("Flow completed successfully!") if __name__ == "__main__": CardCopyTestFlow() ```
1 parent 40ca1d9 commit d19cec5

File tree

2 files changed

+196
-5
lines changed

2 files changed

+196
-5
lines changed

metaflow/plugins/cards/card_cli.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def card_read_options_and_arguments(func):
360360
default=None,
361361
show_default=True,
362362
type=str,
363-
help="Hash of the stored HTML",
363+
help="UUID of the card",
364364
)
365365
@click.option(
366366
"--type",
@@ -1094,3 +1094,128 @@ def _get_run_object(obj, run_id, user_namespace):
10941094

10951095
obj.echo("Using run-id %s" % run.pathspec, fg="blue", bold=False)
10961096
return run, follow_new_runs, None
1097+
1098+
1099+
@card.command(help="Copy cards from one pathspec to another")
1100+
@click.argument("origin_pathspec", type=str)
1101+
@click.argument("destination_pathspec", type=str)
1102+
@card_read_options_and_arguments
1103+
@click.pass_context
1104+
def copy(
1105+
ctx,
1106+
origin_pathspec,
1107+
destination_pathspec,
1108+
hash=None,
1109+
type=None,
1110+
id=None,
1111+
follow_resumed=True,
1112+
):
1113+
"""
1114+
Copy cards from one pathspec to another.
1115+
1116+
Args:
1117+
origin_pathspec: Pathspec of the card to copy from.
1118+
destination_pathspec: Pathspec of the card to copy to.
1119+
hash: Hash of the card to copy.
1120+
type: Type of the card to copy.
1121+
id: ID of the card to copy.
1122+
follow_resumed: Whether to follow the origin-task-id of resumed tasks to seek cards stored for resumed tasks.
1123+
"""
1124+
from metaflow import Task
1125+
from metaflow.exception import MetaflowNotFound
1126+
1127+
# Resolve the origin task
1128+
try:
1129+
origin_task = Task(origin_pathspec, _namespace_check=False)
1130+
if follow_resumed:
1131+
origin_taskpathspec = resumed_info(origin_task)
1132+
if origin_taskpathspec:
1133+
origin_task = Task(origin_taskpathspec, _namespace_check=False)
1134+
1135+
except MetaflowNotFound as ex:
1136+
click.echo(f"Error resolving origin pathspec: {str(ex)}", err=True)
1137+
return exit(1)
1138+
1139+
# Resolve the destination task
1140+
try:
1141+
dest_task = Task(destination_pathspec, _namespace_check=False)
1142+
except MetaflowNotFound as ex:
1143+
click.echo(f"Error resolving destination pathspec: {str(ex)}", err=True)
1144+
return exit(1)
1145+
1146+
flow_datastore = ctx.obj.flow_datastore
1147+
1148+
# Get the card paths from origin
1149+
card_paths, card_datastore = resolve_paths_from_task(
1150+
flow_datastore,
1151+
pathspec=origin_task.pathspec,
1152+
type=type,
1153+
hash=hash,
1154+
card_id=id,
1155+
)
1156+
1157+
if not card_paths:
1158+
click.echo(f"No cards found for pathspec: {origin_pathspec}", err=True)
1159+
return exit(1)
1160+
1161+
# Create destination card datastore
1162+
# Get the card paths from origin
1163+
dest_card_datastore = CardDatastore(
1164+
flow_datastore,
1165+
pathspec=dest_task.pathspec,
1166+
)
1167+
1168+
# Copy each card
1169+
copied_count = 0
1170+
for card_path in card_paths:
1171+
try:
1172+
# Get card info
1173+
card_info = card_datastore.info_from_path(card_path)
1174+
1175+
# Get card HTML content
1176+
card_html = card_datastore.get_card_html(card_path)
1177+
1178+
# Save to destination
1179+
dest_card_datastore.save_card(
1180+
uuid=card_info.hash,
1181+
card_type=card_info.type,
1182+
card_html=card_html,
1183+
card_id=card_info.id,
1184+
)
1185+
1186+
# Try to copy the data file if it exists
1187+
try:
1188+
data_paths = card_datastore.extract_data_paths(
1189+
card_type=card_info.type,
1190+
card_hash=card_info.hash,
1191+
card_id=card_info.id,
1192+
)
1193+
1194+
if data_paths:
1195+
data_path = data_paths[0]
1196+
card_data = card_datastore.get_card_data(data_path)
1197+
if card_data is not None:
1198+
dest_card_datastore.save_data(
1199+
uuid=card_info.hash,
1200+
card_type=card_info.type,
1201+
json_data=card_data,
1202+
card_id=card_info.id,
1203+
)
1204+
except Exception as e:
1205+
# If data copy fails, we still continue with the HTML copy
1206+
click.echo(
1207+
f"Warning: Could not copy data for card {card_info.type}/{card_info.id}: {str(e)}",
1208+
err=True,
1209+
)
1210+
else:
1211+
copied_count += 1
1212+
click.echo(
1213+
f"Copied card {card_info.type}/{card_info.id or ''} from {origin_pathspec} to {destination_pathspec}",
1214+
err=True,
1215+
)
1216+
1217+
except Exception as e:
1218+
click.echo(f"Error copying card {card_path}: {str(e)}", err=True)
1219+
1220+
click.echo(f"Successfully copied {copied_count} card(s)")
1221+
return exit(0)

metaflow/plugins/cards/component_serializer.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -803,10 +803,76 @@ def clear(self):
803803

804804
def refresh(self, *args, **kwargs):
805805
# FIXME: document
806-
if self._default_editable_card is not None:
807-
self._card_component_store[self._default_editable_card].refresh(
808-
*args, **kwargs
809-
)
806+
if self._default_editable_card is None:
807+
return
808+
self._card_component_store[self._default_editable_card].refresh(*args, **kwargs)
809+
810+
def copy(self, source_pathspec, id=None, type=None, follow_resumed=True):
811+
"""
812+
Copy a card from another pathspec to the current task. Like all other card related operations,
813+
this will be best effort. If the card is not found or cannot be copied, this function will return
814+
False but won't crash the metaflow task runtime.
815+
816+
Parameters
817+
----------
818+
source_pathspec : str
819+
Pathspec of the card to copy from.
820+
id : str, optional
821+
ID of the card to copy.
822+
type : str, optional
823+
Type of the card to copy.
824+
follow_resumed : bool, default: True
825+
Whether to follow the origin-task-id of resumed tasks to seek cards stored for resumed tasks.
826+
827+
Returns
828+
-------
829+
bool
830+
True if the card was copied successfully, False otherwise.
831+
"""
832+
from metaflow.metaflow_current import current
833+
import subprocess
834+
import sys
835+
import os
836+
837+
# Get the current pathspec as the destination
838+
if not current.pathspec:
839+
return False
840+
841+
destination_pathspec = current.pathspec
842+
843+
# Build the command
844+
executable = sys.executable
845+
cmd = [
846+
executable,
847+
sys.argv[0],
848+
]
849+
850+
cmd += self._card_creator._top_level_options + [
851+
"card",
852+
"copy",
853+
source_pathspec,
854+
destination_pathspec,
855+
]
856+
857+
# Add optional arguments
858+
if id:
859+
cmd.extend(["--id", id])
860+
if type:
861+
cmd.extend(["--type", type])
862+
if not follow_resumed:
863+
cmd.append("--no-follow-resumed")
864+
865+
# Execute the command following the pattern in card_creator.py
866+
env = os.environ.copy()
867+
try:
868+
subprocess.run(cmd, env=env, stderr=subprocess.PIPE, check=True)
869+
return True
870+
except subprocess.CalledProcessError as e:
871+
self._warning("Error copying card: %s" % e.stderr.decode("utf-8"))
872+
return False
873+
except Exception as e:
874+
self._warning("Error copying card: %s" % e)
875+
return False
810876

811877
def _get_latest_data(self, card_uuid, final=False, mode=None):
812878
"""

0 commit comments

Comments
 (0)