Skip to content

Commit 89537a7

Browse files
authored
studio: add subdir to live metrics post messages to support live experiments in monorepos (#10303)
1 parent c72e1fb commit 89537a7

File tree

6 files changed

+123
-71
lines changed

6 files changed

+123
-71
lines changed

dvc/repo/__init__.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
)
1414
from dvc.ignore import DvcIgnoreFilter
1515
from dvc.log import logger
16-
from dvc.utils import as_posix
1716
from dvc.utils.objects import cached_property
1817

1918
if TYPE_CHECKING:
@@ -351,16 +350,6 @@ def fs(self, fs: "FileSystem"):
351350
# fs.
352351
self._reset()
353352

354-
@property
355-
def subrepo_relpath(self) -> str:
356-
from dvc.fs import GitFileSystem
357-
358-
scm_root_dir = "/" if isinstance(self.fs, GitFileSystem) else self.scm.root_dir
359-
360-
relpath = as_posix(self.fs.relpath(self.root_dir, scm_root_dir))
361-
362-
return "" if relpath == "." else relpath
363-
364353
@property
365354
def data_index(self) -> "DataIndex":
366355
from dvc_data.index import DataIndex

dvc/repo/experiments/executor/base.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
from dvc.stage.serialize import to_lockfile
2424
from dvc.utils import dict_sha256, env2bool, relpath
2525
from dvc.utils.fs import remove
26-
from dvc.utils.studio import env_to_config
26+
from dvc.utils.studio import (
27+
env_to_config,
28+
get_subrepo_relpath,
29+
)
2730

2831
if TYPE_CHECKING:
2932
from queue import Queue
@@ -624,6 +627,7 @@ def _repro_dvc(
624627
params=to_studio_params(dvc.params.show()),
625628
dvc_studio_config=dvc_studio_config,
626629
message=message,
630+
subdir=get_subrepo_relpath(dvc),
627631
)
628632
logger.debug("Running repro in '%s'", os.getcwd())
629633
yield dvc

dvc/utils/studio.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@
1212
DVC_STUDIO_URL,
1313
)
1414
from dvc.log import logger
15+
from dvc.utils import as_posix
1516

1617
if TYPE_CHECKING:
1718
from requests import Response
1819

20+
from dvc.repo import Repo
21+
22+
1923
logger = logger.getChild(__name__)
2024

2125
STUDIO_URL = "https://studio.iterative.ai"
@@ -111,3 +115,13 @@ def env_to_config(env: dict[str, Any]) -> dict[str, Any]:
111115
if DVC_STUDIO_URL in env:
112116
config["url"] = env[DVC_STUDIO_URL]
113117
return config
118+
119+
120+
def get_subrepo_relpath(repo: "Repo") -> str:
121+
from dvc.fs import GitFileSystem
122+
123+
scm_root_dir = "/" if isinstance(repo.fs, GitFileSystem) else repo.scm.root_dir
124+
125+
relpath = as_posix(repo.fs.relpath(repo.root_dir, scm_root_dir))
126+
127+
return "" if relpath == "." else relpath

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ dependencies = [
3838
"dvc-data>=3.13,<3.14",
3939
"dvc-http>=2.29.0",
4040
"dvc-render>=1.0.1,<2",
41-
"dvc-studio-client>=0.19,<1",
41+
"dvc-studio-client>=0.20,<1",
4242
"dvc-task>=0.3.0,<1",
4343
"flatten_dict<1,>=0.4.1",
4444
# https://github.com/iterative/dvc/issues/9654

tests/integration/test_studio_live_experiments.py

Lines changed: 103 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
DVC_STUDIO_TOKEN,
99
DVC_STUDIO_URL,
1010
)
11+
from dvc.repo import Repo
12+
from dvc.testing.scripts import COPY_SCRIPT
13+
from dvc.utils.studio import get_subrepo_relpath
1114

1215

16+
@pytest.mark.studio
1317
@pytest.mark.parametrize("tmp", [True, False])
1418
@pytest.mark.parametrize("offline", [True, False])
1519
def test_post_to_studio(
@@ -66,10 +70,45 @@ def test_post_to_studio(
6670
}
6771

6872

73+
@pytest.mark.studio
6974
@pytest.mark.parametrize("tmp", [True, False])
70-
def test_post_to_studio_custom_message(
71-
tmp_dir, dvc, scm, exp_stage, mocker, monkeypatch, tmp
72-
):
75+
def test_post_to_studio_subdir(tmp_dir, scm, mocker, monkeypatch, tmp):
76+
live_exp_subdir = "project_a"
77+
78+
tmp_dir.scm_gen(
79+
{
80+
live_exp_subdir: {
81+
"params.yaml": "foo: 1",
82+
"metrics.yaml": "foo: 1",
83+
"copy.py": COPY_SCRIPT.encode("utf-8"),
84+
},
85+
},
86+
commit="git init",
87+
)
88+
89+
project_a_dvc = Repo.init(tmp_dir / live_exp_subdir, subdir=True)
90+
with monkeypatch.context() as m:
91+
m.chdir(project_a_dvc.root_dir)
92+
93+
exp_stage = project_a_dvc.run(
94+
cmd="python copy.py params.yaml metrics.yaml",
95+
metrics_no_cache=["metrics.yaml"],
96+
params=["foo"],
97+
name="copy-file",
98+
)
99+
100+
scm.add(
101+
[
102+
".gitignore",
103+
"copy.py",
104+
"dvc.lock",
105+
"dvc.yaml",
106+
"metrics.yaml",
107+
"params.yaml",
108+
]
109+
)
110+
scm.commit("dvc init project_a")
111+
73112
valid_response = mocker.MagicMock()
74113
valid_response.status_code = 200
75114
mocked_post = mocker.patch("requests.post", return_value=valid_response)
@@ -79,12 +118,16 @@ def test_post_to_studio_custom_message(
79118
monkeypatch.setenv(env.STUDIO_TOKEN, "STUDIO_TOKEN")
80119

81120
baseline_sha = scm.get_rev()
82-
exp_rev = first(
83-
dvc.experiments.run(
84-
exp_stage.addressing, params=["foo=1"], tmp_dir=tmp, message="foo"
121+
with monkeypatch.context() as m:
122+
m.chdir(project_a_dvc.root_dir)
123+
exp_rev = first(
124+
project_a_dvc.experiments.run(
125+
exp_stage.addressing, params=["foo=24"], tmp_dir=tmp
126+
)
85127
)
86-
)
87-
name = dvc.experiments.get_exact_name([exp_rev])[exp_rev]
128+
129+
name = project_a_dvc.experiments.get_exact_name([exp_rev])[exp_rev]
130+
project_a_dvc.close()
88131
assert mocked_post.call_count == 2
89132

90133
start_call = mocked_post.call_args_list[0]
@@ -94,7 +137,57 @@ def test_post_to_studio_custom_message(
94137
"repo_url": "STUDIO_REPO_URL",
95138
"baseline_sha": baseline_sha,
96139
"name": name,
97-
"params": {"params.yaml": {"foo": 1}},
140+
"params": {"params.yaml": {"foo": 24}},
141+
"subdir": live_exp_subdir,
98142
"client": "dvc",
99-
"message": "foo",
100143
}
144+
145+
146+
@pytest.mark.studio
147+
def test_monorepo_relpath(tmp_dir, scm):
148+
from dvc.repo.destroy import destroy
149+
150+
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
151+
152+
non_monorepo = Repo.init(tmp_dir)
153+
assert get_subrepo_relpath(non_monorepo) == ""
154+
155+
destroy(non_monorepo)
156+
157+
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
158+
159+
assert get_subrepo_relpath(monorepo_project_a) == "project_a"
160+
161+
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
162+
163+
assert get_subrepo_relpath(monorepo_project_b) == "subdir/project_b"
164+
165+
166+
@pytest.mark.studio
167+
def test_virtual_monorepo_relpath(tmp_dir, scm):
168+
from dvc.fs.git import GitFileSystem
169+
from dvc.repo.destroy import destroy
170+
171+
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
172+
scm.commit("initial commit")
173+
gfs = GitFileSystem(scm=scm, rev="master")
174+
175+
non_monorepo = Repo.init(tmp_dir)
176+
non_monorepo.fs = gfs
177+
non_monorepo.root_dir = "/"
178+
179+
assert get_subrepo_relpath(non_monorepo) == ""
180+
181+
destroy(non_monorepo)
182+
183+
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
184+
monorepo_project_a.fs = gfs
185+
monorepo_project_a.root_dir = "/project_a"
186+
187+
assert get_subrepo_relpath(monorepo_project_a) == "project_a"
188+
189+
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
190+
monorepo_project_b.fs = gfs
191+
monorepo_project_b.root_dir = "/subdir/project_b"
192+
193+
assert get_subrepo_relpath(monorepo_project_b) == "subdir/project_b"

tests/unit/repo/test_repo.py

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -135,51 +135,3 @@ def test_dynamic_cache_initialization(tmp_dir, scm):
135135
dvc.close()
136136

137137
Repo(str(tmp_dir)).close()
138-
139-
140-
def test_monorepo_relpath(tmp_dir, scm):
141-
from dvc.repo.destroy import destroy
142-
143-
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
144-
145-
non_monorepo = Repo.init(tmp_dir)
146-
assert non_monorepo.subrepo_relpath == ""
147-
148-
destroy(non_monorepo)
149-
150-
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
151-
152-
assert monorepo_project_a.subrepo_relpath == "project_a"
153-
154-
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
155-
156-
assert monorepo_project_b.subrepo_relpath == "subdir/project_b"
157-
158-
159-
def test_virtual_monorepo_relpath(tmp_dir, scm):
160-
from dvc.fs.git import GitFileSystem
161-
from dvc.repo.destroy import destroy
162-
163-
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
164-
scm.commit("initial commit")
165-
gfs = GitFileSystem(scm=scm, rev="master")
166-
167-
non_monorepo = Repo.init(tmp_dir)
168-
non_monorepo.fs = gfs
169-
non_monorepo.root_dir = "/"
170-
171-
assert non_monorepo.subrepo_relpath == ""
172-
173-
destroy(non_monorepo)
174-
175-
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
176-
monorepo_project_a.fs = gfs
177-
monorepo_project_a.root_dir = "/project_a"
178-
179-
assert monorepo_project_a.subrepo_relpath == "project_a"
180-
181-
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
182-
monorepo_project_b.fs = gfs
183-
monorepo_project_b.root_dir = "/subdir/project_b"
184-
185-
assert monorepo_project_b.subrepo_relpath == "subdir/project_b"

0 commit comments

Comments
 (0)