Compare commits

...
31 Commits
Author SHA1 Message Date
cd1e228e55 experiments: auto push experiments (#10323)
* add auto_push for experiment on run and on save.
add config.exp.auto_push and config.exp.git_remote

* add func tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* separate auto_push behaviour out of commit

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Saugat Pachhai (सौगात) <[email protected]>
2024-03-04 14:20:56 +05:45
skshetry 3b2e031e60 replace flaky with pytest-rerunfailures (#10335)
* Revert "limit pytest version to <8.1 (#10334)"

This reverts commit bc95b650a4.

* replace flaky with pytest-rerunfailures

`flaky` project seems to be unmaintained (see box/flaky#192).
`pytest-rerunfailures` seems to be maintained under `pytest-dev`
and tests 5 minor releases of pytest.

Note that this plugin is not compatible with `flaky` plugin.
You will have to uninstall it if it exists.
2024-03-04 11:12:45 +05:45
skshetry e5ee82bc99 support newer pytest-docker (#10336) 2024-03-04 11:09:56 +05:45
skshetry bc95b650a4 limit pytest version to <8.1 (#10334)
Change in pytest==8.1.0 breaks `flaky` plugin.

See https://github.com/box/flaky/issues/198.
2024-03-04 10:31:58 +05:45
skshetry 257ea5f4cb tests: use default branch for rev (#10332)
`isolate` fixture don't run in plugin's tests.
So the tests will fail to run if you have `init.defaultBranch`
something other than `master`.
2024-03-01 11:16:59 +05:45
AlexandreKempf 5f7acd4cbe add subdir test to integration tests 2024-02-29 13:50:30 -05:00
AlexandreKempf 13325ea89b use explicit code to fix copy bug 2024-02-29 13:50:30 -05:00
AlexandreKempf fd71434df5 fix test 2024-02-29 13:50:30 -05:00
AlexandreKempf 2ff4ff2daa implement logic for closest parent detection 2024-02-29 13:50:30 -05:00
AlexandreKempf 0ee6d6eba8 add more extensive tests 2024-02-29 13:50:30 -05:00
pre-commit-ci[bot] 7c34370464 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2024-02-29 13:50:30 -05:00
AlexandreKempf b67b254287 add test 2024-02-29 13:50:30 -05:00
AlexandreKempf 212928a1fa fix plots nested configuration 2024-02-29 13:50:30 -05:00
dependabot[bot] 5be23cfd4e build(deps-dev): update dvc-data requirement from <3.14,>=3.13 to >=3.13,<3.15 (#10329)
build(deps-dev): update dvc-data requirement

Updates the requirements on [dvc-data](https://github.com/iterative/dvc-data) to permit the latest version.
- [Release notes](https://github.com/iterative/dvc-data/releases)
- [Commits](https://github.com/iterative/dvc-data/compare/3.13.0...3.14.0)

---
updated-dependencies:
- dependency-name: dvc-data
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-28 19:28:29 +05:45
skshetry 4d3a631e60 fix PathInfo instantiation in Python 3.12 (#10328) 2024-02-28 14:40:56 +05:45
skshetry 491155cdb0 experimental upath implementation for dvcfilesystem (#10302) 2024-02-27 20:08:12 +05:45
skshetry 9bb4501bf0 ds add: use --dvc/--dvcx/--url flags to accept url (#10326)
* ds add: use --dvc/--dvcx/--url flags to accept url

.. instead of relying on `scheme://`

I think this is more simpler from user's perspective, easier to
teach and easier to maintain as we won't have to do url parsing.

I am taking inspiration from `cargo install` has `--git`/`--rev`, etc.
to install from alternate sources.

```console
Options:
      --version <VERSION>     Specify a version to install
      --index <INDEX>         Registry index to install from
      --registry <REGISTRY>   Registry to use
      --git <URL>             Git URL to install the specified crate from
      --branch <BRANCH>       Branch to use when installing from git
      --tag <TAG>             Tag to use when installing from git
      --rev <SHA>             Specific commit to use when installing from git
      --path <PATH>           Filesystem path to local crate to install
      --root <DIR>            Directory to install packages into```
```

The `name` is also changed from an option to an argument.

### Examples

```console
dvc ds add example-get-started --dvc [email protected]:iterative/example-get-started.git
dvc ds add dogs --dvcx dogs
dvc ds add dogs --url s3://bucket/key/path
```

* add --version alias
2024-02-27 20:06:38 +05:45
skshetry 4cb658f96c dataset: provide --rev to track a different dvcx dataset version or git rev (#10325)
* dataset: provide --rev to track a different dvcx dataset version or git rev

* fix message

* add tests; disallow --rev for url datasets
2024-02-27 08:26:07 +05:45
Matt Seddon 9f66897bf7 plots: support x-dict in nested dvc.yaml (#10318) 2024-02-26 19:54:20 +00:00
skshetry 84a1750013 cli: fix typo in help message, replace / with : in scp style url (#10322) 2024-02-26 21:59:28 +05:45
skshetry 6cb37ac27b tests: add tests for datasets (#10321) 2024-02-26 21:45:09 +05:45
skshetry f38490c897 dvc.api for dataset (#10316)
* add support for tracking remote dataset

This adds support for virtually tracking a
dataset such as dvcx dataset, dataset from remote dvc/git
registries, and cloud-versioned remotes.

This PR introduces two different commands under `ds` namespace:
`add` and `update`.

`dvc ds add` command adds the dataset to `dvc.yaml` and tracks
the sources in `dvc.lock` file. Similarly, `dvc ds update`
updates the sources in `dvc.lock` file.

Example Usage:
```console
dvc ds add --name dogs --url dvcx://dogs # tracking dvcx dataset
# tracking dvc/git registries
dvc ds add --name example --url dvc://[email protected]/iterative/example.git
# cloud-versioned-remotes
dvc ds add --name versioning --url s3://cloud-versioning-demo
```

To update, specify the name.
```console
dvc ds update <name>
```

`dvc ds add` freezes (in the traditional sense of `dvc import/import-url`).
It keeps a "specification" of the dataset in `dvc.yaml` and also freezes
information about the dataset in the `dvc.lock` file. They are kept
inside `datasets` section in both `dvc.yaml` and `dvc.lock` files.

This metadata is used in the pipelines. You can add a dependency to your
stage using `ds://` scheme, followed by the name of the dataset in
`dvc.yaml` file. As it is used in pipelines, the `name` of the dataset
has to be unique in the repository. Different dataset of same names
are not allowed. On `dvc repro`, `dvc` copies the frozen information
about the particular dataset into the `deps` field for the stage in
`dvc.lock`. On successive invocation, `dvc` will compare the information
from the deps field of the lock with the frozen information in the
`datasets` section and decides whether to rerun or not.

As the dataset is frozen, `dvc repro` won't rerun until the dataset is
updated via `dvc update`.

Here are some examples for how `dvc.yaml` looks like:
```yaml
datasets:
- name: dogs
  url: dvcx://dogs
  type: dvcx
- name: example-get-started
  url: [email protected]:iterative/example-get-started.git
  type: dvc
  path: path
- name: dogs2
  url: dvcx://dogs@v2
  type: dvcx
- name: cloud-versioning-demo
  url: s3://cloud-versioning-demo
  type: url
```

```yaml
schema: '2.0'
datasets:
- name: dogs
  url: dvcx://dogs
  type: dvcx
  version: 3
  created_at: '2023-12-11T10:32:05.942708+00:00'
- name: example-get-started
  url: [email protected]:iterative/example-get-started.git
  type: dvc
  path: path
  rev_lock: df75c16ef61f0772d6e4bb27ba4617b06b4b5398
- name: cloud-versioning-demo
  url: s3://cloud-versioning-demo
  type: url
  meta:
    isdir: true
    size: 323919
    nfiles: 33
  files:
  - relpath: myproject/model.pt
    meta:
      size: 106433
      version_id: 5qrtnhnQ4fBzV73kqqK6pMGhTOzd_IPr
      etag: 3bc0028677ce6fb65bec8090c248b002
# truncated
```

The pipeline stages keep them in `dataset` section of individual deps.

```console
dvc stage add -n train -d ds://dogs python train.py
cat dvc.yaml
```

```yaml
# truncated
stages:
  train:
    cmd: python train.py
    deps:
    - ds://dogs
```

When it is reproduced, the `dvc.lock` will look something like follows:
```yaml
# truncated
stages:
  train:
    cmd: python train.py
    deps:
    - path: ds://dogs
      dataset:
        name: dogs
        url: dvcx://dogs
        type: dvcx
        version: 3
        created_at: '2023-12-11T10:32:05.942708+00:00'
```

* remodeling

* remove non-cloud-versioned remotes

* improve deserializing; handle invalidation

* dvc.api for dataset

* expose the API in the top-level
2024-02-26 21:29:16 +05:45
skshetry 34d80fbfce add support for tracking remote dataset (#10287)
* add support for tracking remote dataset

This adds support for virtually tracking a
dataset such as dvcx dataset, dataset from remote dvc/git
registries, and cloud-versioned remotes.

This PR introduces two different commands under `ds` namespace:
`add` and `update`.

`dvc ds add` command adds the dataset to `dvc.yaml` and tracks
the sources in `dvc.lock` file. Similarly, `dvc ds update`
updates the sources in `dvc.lock` file.

Example Usage:
```console
dvc ds add --name dogs --url dvcx://dogs # tracking dvcx dataset
# tracking dvc/git registries
dvc ds add --name example --url dvc://[email protected]/iterative/example.git
# cloud-versioned-remotes
dvc ds add --name versioning --url s3://cloud-versioning-demo
```

To update, specify the name.
```console
dvc ds update <name>
```

`dvc ds add` freezes (in the traditional sense of `dvc import/import-url`).
It keeps a "specification" of the dataset in `dvc.yaml` and also freezes
information about the dataset in the `dvc.lock` file. They are kept
inside `datasets` section in both `dvc.yaml` and `dvc.lock` files.

This metadata is used in the pipelines. You can add a dependency to your
stage using `ds://` scheme, followed by the name of the dataset in
`dvc.yaml` file. As it is used in pipelines, the `name` of the dataset
has to be unique in the repository. Different dataset of same names
are not allowed. On `dvc repro`, `dvc` copies the frozen information
about the particular dataset into the `deps` field for the stage in
`dvc.lock`. On successive invocation, `dvc` will compare the information
from the deps field of the lock with the frozen information in the
`datasets` section and decides whether to rerun or not.

As the dataset is frozen, `dvc repro` won't rerun until the dataset is
updated via `dvc update`.

Here are some examples for how `dvc.yaml` looks like:
```yaml
datasets:
- name: dogs
  url: dvcx://dogs
  type: dvcx
- name: example-get-started
  url: [email protected]:iterative/example-get-started.git
  type: dvc
  path: path
- name: dogs2
  url: dvcx://dogs@v2
  type: dvcx
- name: cloud-versioning-demo
  url: s3://cloud-versioning-demo
  type: url
```

```yaml
schema: '2.0'
datasets:
- name: dogs
  url: dvcx://dogs
  type: dvcx
  version: 3
  created_at: '2023-12-11T10:32:05.942708+00:00'
- name: example-get-started
  url: [email protected]:iterative/example-get-started.git
  type: dvc
  path: path
  rev_lock: df75c16ef61f0772d6e4bb27ba4617b06b4b5398
- name: cloud-versioning-demo
  url: s3://cloud-versioning-demo
  type: url
  meta:
    isdir: true
    size: 323919
    nfiles: 33
  files:
  - relpath: myproject/model.pt
    meta:
      size: 106433
      version_id: 5qrtnhnQ4fBzV73kqqK6pMGhTOzd_IPr
      etag: 3bc0028677ce6fb65bec8090c248b002
# truncated
```

The pipeline stages keep them in `dataset` section of individual deps.

```console
dvc stage add -n train -d ds://dogs python train.py
cat dvc.yaml
```

```yaml
# truncated
stages:
  train:
    cmd: python train.py
    deps:
    - ds://dogs
```

When it is reproduced, the `dvc.lock` will look something like follows:
```yaml
# truncated
stages:
  train:
    cmd: python train.py
    deps:
    - path: ds://dogs
      dataset:
        name: dogs
        url: dvcx://dogs
        type: dvcx
        version: 3
        created_at: '2023-12-11T10:32:05.942708+00:00'
```

* remodeling

* remove non-cloud-versioned remotes

* improve deserializing; handle invalidation
2024-02-26 21:28:39 +05:45
Matt Seddon 89537a77b5 studio: add subdir to live metrics post messages to support live experiments in monorepos (#10303) 2024-02-21 12:05:31 +11:00
Peter Rowlands c72e1fb0b8 repo.imports: remove unused code 2024-02-20 15:08:07 +09:00
pre-commit-ci[bot] 9fbe5d929d [pre-commit.ci] pre-commit autoupdate (#10310)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.2.1 → v0.2.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.1...v0.2.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-20 09:09:04 +05:45
Ruslan Kuprieiev 0635b0ca15 index: check dep.hash_name for imports 2024-02-19 15:16:57 +02:00
Ruslan Kuprieiev 06abf73c22 deps: bump dvc-data >=3.13.0 2024-02-15 23:18:49 +02:00
pre-commit-ci[bot] 5e2c441d28 [pre-commit.ci] pre-commit autoupdate (#10301)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.2.0 → v0.2.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.2.0...v0.2.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-15 09:03:30 +05:45
skshetry 6efd699267 deps: bump dvc-studio-client to >=0.19,<1 (#10298) 2024-02-10 09:56:09 +05:45
skshetry f639ab50ab simplify message in studio login/logout flow (#10297) 2024-02-09 23:02:35 +05:45
42 changed files with 2065 additions and 330 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ repos:
- id: sort-simple-yaml
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.2.0'
rev: 'v0.2.2'
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
+2
View File
@@ -2,6 +2,7 @@ from dvc.fs.dvc import _DVCFileSystem as DVCFileSystem
from .artifacts import artifacts_show
from .data import get_url, open, read
from .dataset import get as get_dataset
from .experiments import exp_save, exp_show
from .scm import all_branches, all_commits, all_tags
from .show import metrics_show, params_show
@@ -14,6 +15,7 @@ __all__ = [
"artifacts_show",
"exp_save",
"exp_show",
"get_dataset",
"get_url",
"metrics_show",
"open",
+66
View File
@@ -0,0 +1,66 @@
from typing import Literal, TypedDict, Union
class DVCXDataset(TypedDict):
type: Literal["dvcx"]
name: str
version: int
class DVCDataset(TypedDict):
type: Literal["dvc"]
url: str
path: str
sha: str
class URLDataset(TypedDict):
type: Literal["url"]
files: list[str]
path: str
def get(name: str) -> Union[DVCXDataset, DVCDataset, URLDataset]:
from difflib import get_close_matches
from dvc.fs import get_cloud_fs
from dvc.repo import Repo, datasets
repo = Repo()
try:
dataset = repo.datasets[name]
except datasets.DatasetNotFoundError as e:
add_note = getattr(e, "add_note", lambda _: None)
if matches := get_close_matches(name, repo.datasets):
add_note(f"Did you mean: {matches[0]!r}?")
raise
if dataset._invalidated:
raise ValueError(f"dataset not in sync. Sync with 'dvc ds update {name}'.")
if not dataset.lock:
raise ValueError("missing lock information")
if dataset.type == "dvc":
return DVCDataset(
type="dvc",
url=dataset.lock.url,
path=dataset.lock.path,
sha=dataset.lock.rev_lock,
)
if dataset.type == "dvcx":
return DVCXDataset(
type="dvcx", name=dataset.name_version[0], version=dataset.lock.version
)
if dataset.type == "url":
fs_cls, _, path = get_cloud_fs(repo.config, url=dataset.lock.url)
assert fs_cls
join_version = getattr(fs_cls, "join_version", lambda path, _: path)
protocol = fs_cls.protocol
versioned_path = join_version(path, dataset.lock.meta.version_id)
versioned_path = f"{protocol}://{versioned_path}"
files = [
join_version(
fs_cls.join(versioned_path, file.relpath), file.meta.version_id
)
for file in dataset.lock.files
]
return URLDataset(type="url", files=files, path=versioned_path)
+2
View File
@@ -17,6 +17,7 @@ from dvc.commands import (
dag,
data,
data_sync,
dataset,
destroy,
diff,
du,
@@ -67,6 +68,7 @@ COMMANDS = [
dag,
data,
data_sync,
dataset,
destroy,
diff,
du,
+225
View File
@@ -0,0 +1,225 @@
from typing import TYPE_CHECKING, Optional
from dvc.cli import formatter
from dvc.cli.command import CmdBase
from dvc.cli.utils import append_doc_link
from dvc.exceptions import DvcException
from dvc.log import logger
if TYPE_CHECKING:
from rich.text import Text
from dvc.repo.datasets import Dataset, FileInfo
logger = logger.getChild(__name__)
def diff_files(old: list["FileInfo"], new: list["FileInfo"]) -> dict[str, list[str]]:
old_files = {d.relpath: d for d in old}
new_files = {d.relpath: d for d in new}
rest = old_files.keys() & new_files.keys()
return {
"added": list(new_files.keys() - old_files.keys()),
"deleted": list(old_files.keys() - new_files.keys()),
"modified": [p for p in rest if new_files[p] != old_files[p]],
}
class CmdDatasetAdd(CmdBase):
@classmethod
def display(cls, name: str, dataset: "Dataset", action: str = "Adding"):
from dvc.ui import ui
assert dataset.lock
url = dataset.spec.url
ver: str = ""
if dataset.type == "dvcx":
ver = f"v{dataset.lock.version}"
if dataset.type == "dvc":
if dataset.lock.path:
url = f"{url}:/{dataset.lock.path.lstrip('/')}"
if rev := dataset.lock.rev:
ver = rev
ver_part: Optional["Text"] = None
if ver:
ver_part = ui.rich_text.assemble(" @ ", (ver, "repr.number"))
text = ui.rich_text.assemble("(", (url, "repr.url"), ver_part or "", ")")
ui.write(action, ui.rich_text(name, "cyan"), text, styled=True)
def run(self):
if not self.args.dvc and self.args.rev:
raise DvcException("--rev can't be used without --dvc")
if not self.args.dvc and self.args.path:
raise DvcException("--path can't be used without --dvc")
d = vars(self.args)
for key in ["dvc", "dvcx", "url"]:
if url := d.pop(key, None):
d.update({"type": key, "url": url})
break
existing = self.repo.datasets.get(self.args.name)
with self.repo.scm_context:
if not self.args.force and existing:
path = self.repo.fs.relpath(existing.manifest_path)
raise DvcException(
f"{self.args.name} already exists in {path}, "
"use the --force to overwrite"
)
dataset = self.repo.datasets.add(**d)
self.display(self.args.name, dataset)
return 0
class CmdDatasetUpdate(CmdBase):
def display(self, name: str, dataset: "Dataset", new: "Dataset"):
from dvc.commands.checkout import log_changes
from dvc.ui import ui
action = "Updating"
if not dataset.lock:
return CmdDatasetAdd.display(name, new, action)
if dataset == new:
ui.write("[yellow]Nothing to update[/]", styled=True)
return
assert new.lock
v: Optional[tuple[str, str]] = None
if dataset.type == "dvcx":
assert new.type == "dvcx"
if new.lock.version < dataset.lock.version:
action = "Downgrading"
v = (f"v{dataset.lock.version}", f"v{new.lock.version}")
if dataset.type == "dvc":
assert new.type == "dvc"
v = (f"{dataset.lock.rev_lock[:9]}", f"{new.lock.rev_lock[:9]}")
if v:
part = ui.rich_text.assemble(
(v[0], "repr.number"),
" -> ",
(v[1], "repr.number"),
)
else:
part = ui.rich_text(dataset.spec.url, "repr.url")
changes = ui.rich_text.assemble("(", part, ")")
ui.write(action, ui.rich_text(name, "cyan"), changes, styled=True)
if dataset.type == "url":
assert new.type == "url"
stats = diff_files(dataset.lock.files, new.lock.files)
log_changes(stats)
def run(self):
from difflib import get_close_matches
from dvc.repo.datasets import DatasetNotFoundError
from dvc.ui import ui
version = None
if self.args.rev:
try:
version = int(self.args.rev.lstrip("v"))
except ValueError:
version = self.args.rev
d = vars(self.args) | {"version": version}
with self.repo.scm_context:
try:
dataset, new = self.repo.datasets.update(**d)
except DatasetNotFoundError:
logger.exception("")
if matches := get_close_matches(self.args.name, self.repo.datasets):
ui.write(
"did you mean?",
ui.rich_text(matches[0], "cyan"),
stderr=True,
styled=True,
)
return 1
self.display(self.args.name, dataset, new)
return 0
def add_parser(subparsers, parent_parser):
ds_parser = subparsers.add_parser(
"dataset",
aliases=["ds"],
parents=[parent_parser],
formatter_class=formatter.RawDescriptionHelpFormatter,
)
ds_subparsers = ds_parser.add_subparsers(
dest="cmd",
help="Use `dvc dataset CMD --help` to display command-specific help.",
required=True,
)
dataset_add_help = "Add a dataset."
ds_add_parser = ds_subparsers.add_parser(
"add",
parents=[parent_parser],
description=append_doc_link(dataset_add_help, "dataset/add"),
formatter_class=formatter.RawTextHelpFormatter,
help=dataset_add_help,
)
url_exclusive_group = ds_add_parser.add_mutually_exclusive_group(required=True)
url_exclusive_group.add_argument(
"--dvcx", metavar="name", help="Name of the dvcx dataset to track"
)
url_exclusive_group.add_argument(
"--dvc",
help="Path or URL to a Git/DVC repository to track",
metavar="url",
)
url_exclusive_group.add_argument(
"--url",
help="""\
URL of a cloud-versioned remote to track. Supported URLs:
s3://bucket/key/path
gs://bucket/path/to/file/or/dir
azure://mycontainer/path
remote://remote_name/path/to/file/or/dir (see `dvc remote`)
""",
)
ds_add_parser.add_argument("name", help="Name of the dataset to add")
ds_add_parser.add_argument(
"--rev",
help="Git revision, e.g. SHA, branch, tag (only applicable with --dvc)",
metavar="<commit>",
)
ds_add_parser.add_argument(
"--path",
help="Path to a file or a directory within a git repository "
"(only applicable with --dvc)",
)
ds_add_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite existing dataset",
)
ds_add_parser.set_defaults(func=CmdDatasetAdd)
dataset_update_help = "Update a dataset."
ds_update_parser = ds_subparsers.add_parser(
"update",
parents=[parent_parser],
description=append_doc_link(dataset_update_help, "dataset/update"),
formatter_class=formatter.RawDescriptionHelpFormatter,
help=dataset_update_help,
)
ds_update_parser.add_argument("name", help="Name of the dataset to update")
ds_update_parser.add_argument(
"--rev",
"--version",
nargs="?",
help="DVCX dataset version or Git revision (e.g. SHA, branch, tag)",
metavar="<version>",
)
ds_update_parser.set_defaults(func=CmdDatasetUpdate)
+19 -9
View File
@@ -5,6 +5,7 @@ from funcy import get_in
from dvc.cli import formatter
from dvc.cli.utils import append_doc_link
from dvc.commands.config import CmdConfig
from dvc.exceptions import DvcException
from dvc.log import logger
logger = logger.getChild(__name__)
@@ -22,12 +23,21 @@ class CmdStudioLogin(CmdConfig):
hostname = self.args.hostname or os.environ.get(DVC_STUDIO_URL) or STUDIO_URL
scopes = self.args.scopes
studio = self.config.get("studio", {})
if studio.get("url", hostname) == hostname and "token" in studio:
raise DvcException(
"Token already exists. "
"To login with a different token, "
"logout using 'dvc studio logout'."
)
open_browser = not self.args.no_open
try:
token_name, access_token = get_access_token(
_, access_token = get_access_token(
token_name=name,
hostname=hostname,
scopes=scopes,
use_device_code=self.args.no_open,
open_browser=open_browser,
client_name="DVC",
)
except StudioAuthError as e:
@@ -35,11 +45,9 @@ class CmdStudioLogin(CmdConfig):
return 1
self.save_config(hostname, access_token)
ui.write(
"Authentication has been successfully completed."
"The generated token will now be accessible as"
f" {token_name} in the user's Studio profile."
)
config_path = self.config.files["global"]
ui.write(f"Authentication complete. Saved token to {config_path}.")
return 0
def save_config(self, hostname, token):
@@ -54,12 +62,14 @@ class CmdStudioLogout(CmdConfig):
with self.config.edit("global") as conf:
if not get_in(conf, ["studio", "token"]):
ui.error_write("Not logged in to Studio.")
ui.error_write(
"Not logged in to Studio. Log in with 'dvc studio login'."
)
return 1
del conf["studio"]["token"]
ui.write("Logged out from Studio")
ui.write("Logged out from Studio (you can log back in with 'dvc studio login')")
return 0
+2
View File
@@ -336,6 +336,8 @@ SCHEMA = {
"params": str,
"plots": str,
"live": str,
"auto_push": Bool,
"git_remote": str,
},
"parsing": {
"bool": All(Lower, Choices("store_true", "boolean_optional")),
+4 -4
View File
@@ -5,9 +5,9 @@ from dataclasses import dataclass
from tempfile import NamedTemporaryFile
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
from sqlalchemy import create_engine
from sqlalchemy.engine import make_url as _make_url
from sqlalchemy.exc import NoSuchModuleError
from sqlalchemy import create_engine # type: ignore[import]
from sqlalchemy.engine import make_url as _make_url # type: ignore[import]
from sqlalchemy.exc import NoSuchModuleError # type: ignore[import]
from dvc import env
from dvc.exceptions import DvcException
@@ -17,7 +17,7 @@ from dvc.utils import env2bool
if TYPE_CHECKING:
from sqlalchemy.engine import URL, Connectable, Engine
from sqlalchemy.sql.expression import Selectable
from sqlalchemy.sql.expression import Selectable # type: ignore[import]
logger = logger.getChild(__name__)
+18 -3
View File
@@ -3,6 +3,7 @@ from urllib.parse import urlparse
from funcy import compact, merge
from dvc.exceptions import DvcException
from dvc_data.hashfile.hash_info import HashInfo
from .db import AbstractDependency
@@ -47,9 +48,15 @@ class DatasetDependency(AbstractDependency):
)
def workspace_status(self):
registered = self.repo.index.datasets.get(self.name, {})
ds = self.repo.datasets[self.name]
if not ds.lock:
return {str(self): "not in sync"}
info: dict[str, Any] = self.hash_info.value if self.hash_info else {} # type: ignore[assignment]
if info != registered:
lock = self.repo.datasets._lock_from_info(info)
if not lock:
return {str(self): "new"}
if lock != ds.lock:
return {str(self): "modified"}
return {}
@@ -57,7 +64,15 @@ class DatasetDependency(AbstractDependency):
return self.workspace_status()
def get_hash(self):
return HashInfo(self.PARAM_DATASET, self.repo.index.datasets.get(self.name, {}))
ds = self.repo.datasets[self.name]
if not ds.lock:
if ds._invalidated:
raise DvcException(
"Dataset information is not in sync. "
f"Run 'dvc ds update {self.name}' to sync."
)
raise DvcException("Dataset information missing from dvc.lock file")
return HashInfo(self.PARAM_DATASET, ds.lock.to_dict()) # type: ignore[arg-type]
def save(self):
self.hash_info = self.get_hash()
+2 -1
View File
@@ -11,6 +11,7 @@ from .base import Dependency
if TYPE_CHECKING:
from dvc.output import Output
from dvc.repo import Repo
from dvc.stage import Stage
logger = logger.getChild(__name__)
@@ -33,7 +34,7 @@ class AbstractDependency(Dependency):
"""Dependency without workspace/fs/fs_path"""
def __init__(self, stage: "Stage", info: dict[str, Any], *args, **kwargs):
self.repo = stage.repo
self.repo: "Repo" = stage.repo
self.stage = stage
self.fs = None
self.fs_path = None
+37
View File
@@ -172,6 +172,7 @@ class SingleStageFile(FileMixin):
from dvc.stage.loader import SingleStageLoader as LOADER # noqa: N814
datasets: ClassVar[list[dict[str, Any]]] = []
datasets_lock: ClassVar[list[dict[str, Any]]] = []
metrics: ClassVar[list[str]] = []
plots: ClassVar[Any] = {}
params: ClassVar[list[str]] = []
@@ -240,6 +241,20 @@ class ProjectFile(FileMixin):
if update_lock:
self._dump_lockfile(stage, **kwargs)
def dump_dataset(self, dataset):
with modify_yaml(self.path, fs=self.repo.fs) as data:
datasets: list[dict] = data.setdefault("datasets", [])
loc = next(
(i for i, ds in enumerate(datasets) if ds["name"] == dataset["name"]),
None,
)
if loc is not None:
apply_diff(dataset, datasets[loc])
datasets[loc] = dataset
else:
datasets.append(dataset)
self.repo.scm_context.track_file(self.relpath)
def _dump_lockfile(self, stage, **kwargs):
self._lockfile.dump(stage, **kwargs)
@@ -308,6 +323,10 @@ class ProjectFile(FileMixin):
def datasets(self) -> list[dict[str, Any]]:
return self.contents.get("datasets", [])
@property
def datasets_lock(self) -> list[dict[str, Any]]:
return self.lockfile_contents.get("datasets", [])
@property
def artifacts(self) -> dict[str, Optional[dict[str, Any]]]:
return self.contents.get("artifacts", {})
@@ -357,6 +376,24 @@ class Lockfile(FileMixin):
self._check_gitignored()
return {}, ""
def dump_dataset(self, dataset: dict):
with modify_yaml(self.path, fs=self.repo.fs) as data:
data.update({"schema": "2.0"})
if not data:
logger.info("Generating lock file '%s'", self.relpath)
datasets: list[dict] = data.setdefault("datasets", [])
loc = next(
(i for i, ds in enumerate(datasets) if ds["name"] == dataset["name"]),
None,
)
if loc is not None:
datasets[loc] = dataset
else:
datasets.append(dataset)
data.setdefault("stages", {})
self.repo.scm_context.track_file(self.relpath)
def dump(self, stage, **kwargs):
stage_data = serialize.to_lockfile(stage, **kwargs)
+54
View File
@@ -0,0 +1,54 @@
"""UPath implementation for DVCFileSystem.
This provides a `pathlib.Path` like interface to
work with DVCFileSystem.
Examples
--------
>>> from upath import UPath
>>> local = UPath("dvc://path/to/local/repo")
>>> https = UPath("dvc+https://github.com/iterative/example-get-started", rev="main")
>>> ssh = UPath("dvc+ssh://[email protected]:iterative/example-get-started.git")
"""
from urllib.parse import urlsplit
from upath import UPath
class DVCPath(UPath):
@classmethod
def _transform_init_args(cls, args, protocol, storage_options):
if not args:
args = ("/",)
elif (
args
and "url" not in storage_options
and protocol in {"dvc+http", "dvc+https", "dvc+ssh"}
):
url, *rest = args
url = urlsplit(str(url))
proto = protocol.split("+")[1]
if proto == "ssh":
base_url = url.netloc + url.path
else:
base_url = url._replace(scheme=proto).geturl()
storage_options["url"] = base_url
# Assume the given path is a root url
args = ("/", *rest)
return super()._transform_init_args(args, "dvc", storage_options)
def __str__(self):
s = super().__str__()
if url := self.storage_options.get("url"):
return s.replace("dvc://", f"dvc+{url}", 1)
return s
def with_segments(self, *pathsegments):
obj = super().with_segments(*pathsegments)
# cache filesystem, as dvcfs does not cache filesystem
# caveat: any joinpath operation will instantiate filesystem
obj._fs_cached = self.fs
return obj
+1 -1
View File
@@ -814,7 +814,7 @@ class Output:
ret: dict[str, Any] = {}
with_files = (
(not self.IS_DEPENDENCY or self.stage.is_import)
(not self.IS_DEPENDENCY or kwargs.get("datasets") or self.stage.is_import)
and self.hash_info.isdir
and (kwargs.get("with_files") or self.files is not None)
)
+3 -11
View File
@@ -13,7 +13,6 @@ from dvc.exceptions import (
)
from dvc.ignore import DvcIgnoreFilter
from dvc.log import logger
from dvc.utils import as_posix
from dvc.utils.objects import cached_property
if TYPE_CHECKING:
@@ -151,6 +150,7 @@ class Repo:
from dvc.fs import GitFileSystem, LocalFileSystem, localfs
from dvc.lock import LockNoop, make_lock
from dvc.repo.artifacts import Artifacts
from dvc.repo.datasets import Datasets
from dvc.repo.metrics import Metrics
from dvc.repo.params import Params
from dvc.repo.plots import Plots
@@ -221,6 +221,7 @@ class Repo:
self.plots: Plots = Plots(self)
self.params: Params = Params(self)
self.artifacts: Artifacts = Artifacts(self)
self.datasets: Datasets = Datasets(self)
self.stage_collection_error_handler: Optional[
Callable[[str, Exception], None]
@@ -351,16 +352,6 @@ class Repo:
# fs.
self._reset()
@property
def subrepo_relpath(self) -> str:
from dvc.fs import GitFileSystem
scm_root_dir = "/" if isinstance(self.fs, GitFileSystem) else self.scm.root_dir
relpath = as_posix(self.fs.relpath(self.root_dir, scm_root_dir))
return "" if relpath == "." else relpath
@property
def data_index(self) -> "DataIndex":
from dvc_data.index import DataIndex
@@ -664,6 +655,7 @@ class Repo:
def _reset(self):
self.scm._reset()
self.datasets._reset()
self.state.close()
if "dvcfs" in self.__dict__:
self.dvcfs.close()
+423
View File
@@ -0,0 +1,423 @@
import os
from collections.abc import Iterator, Mapping
from datetime import datetime
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, cast
from urllib.parse import urlparse
from attrs import Attribute, AttrsInstance, asdict, evolve, field, fields, frozen
from attrs.converters import default_if_none
from dvc.dvcfile import Lockfile, ProjectFile
from dvc.exceptions import DvcException
from dvc.log import logger
from dvc.types import StrPath
from dvc_data.hashfile.meta import Meta
if TYPE_CHECKING:
from dql.dataset import DatasetRecord # type: ignore[import]
from dvcx.dataset import DatasetVersion # type: ignore[import]
from typing_extensions import Self
from dvc.repo import Repo
logger = logger.getChild(__name__)
def _get_dataset_record(name: str) -> "DatasetRecord":
from dvc.exceptions import DvcException
try:
from dvcx.catalog import get_catalog # type: ignore[import]
except ImportError as exc:
raise DvcException("dvcx is not installed") from exc
catalog = get_catalog()
return catalog.get_remote_dataset(name)
def _get_dataset_info(
name: str, record: Optional["DatasetRecord"] = None, version: Optional[int] = None
) -> "DatasetVersion":
record = record or _get_dataset_record(name)
assert record
v = record.latest_version if version is None else version
assert v is not None
return record.get_version(v)
def default_str(v) -> str:
return default_if_none("")(v)
def to_datetime(d: Union[str, datetime]) -> datetime:
return datetime.fromisoformat(d) if isinstance(d, str) else d
def ensure(cls):
def inner(v):
return cls.from_dict(v) if isinstance(v, dict) else v
return inner
class SerDe:
def to_dict(self: AttrsInstance) -> dict[str, Any]:
def filter_defaults(attr: Attribute, v: Any):
if attr.metadata.get("exclude_falsy", False) and not v:
return False
return attr.default != v
def value_serializer(_inst, _field, v):
return v.isoformat() if isinstance(v, datetime) else v
return asdict(self, filter=filter_defaults, value_serializer=value_serializer)
@classmethod
def from_dict(cls: type["Self"], d: dict[str, Any]) -> "Self":
_fields = fields(cast("type[AttrsInstance]", cls))
kwargs = {f.name: d[f.name] for f in _fields if f.name in d}
return cls(**kwargs)
@frozen(kw_only=True)
class DatasetSpec(SerDe):
name: str
url: str
type: Literal["dvc", "dvcx", "url"]
@frozen(kw_only=True)
class DVCDatasetSpec(DatasetSpec):
type: Literal["dvc"]
path: str = field(default="", converter=default_str)
rev: Optional[str] = None
@frozen(kw_only=True, order=True)
class FileInfo(SerDe):
relpath: str
meta: Meta = field(order=False, converter=ensure(Meta)) # type: ignore[misc]
@frozen(kw_only=True)
class DVCDatasetLock(DVCDatasetSpec):
rev_lock: str
@frozen(kw_only=True)
class DVCXDatasetLock(DatasetSpec):
version: int
created_at: datetime = field(converter=to_datetime)
@frozen(kw_only=True)
class URLDatasetLock(DatasetSpec):
meta: Meta = field(converter=ensure(Meta)) # type: ignore[misc]
files: list[FileInfo] = field(
factory=list,
converter=lambda f: sorted(map(ensure(FileInfo), f)),
metadata={"exclude_falsy": True},
)
def to_spec(lock: "Lock") -> "Spec":
cls = DVCDatasetSpec if lock.type == "dvc" else DatasetSpec
return cls(**{f.name: getattr(lock, f.name) for f in fields(cls)})
@frozen(kw_only=True)
class DVCDataset:
manifest_path: str
spec: DVCDatasetSpec
lock: Optional[DVCDatasetLock] = None
_invalidated: bool = field(default=False, eq=False, repr=False)
type: ClassVar[Literal["dvc"]] = "dvc"
def update(self, repo, rev: Optional[str] = None, **kwargs) -> "Self":
from dvc.dependency import RepoDependency
spec = self.spec
if rev:
spec = evolve(self.spec, rev=rev)
def_repo = {
RepoDependency.PARAM_REV: spec.rev,
RepoDependency.PARAM_URL: spec.url,
}
dep = RepoDependency(def_repo, None, spec.path, repo=repo) # type: ignore[arg-type]
dep.save()
d = dep.dumpd()
repo_info = d[RepoDependency.PARAM_REPO]
assert isinstance(repo_info, dict)
rev_lock = repo_info[RepoDependency.PARAM_REV_LOCK]
lock = DVCDatasetLock(**spec.to_dict(), rev_lock=rev_lock)
return evolve(self, spec=spec, lock=lock)
@frozen(kw_only=True)
class DVCXDataset:
manifest_path: str
spec: "DatasetSpec"
lock: "Optional[DVCXDatasetLock]" = field(default=None)
_invalidated: bool = field(default=False, eq=False, repr=False)
type: ClassVar[Literal["dvcx"]] = "dvcx"
@property
def pinned(self) -> bool:
return self.name_version[1] is not None
@property
def name_version(self) -> tuple[str, Optional[int]]:
url = urlparse(self.spec.url)
path = url.netloc + url.path
parts = path.split("@v")
assert parts
name = parts[0]
version = int(parts[1]) if len(parts) > 1 else None
return name, version
def update(
self,
repo, # noqa: ARG002
record: Optional["DatasetRecord"] = None,
version: Optional[int] = None,
**kwargs,
) -> "Self":
name, _version = self.name_version
version = version if version is not None else _version
version_info = _get_dataset_info(name, record=record, version=version)
lock = DVCXDatasetLock(
**self.spec.to_dict(),
version=version_info.version,
created_at=version_info.created_at,
)
return evolve(self, lock=lock)
@frozen(kw_only=True)
class URLDataset:
manifest_path: str
spec: "DatasetSpec"
lock: "Optional[URLDatasetLock]" = None
_invalidated: bool = field(default=False, eq=False, repr=False)
type: ClassVar[Literal["url"]] = "url"
def update(self, repo, **kwargs):
from dvc.dependency import Dependency
dep = Dependency(
None, self.spec.url, repo=repo, fs_config={"version_aware": True}
)
dep.save()
d = dep.dumpd(datasets=True)
files = [
FileInfo(relpath=info["relpath"], meta=Meta.from_dict(info))
for info in d.get("files", [])
]
lock = URLDatasetLock(**self.spec.to_dict(), meta=dep.meta, files=files)
return evolve(self, lock=lock)
Lock = Union[DVCDatasetLock, DVCXDatasetLock, URLDatasetLock]
Spec = Union[DatasetSpec, DVCDatasetSpec]
Dataset = Union[DVCDataset, DVCXDataset, URLDataset]
class DatasetNotFoundError(DvcException, KeyError):
def __init__(self, name, *args):
self.name = name
super().__init__("dataset not found", *args)
def __str__(self) -> str:
return self.msg
class Datasets(Mapping[str, Dataset]):
def __init__(self, repo: "Repo") -> None:
self.repo: "Repo" = repo
def __repr__(self):
return repr(dict(self))
def __rich_repr__(self):
yield dict(self)
def __getitem__(self, name: str) -> Dataset:
try:
return self._datasets[name]
except KeyError as exc:
raise DatasetNotFoundError(name) from exc
def __setitem__(self, name: str, dataset: Dataset) -> None:
self._datasets[name] = dataset
def __contains__(self, name: object) -> bool:
return name in self._datasets
def __iter__(self) -> Iterator[str]:
return iter(self._datasets)
def __len__(self) -> int:
return len(self._datasets)
@cached_property
def _spec(self) -> dict[str, tuple[str, dict[str, Any]]]:
return {
dataset["name"]: (path, dataset)
for path, datasets in self.repo.index._datasets.items()
for dataset in datasets
}
@cached_property
def _lock(self) -> dict[str, Optional[dict[str, Any]]]:
datasets_lock = self.repo.index._datasets_lock
def find(path, name) -> Optional[dict[str, Any]]:
# only look for `name` in the lock file next to the
# corresponding `dvc.yaml` file
lock = datasets_lock.get(path, [])
return next((dataset for dataset in lock if dataset["name"] == name), None)
return {ds["name"]: find(path, name) for name, (path, ds) in self._spec.items()}
@cached_property
def _datasets(self) -> dict[str, Dataset]:
return {
name: self._build_dataset(path, spec, self._lock[name])
for name, (path, spec) in self._spec.items()
}
def _reset(self) -> None:
self.__dict__.pop("_spec", None)
self.__dict__.pop("_lock", None)
self.__dict__.pop("_datasets", None)
@staticmethod
def _spec_from_info(spec: dict[str, Any]) -> Spec:
typ = spec.get("type")
if not typ:
raise ValueError("type should be present in spec")
if typ == "dvc":
return DVCDatasetSpec.from_dict(spec)
if typ in {"dvcx", "url"}:
return DatasetSpec.from_dict(spec)
raise ValueError(f"unknown dataset type: {spec.get('type', '')}")
@staticmethod
def _lock_from_info(lock: Optional[dict[str, Any]]) -> Optional[Lock]:
kl = {"dvc": DVCDatasetLock, "dvcx": DVCXDatasetLock, "url": URLDatasetLock}
if lock and (cls := kl.get(lock.get("type", ""))): # type: ignore[assignment]
return cls.from_dict(lock) # type: ignore[attr-defined]
return None
@classmethod
def _build_dataset(
cls,
manifest_path: str,
spec_data: dict[str, Any],
lock_data: Optional[dict[str, Any]] = None,
) -> Dataset:
_invalidated = False
spec = cls._spec_from_info(spec_data)
lock = cls._lock_from_info(lock_data)
# if dvc.lock and dvc.yaml file are not in sync, we invalidate the lock.
if lock is not None and to_spec(lock) != spec:
logger.debug(
"invalidated lock data for %s in %s",
spec.name,
manifest_path,
)
_invalidated = True # signal is used during `dvc repro`/`dvc status`.
lock = None
assert isinstance(spec, DatasetSpec)
if spec.type == "dvc":
assert lock is None or isinstance(lock, DVCDatasetLock)
assert isinstance(spec, DVCDatasetSpec)
return DVCDataset(
manifest_path=manifest_path,
spec=spec,
lock=lock,
invalidated=_invalidated,
)
if spec.type == "url":
assert lock is None or isinstance(lock, URLDatasetLock)
return URLDataset(
manifest_path=manifest_path,
spec=spec,
lock=lock,
invalidated=_invalidated,
)
if spec.type == "dvcx":
assert lock is None or isinstance(lock, DVCXDatasetLock)
return DVCXDataset(
manifest_path=manifest_path,
spec=spec,
lock=lock,
invalidated=_invalidated,
)
raise ValueError(f"unknown dataset type: {spec.type!r}")
def add(
self,
name: str,
url: str,
type: str, # noqa: A002
manifest_path: StrPath = "dvc.yaml",
**kwargs: Any,
) -> Dataset:
assert type in {"dvc", "dvcx", "url"}
kwargs.update({"name": name, "url": url, "type": type})
dataset = self._build_dataset(os.path.abspath(manifest_path), kwargs)
dataset = dataset.update(self.repo)
self.dump(dataset)
self[name] = dataset
return dataset
def update(self, name, **kwargs) -> tuple[Dataset, Dataset]:
dataset = self[name]
version = kwargs.get("version")
if dataset.type == "url" and (version or kwargs.get("rev")):
raise ValueError("cannot update version/revision for a url")
if dataset.type == "dvcx" and version is not None:
if not isinstance(version, int):
raise TypeError(
f"dvcx version has to be an integer, got {type(version).__name__!r}"
)
if version < 1:
raise ValueError(f"dvcx version should be >=1, got {version}")
new = dataset.update(self.repo, **kwargs)
self.dump(new, old=dataset)
self[name] = new
return dataset, new
def _dump_spec(self, manifest_path: StrPath, spec: Spec) -> None:
spec_data = spec.to_dict()
assert spec_data.keys() & {"type", "name", "url"}
project_file = ProjectFile(self.repo, manifest_path)
project_file.dump_dataset(spec_data)
def _dump_lock(self, manifest_path: StrPath, lock: Lock) -> None:
lock_data = lock.to_dict()
assert lock_data.keys() & {"type", "name", "url"}
lockfile = Lockfile(self.repo, Path(manifest_path).with_suffix(".lock"))
lockfile.dump_dataset(lock_data)
def dump(self, dataset: Dataset, old: Optional[Dataset] = None) -> None:
if not old or old.spec != dataset.spec:
self._dump_spec(dataset.manifest_path, dataset.spec)
if dataset.lock and (not old or old.lock != dataset.lock):
self._dump_lock(dataset.manifest_path, dataset.lock)
+42 -29
View File
@@ -23,7 +23,10 @@ from dvc.repo.params.show import _collect_top_level_params
from dvc.stage.serialize import to_lockfile
from dvc.utils import dict_sha256, env2bool, relpath
from dvc.utils.fs import remove
from dvc.utils.studio import env_to_config
from dvc.utils.studio import (
env_to_config,
get_subrepo_relpath,
)
if TYPE_CHECKING:
from queue import Queue
@@ -294,13 +297,16 @@ class BaseExecutor(ABC):
exp_hash = cls.hash_exp(stages)
if include_untracked:
dvc.scm.add(include_untracked, force=True) # type: ignore[call-arg]
cls.commit(
dvc.scm, # type: ignore[arg-type]
exp_hash,
exp_name=info.name,
force=force,
message=message,
)
with cls.auto_push(dvc):
cls.commit(
dvc.scm, # type: ignore[arg-type]
exp_hash,
exp_name=info.name,
force=force,
message=message,
)
ref: Optional[str] = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
exp_ref = ExpRefInfo.from_ref(ref) if ref else None
untracked = dvc.scm.untracked_files()
@@ -457,9 +463,6 @@ class BaseExecutor(ABC):
from dvc.repo.checkout import checkout as dvc_checkout
from dvc.ui import ui
auto_push = env2bool(DVC_EXP_AUTO_PUSH)
git_remote = os.getenv(DVC_EXP_GIT_REMOTE, None)
if queue is not None:
queue.put((rev, os.getpid()))
if log_errors and log_level is not None:
@@ -480,9 +483,6 @@ class BaseExecutor(ABC):
message=message,
**kwargs,
) as dvc:
if auto_push:
cls._validate_remotes(dvc, git_remote)
args, kwargs = cls._repro_args(dvc)
if args:
targets: Optional[Union[list, str]] = args[0]
@@ -516,8 +516,6 @@ class BaseExecutor(ABC):
dvc,
info,
exp_hash,
auto_push,
git_remote,
repro_force,
message=message,
)
@@ -547,20 +545,18 @@ class BaseExecutor(ABC):
dvc,
info,
exp_hash,
auto_push,
git_remote,
repro_force,
message: Optional[str] = None,
) -> tuple[Optional[str], Optional["ExpRefInfo"], bool]:
cls.commit(
dvc.scm,
exp_hash,
exp_name=info.name,
force=repro_force,
message=message,
)
if auto_push:
cls._auto_push(dvc, dvc.scm, git_remote)
with cls.auto_push(dvc):
cls.commit(
dvc.scm,
exp_hash,
exp_name=info.name,
force=repro_force,
message=message,
)
ref: Optional[str] = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
exp_ref: Optional["ExpRefInfo"] = ExpRefInfo.from_ref(ref) if ref else None
if cls.WARN_UNTRACKED:
@@ -624,6 +620,7 @@ class BaseExecutor(ABC):
params=to_studio_params(dvc.params.show()),
dvc_studio_config=dvc_studio_config,
message=message,
subdir=get_subrepo_relpath(dvc),
)
logger.debug("Running repro in '%s'", os.getcwd())
yield dvc
@@ -668,15 +665,30 @@ class BaseExecutor(ABC):
kwargs = {}
return args, kwargs
@classmethod
@contextmanager
def auto_push(cls, dvc: "Repo") -> Iterator[None]:
exp_config = dvc.config.get("exp", {})
auto_push = env2bool(DVC_EXP_AUTO_PUSH, exp_config.get("auto_push", False))
if not auto_push:
yield
return
git_remote = os.getenv(
DVC_EXP_GIT_REMOTE, exp_config.get("git_remote", "origin")
)
cls._validate_remotes(dvc, git_remote)
yield
cls._auto_push(dvc, git_remote)
@staticmethod
def _auto_push(
dvc: "Repo",
scm: "Git",
git_remote: Optional[str],
push_cache=True,
run_cache=True,
):
branch = scm.get_ref(EXEC_BRANCH, follow=False)
branch = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
try:
dvc.experiments.push(
git_remote,
@@ -704,6 +716,7 @@ class BaseExecutor(ABC):
message: Optional[str] = None,
):
"""Commit stages as an experiment and return the commit SHA."""
rev = scm.get_rev()
if not scm.is_dirty(untracked_files=False):
logger.debug("No changes to commit")
-145
View File
@@ -1,145 +0,0 @@
import os
from functools import partial
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Union
from dvc.log import logger
if TYPE_CHECKING:
from dvc.dependency.base import Dependency
from dvc.repo import Repo
from dvc.repo.index import Index, IndexView
from dvc.stage import Stage
from dvc.types import TargetType
from dvc_data.hashfile.hash_info import HashInfo
logger = logger.getChild(__name__)
def unfetched_view(
index: "Index", targets: "TargetType", unpartial: bool = False, **kwargs
) -> tuple["IndexView", "IndexView", list["Dependency"]]:
"""Return index view of imports which have not been fetched.
Returns:
Tuple in the form (legacy_view, view, changed_deps) where changed_imports is a
list of import dependencies that cannot be fetched due to changed data source.
"""
from dvc.cachemgr import LEGACY_HASH_NAMES
changed_deps: list["Dependency"] = []
def need_fetch(stage: "Stage", legacy: bool = False) -> bool:
if not stage.is_import or (stage.is_partial_import and not unpartial):
return False
out = stage.outs[0]
if not out.changed_cache():
return False
dep = stage.deps[0]
if dep.changed_checksum():
changed_deps.append(dep)
return False
if out.hash_name in LEGACY_HASH_NAMES and legacy:
return True
if out.hash_name not in LEGACY_HASH_NAMES and not legacy:
return True
return False
legacy_unfetched = index.targets_view(
targets,
stage_filter=partial(need_fetch, legacy=True),
**kwargs,
)
unfetched = index.targets_view(targets, stage_filter=need_fetch, **kwargs)
return legacy_unfetched, unfetched, changed_deps
def partial_view(index: "Index", targets: "TargetType", **kwargs) -> "IndexView":
return index.targets_view(
targets,
stage_filter=lambda s: s.is_partial_import,
**kwargs,
)
def unpartial_imports(index: Union["Index", "IndexView"]) -> int:
"""Update any outs in the index which are no longer partial imports.
Returns:
Total number of files which were unpartialed.
"""
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.meta import Meta
updated = 0
for out in index.outs:
# we need to use view[key] here and since the out fields have not been
# updated yet (out.get_entry() would return the partial-import state)
workspace, key = out.index_key
entry = index.data[workspace][key]
if out.stage.is_partial_import:
out.hash_info = entry.hash_info or HashInfo()
out.meta = entry.meta or Meta()
out.stage.md5 = out.stage.compute_md5()
out.stage.dump()
updated += out.meta.nfiles if out.meta.nfiles is not None else 1
return updated
def save_imports(
repo: "Repo", targets: "TargetType", unpartial: bool = False, **kwargs
) -> set["HashInfo"]:
"""Save (download) imports from their original source location.
Imports which are already cached will not be downloaded.
Returns:
Objects which were downloaded from source location.
"""
from dvc.fs.callbacks import TqdmCallback
from dvc.stage.exceptions import DataSourceChanged
from dvc_data.index import md5, save
from dvc_data.index.checkout import apply, compare
downloaded: set["HashInfo"] = set()
legacy_unfetched, unfetched, changed = unfetched_view(
repo.index, targets, unpartial=unpartial, **kwargs
)
for dep in changed:
logger.warning(str(DataSourceChanged(f"{dep.stage} ({dep})")))
for view, cache in [
(legacy_unfetched, repo.cache.legacy),
(unfetched, repo.cache.local),
]:
data_view = view.data["repo"]
if len(data_view):
if not cache.fs.exists(cache.path):
os.makedirs(cache.path)
with TemporaryDirectory(dir=cache.path) as tmpdir:
with TqdmCallback(
desc="Downloading imports from source",
unit="files",
) as cb:
diff = compare(None, data_view)
apply(diff, tmpdir, cache.fs, callback=cb, storage="remote")
md5(data_view, name=cache.hash_name)
save(data_view, odb=cache, hardlink=True)
downloaded.update(
entry.hash_info
for _, entry in data_view.iteritems()
if entry.meta is not None
and not entry.meta.isdir
and entry.hash_info is not None
)
if unpartial:
unpartial_imports(partial_view(repo.index, targets, **kwargs))
return downloaded
+22 -13
View File
@@ -104,7 +104,7 @@ def collect_files(
dirs[:] = [d for d in dirs if not is_out_or_ignored(root, d)]
def _load_data_from_tree(index, prefix, ws, key, tree):
def _load_data_from_tree(index, prefix, ws, key, tree, hash_name):
from dvc_data.index import DataIndexEntry, Meta
parents = set()
@@ -117,7 +117,7 @@ def _load_data_from_tree(index, prefix, ws, key, tree):
index[(*prefix, ws, *fkey)] = DataIndexEntry(
key=fkey,
meta=ometa,
hash_info=ohi,
hash_info=ohi if (ohi and ohi.name == hash_name) else None,
)
for parent in parents:
@@ -151,7 +151,7 @@ def _load_data_from_outs(index, prefix, outs):
tree = out.get_obj()
if tree is not None:
_load_data_from_tree(index, prefix, ws, key, tree)
_load_data_from_tree(index, prefix, ws, key, tree, out.hash_name)
entry = DataIndexEntry(
key=key,
@@ -193,16 +193,22 @@ def _load_storage_from_import(storage_map, key, out):
return
dep = out.stage.deps[0]
if not out.hash_info and (
not dep.hash_info or dep.hash_info.name != storage_map[key].cache.odb.hash_name
):
# partial import
if not out.hash_info or dep.fs.version_aware:
if dep.meta and dep.meta.isdir:
meta_token = dep.hash_info.value
else:
meta_token = tokenize(dep.meta.to_dict())
fs_cache = out.repo.cache.fs_cache
storage_map.add_cache(
FileStorage(
key,
fs_cache.fs,
fs_cache.fs.join(fs_cache.path, dep.fs.protocol, tokenize(dep.fs_path)),
fs_cache.fs.join(
fs_cache.path,
dep.fs.protocol,
tokenize(dep.fs_path, meta_token),
),
)
)
@@ -284,6 +290,7 @@ class Index:
params: Optional[dict[str, Any]] = None,
artifacts: Optional[dict[str, Any]] = None,
datasets: Optional[dict[str, list[dict[str, Any]]]] = None,
datasets_lock: Optional[dict[str, list[dict[str, Any]]]] = None,
) -> None:
self.repo = repo
self.stages = stages or []
@@ -292,6 +299,7 @@ class Index:
self._params = params or {}
self._artifacts = artifacts or {}
self._datasets: dict[str, list[dict[str, Any]]] = datasets or {}
self._datasets_lock: dict[str, list[dict[str, Any]]] = datasets_lock or {}
self._collected_targets: dict[int, list["StageInfo"]] = {}
@cached_property
@@ -316,6 +324,7 @@ class Index:
params = {}
artifacts = {}
datasets = {}
datasets_lock = {}
onerror = onerror or repo.stage_collection_error_handler
for _, idx in collect_files(repo, onerror=onerror):
@@ -325,6 +334,7 @@ class Index:
params.update(idx._params)
artifacts.update(idx._artifacts)
datasets.update(idx._datasets)
datasets_lock.update(idx._datasets_lock)
return cls(
repo,
stages=stages,
@@ -333,6 +343,7 @@ class Index:
params=params,
artifacts=artifacts,
datasets=datasets,
datasets_lock=datasets_lock,
)
@classmethod
@@ -348,6 +359,9 @@ class Index:
params={path: dvcfile.params} if dvcfile.params else {},
artifacts={path: dvcfile.artifacts} if dvcfile.artifacts else {},
datasets={path: dvcfile.datasets} if dvcfile.datasets else {},
datasets_lock={path: dvcfile.datasets_lock}
if dvcfile.datasets_lock
else {},
)
def update(self, stages: Iterable["Stage"]) -> "Index":
@@ -400,11 +414,6 @@ class Index:
for stage in self.stages:
yield from stage.outs
@cached_property
def datasets(self) -> dict[str, dict[str, Any]]:
datasets = chain.from_iterable(self._datasets.values())
return {dataset["name"]: dataset for dataset in datasets}
@cached_property
def out_data_keys(self) -> dict[str, set["DataIndexKey"]]:
by_workspace: dict[str, set["DataIndexKey"]] = defaultdict(set)
+31 -6
View File
@@ -423,11 +423,17 @@ def _id_is_path(plot_props=None):
def _adjust_sources(fs, plot_props, config_dir):
new_plot_props = deepcopy(plot_props)
old_y = new_plot_props.pop("y", {})
new_y = {}
for filepath, val in old_y.items():
new_y[_normpath(fs.join(config_dir, filepath))] = val
new_plot_props["y"] = new_y
for axis in ["x", "y"]:
x_is_inferred = axis == "x" and (
axis not in new_plot_props or isinstance(new_plot_props[axis], str)
)
if x_is_inferred:
continue
old = new_plot_props.pop(axis, {})
new = {}
for filepath, val in old.items():
new[_normpath(fs.join(config_dir, filepath))] = val
new_plot_props[axis] = new
return new_plot_props
@@ -442,6 +448,10 @@ def _resolve_definitions(
config_path = os.fspath(config_path)
config_dir = fs.dirname(config_path)
result: dict[str, dict] = {}
plot_ids_parents = [
_normpath(fs.join(config_dir, plot_id)) for plot_id in definitions
]
for plot_id, plot_props in definitions.items():
if plot_props is None:
plot_props = {}
@@ -451,6 +461,12 @@ def _resolve_definitions(
unpacked = unpack_if_dir(
fs, data_path, props=plot_props | props, onerror=onerror
)
# use config for parent directory with most specific definition
unpacked["data"] = {
k: v
for k, v in unpacked["data"].items()
if _closest_parent(fs, k, plot_ids_parents) == data_path
}
dpath.merge(result, unpacked)
elif _matches(targets, config_path, plot_id):
adjusted_props = _adjust_sources(fs, plot_props, config_dir)
@@ -459,6 +475,15 @@ def _resolve_definitions(
return result
def _closest_parent(fs, path, parents):
best_result = ""
for parent in parents:
common_path = fs.commonpath([path, parent])
if len(common_path) > len(best_result):
best_result = common_path
return best_result
def _collect_pipeline_files(repo, targets: list[str], props, onerror=None):
result: dict[str, dict] = {}
top_plots = repo.index._plots
@@ -513,7 +538,7 @@ def unpack_if_dir(fs, path, props: dict[str, str], onerror: Optional[Callable] =
if "data" in unpacked:
for subpath in unpacked["data"]:
result["data"].update({subpath: props})
result["data"].update({subpath: props.copy()})
else:
result.update(unpacked)
+2 -1
View File
@@ -47,6 +47,7 @@ LOCK_FILE_STAGE_SCHEMA = {
LOCKFILE_STAGES_SCHEMA = {str: LOCK_FILE_STAGE_SCHEMA}
LOCKFILE_SCHEMA = {
vol.Required("schema"): vol.Equal("2.0", "invalid schema version"),
"datasets": object,
STAGES: LOCKFILE_STAGES_SCHEMA,
}
@@ -128,7 +129,7 @@ DATASET_SCHEMA = vol.Schema(
{vol.Required("type"): str, vol.Required("name"): str}, extra=vol.ALLOW_EXTRA
)
MULTI_STAGE_SCHEMA = {
"datasets": [DATASET_SCHEMA],
"datasets": object,
PLOTS: [vol.Any(str, SINGLE_PLOT_SCHEMA)],
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
VARS_KWD: VARS_SCHEMA,
+4 -2
View File
@@ -33,9 +33,9 @@ class TestAPI:
[
{},
{"url": "{path}"},
{"url": "{path}", "rev": "master"},
{"url": "{path}", "rev": "{default_branch}"},
{"url": "file://{posixpath}"},
{"url": "file://{posixpath}", "rev": "master"},
{"url": "file://{posixpath}", "rev": "{default_branch}"},
],
ids=["current", "local", "local_rev", "git", "git_rev"],
)
@@ -61,6 +61,8 @@ class TestAPI:
if url := fs_kwargs.get("url"):
fs_kwargs["url"] = url.format(path=tmp_dir, posixpath=tmp_dir.as_posix())
if rev := fs_kwargs.get("rev"):
fs_kwargs["rev"] = rev.format(default_branch=scm.active_branch())
fs = DVCFileSystem(**fs_kwargs)
@@ -1,7 +1,7 @@
import pytest
@pytest.mark.flaky(max_runs=3, min_passes=1)
@pytest.mark.flaky(reruns=3)
def test_get(bench_dvc, tmp_dir, scm, dvc, make_dataset, remote):
dataset = make_dataset(
cache=False, files=False, dvcfile=True, commit=True, remote=True
@@ -1,7 +1,7 @@
import pytest
@pytest.mark.flaky(max_runs=3, min_passes=1)
@pytest.mark.flaky(reruns=3)
def test_import(bench_dvc, tmp_dir, scm, dvc, make_dataset, remote):
dataset = make_dataset(
cache=False, files=False, dvcfile=True, commit=True, remote=True
+2 -15
View File
@@ -221,15 +221,8 @@ def docker_compose_project_name():
@pytest.fixture(scope="session")
def docker_services(
tmp_path_factory,
docker_compose_command,
docker_compose_file,
docker_compose_project_name,
docker_setup,
):
def docker_services(tmp_path_factory, request):
from filelock import FileLock
from pytest_docker.plugin import DockerComposeExecutor, Services
if os.environ.get("CI") and os.name == "nt":
pytest.skip("disabled for Windows on CI")
@@ -251,18 +244,12 @@ def docker_services(
out = (err.output or b"").decode("utf-8")
pytest.skip(f"docker-compose is not installed: {out}")
executor = DockerComposeExecutor(
docker_compose_command, docker_compose_file, docker_compose_project_name
)
# making sure we don't accidentally launch docker-compose in parallel,
# as it might result in network conflicts. Inspired by:
# https://github.com/pytest-dev/pytest-xdist#making-session-scoped-fixtures-execute-only-once
lockfile = tmp_path_factory.getbasetemp().parent / "docker-compose.lock"
with FileLock(os.fspath(lockfile)):
executor.execute(docker_setup)
# note: we are not tearing down the containers here
return Services(executor)
return request.getfixturevalue("docker_services")
@pytest.fixture
+8 -6
View File
@@ -1,6 +1,7 @@
import os
import pathlib
import posixpath
import sys
from typing import Callable, ClassVar
from urllib.parse import urlparse
@@ -27,12 +28,13 @@ class PathInfo(pathlib.PurePath, _BasePath):
__slots__ = ()
scheme = "local"
def __new__(cls, *args):
# Construct a proper subclass depending on current os
if cls is PathInfo:
cls = WindowsPathInfo if os.name == "nt" else PosixPathInfo
if sys.version_info < (3, 12):
return cls._from_parts(args) # type: ignore[attr-defined]
def __new__(cls, *args):
if cls is PathInfo:
cls = WindowsPathInfo if os.name == "nt" else PosixPathInfo
return cls._from_parts(args) # type: ignore[attr-defined]
def as_posix(self):
f = self._flavour # type: ignore[attr-defined]
@@ -67,7 +69,7 @@ class PathInfo(pathlib.PurePath, _BasePath):
def isin(self, other):
if isinstance(other, (str, bytes)):
other = self.__class__(other)
other = self.__class__(other) # type: ignore[arg-type]
elif self.__class__ != other.__class__:
return False
# Use cached casefolded parts to compare paths
+5 -6
View File
@@ -67,13 +67,12 @@ class TmpDir(pathlib.Path):
def config(self):
return {"url": self.url}
def __new__(cls, *args, **kwargs):
if cls is TmpDir:
cls = WindowsTmpDir if os.name == "nt" else PosixTmpDir
if sys.version_info < (3, 12):
def __new__(cls, *args, **kwargs):
if cls is TmpDir:
cls = WindowsTmpDir if os.name == "nt" else PosixTmpDir
if sys.version_info >= (3, 12):
return object.__new__(cls)
else: # noqa: RET505
# init parameter and `_init` method has been removed in Python 3.10.
kw = {"init": False} if sys.version_info < (3, 10) else {}
self = cls._from_parts(args, **kw) # type: ignore[attr-defined]
+2
View File
@@ -106,6 +106,7 @@ class TestImportURLVersionAware:
(remote_version_aware / "file").write_text("modified")
assert dvc.status().get("file.dvc") == [
{"changed deps": {"remote://upstream/file": "update available"}},
{"changed outs": {"file": "not in cache"}},
]
dvc.update(str(tmp_dir / "file.dvc"))
assert (tmp_dir / "file").read_text() == "modified"
@@ -137,6 +138,7 @@ class TestImportURLVersionAware:
(remote_version_aware / "data_dir" / "new_file").write_text("new")
assert dvc.status().get("data_dir.dvc") == [
{"changed deps": {"remote://upstream/data_dir": "modified"}},
{"changed outs": {"data_dir": "not in cache"}},
]
dvc.update(str(tmp_dir / "data_dir.dvc"))
assert (tmp_dir / "data_dir" / "subdir" / "file").read_text() == "modified"
+14
View File
@@ -12,10 +12,14 @@ from dvc.env import (
DVC_STUDIO_URL,
)
from dvc.log import logger
from dvc.utils import as_posix
if TYPE_CHECKING:
from requests import Response
from dvc.repo import Repo
logger = logger.getChild(__name__)
STUDIO_URL = "https://studio.iterative.ai"
@@ -111,3 +115,13 @@ def env_to_config(env: dict[str, Any]) -> dict[str, Any]:
if DVC_STUDIO_URL in env:
config["url"] = env[DVC_STUDIO_URL]
return config
def get_subrepo_relpath(repo: "Repo") -> str:
from dvc.fs import GitFileSystem
scm_root_dir = "/" if isinstance(repo.fs, GitFileSystem) else repo.scm.root_dir
relpath = as_posix(repo.fs.relpath(repo.root_dir, scm_root_dir))
return "" if relpath == "." else relpath
+12 -3
View File
@@ -35,10 +35,10 @@ dependencies = [
"configobj>=5.0.6",
"distro>=1.3",
"dpath<3,>=2.1.0",
"dvc-data>=3.10,<3.12",
"dvc-data>=3.13,<3.15",
"dvc-http>=2.29.0",
"dvc-render>=1.0.1,<2",
"dvc-studio-client>=0.17.1,<1",
"dvc-studio-client>=0.20,<1",
"dvc-task>=0.3.0,<1",
"flatten_dict<1,>=0.4.1",
# https://github.com/iterative/dvc/issues/9654
@@ -102,8 +102,8 @@ tests = [
"beautifulsoup4>=4.4",
"dvc-ssh",
"filelock",
"flaky",
"pytest>=7,<9",
"pytest-rerunfailures",
"pytest-cov>=4.1.0",
"pytest-docker>=1,<4",
"pytest-mock",
@@ -130,6 +130,14 @@ dvc = "dvc.cli:main"
[project.entry-points."fsspec.specs"]
dvc = "dvc.api:DVCFileSystem"
[project.entry-points."universal_pathlib.implementations"]
dvc = "dvc.fs.dvc_path:DVCPath"
# universal_pathlib does not support fsspec url chaining yet.
# see https://github.com/fsspec/universal_pathlib/issues/28.
"dvc+http" = "dvc.fs.dvc_path:DVCPath"
"dvc+https" = "dvc.fs.dvc_path:DVCPath"
"dvc+ssh" = "dvc.fs.dvc_path:DVCPath"
[project.entry-points."pyinstaller40"]
hook-dirs = "dvc.__pyinstaller:get_hook_dirs"
tests = "dvc.__pyinstaller:get_PyInstaller_tests"
@@ -232,6 +240,7 @@ module = [
"ruamel.yaml.*",
"shortuuid",
"shtab",
"upath",
"virtualenv",
"viztracer",
"voluptuous",
+1 -1
View File
@@ -123,7 +123,7 @@ def pytest_runtest_setup(item):
if "CI" in os.environ and item.get_closest_marker("needs_internet") is not None:
# remotes that need internet connection might be flaky,
# so we rerun them in case it fails.
item.add_marker(pytest.mark.flaky(max_runs=5, min_passes=1))
item.add_marker(pytest.mark.flaky(reruns=5))
@pytest.fixture(scope="session")
+37
View File
@@ -372,3 +372,40 @@ def test_push_pull_invalid_workspace(
dvc.experiments.push(git_upstream.remote, push_cache=True)
dvc.experiments.pull(git_upstream.remote, pull_cache=True)
assert "failed to collect" not in caplog.text
@pytest.mark.parametrize(
"auto_push, expected_key", [(True, "up_to_date"), (False, "success")]
)
def test_auto_push_on_run(
tmp_dir, scm, dvc, git_upstream, local_remote, exp_stage, auto_push, expected_key
):
remote = git_upstream.remote
with dvc.config.edit() as conf:
conf["exp"]["auto_push"] = auto_push
conf["exp"]["git_remote"] = remote
exp_name = "foo"
dvc.experiments.run(exp_stage.addressing, params=["foo=2"], name=exp_name)
assert first(dvc.experiments.push(name=exp_name, git_remote=remote)) == expected_key
@pytest.mark.parametrize(
"auto_push, expected_key", [(True, "up_to_date"), (False, "success")]
)
def test_auto_push_on_save(
tmp_dir, scm, dvc, git_upstream, local_remote, exp_stage, auto_push, expected_key
):
remote = git_upstream.remote
exp_name = "foo"
dvc.experiments.run(exp_stage.addressing, params=["foo=2"], name=exp_name)
with dvc.config.edit() as conf:
conf["exp"]["auto_push"] = auto_push
conf["exp"]["git_remote"] = remote
dvc.experiments.save(name=exp_name, force=True)
assert first(dvc.experiments.push(name=exp_name, git_remote=remote)) == expected_key
+63
View File
@@ -0,0 +1,63 @@
import dpath
from dvc.repo.plots import Plots
def test_subdir_config_not_overwritten_by_parents(tmp_dir, scm, dvc):
plot_data = [
{"x": 1, "y": 0.1},
{"x": 2, "y": 0.2},
{"x": 3, "y": 0.3},
]
subdir_plot_data = [
{"x": 1, "y": 0.2, "z": 0.1},
{"x": 2, "y": 0.3, "z": 0.2},
{"x": 3, "y": 0.4, "z": 0.3},
]
(tmp_dir / "plots").mkdir()
(tmp_dir / "plots" / "subdir").mkdir()
(tmp_dir / "plots" / "plot.json").dump_json(plot_data)
(tmp_dir / "plots" / "subdir" / "plot.json").dump_json(subdir_plot_data)
plots_config = [
{
"plots/subdir/": {
"x": "z",
"y": "x",
}
},
{"plots": {"x": "x", "y": "y"}},
{
"subdir axis defined by filename": {
"x": {"plots/subdir/plot.json": "x"},
"y": {"plots/subdir/plot.json": "y"},
}
},
]
from dvc.utils.serialize import modify_yaml
with modify_yaml("dvc.yaml") as dvcfile_content:
dvcfile_content["plots"] = plots_config
scm.add(
[
"plots/plot.json",
"plots/subdir/plot.json",
"dvc.yaml",
]
)
scm.commit("add data sources")
plots = next(Plots(dvc).collect())
assert dpath.get(plots, "workspace/definitions/data/dvc.yaml/data") == {
"plots/plot.json": {"x": "x", "y": "y"},
"plots/subdir/plot.json": {"x": "z", "y": "x"},
"subdir axis defined by filename": {
"x": {"plots/subdir/plot.json": "x"},
"y": {"plots/subdir/plot.json": "y"},
},
}
+81
View File
@@ -172,6 +172,87 @@ def test_plots_show_overlap(tmp_dir, dvc, run_copy_metrics, clear_before_run):
)
def test_plots_show_nested_x_dict(tmp_dir, dvc, scm):
rel_pipeline_dir = "pipelines/data-increment"
pipeline_rel_dvclive_metrics_dir = "dvclive/plots/metrics"
dvc_rel_dvclive_metrics_dir = (
f"{rel_pipeline_dir}/{pipeline_rel_dvclive_metrics_dir}"
)
pipeline_dir = tmp_dir / rel_pipeline_dir
dvclive_metrics_dir = pipeline_dir / pipeline_rel_dvclive_metrics_dir
dvclive_metrics_dir.mkdir(parents=True)
def _get_plot_defn(rel_dir: str) -> dict:
return {
"template": "simple",
"x": {f"{rel_dir}/Max_Leaf_Nodes.tsv": "Max_Leaf_Nodes"},
"y": {f"{rel_dir}/Error.tsv": "Error"},
}
(pipeline_dir / "dvc.yaml").dump(
{
"plots": [
{
"Error vs max_leaf_nodes": _get_plot_defn(
pipeline_rel_dvclive_metrics_dir
)
},
]
},
)
dvclive_metrics_dir.gen(
{
"Error.tsv": "step\tError\n" "0\t0.11\n" "1\t0.22\n" "2\t0.44\n",
"Max_Leaf_Nodes.tsv": "step\tMax_Leaf_Nodes\n"
"0\t5\n"
"1\t50\n"
"2\t500\n",
}
)
scm.commit("add dvc.yaml and dvclive metrics")
result = dvc.plots.show()
assert result == {
"workspace": {
"definitions": {
"data": {
f"{rel_pipeline_dir}/dvc.yaml": {
"data": {
"Error vs max_leaf_nodes": _get_plot_defn(
dvc_rel_dvclive_metrics_dir
)
},
}
}
},
"sources": {
"data": {
f"{dvc_rel_dvclive_metrics_dir}/Error.tsv": {
"data": [
{"Error": "0.11", "step": "0"},
{"Error": "0.22", "step": "1"},
{"Error": "0.44", "step": "2"},
],
"props": {},
},
f"{dvc_rel_dvclive_metrics_dir}/Max_Leaf_Nodes.tsv": {
"data": [
{"Max_Leaf_Nodes": "5", "step": "0"},
{"Max_Leaf_Nodes": "50", "step": "1"},
{"Max_Leaf_Nodes": "500", "step": "2"},
],
"props": {},
},
}
},
}
}
def test_dir_plots(tmp_dir, dvc, run_copy_metrics):
subdir = tmp_dir / "subdir"
subdir.mkdir()
+1 -2
View File
@@ -3,7 +3,6 @@ import os
import shutil
import pytest
from flaky.flaky_decorator import flaky
import dvc_data
from dvc.cli import main
@@ -207,7 +206,7 @@ def test_verify_hashes(tmp_dir, scm, dvc, mocker, tmp_path_factory, local_remote
assert hash_spy.call_count == 10
@flaky(max_runs=3, min_passes=1)
@pytest.mark.flaky(reruns=3)
@pytest.mark.parametrize("erepo_type", ["git_dir", "erepo_dir"])
def test_pull_git_imports(request, tmp_dir, dvc, scm, erepo_type):
erepo = request.getfixturevalue(erepo_type)
+414
View File
@@ -0,0 +1,414 @@
import os
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import pytest
from attrs import define, evolve, field, has
from dvc.dependency.base import Dependency
from dvc.exceptions import ReproductionError
from dvc.repo.datasets import (
DatasetNotFoundError,
DatasetSpec,
DVCDataset,
DVCDatasetLock,
DVCDatasetSpec,
DVCXDataset,
DVCXDatasetLock,
FileInfo,
URLDataset,
URLDatasetLock,
)
from dvc_data.hashfile.meta import Meta
from dvc_data.index import HashInfo, Tree
if TYPE_CHECKING:
from dvc.repo import Repo
@define
class MockedDVCXVersionInfo:
version: int
created_at: datetime = field(factory=lambda: datetime.now(timezone.utc))
def evolve_recursive(inst, **changes):
"""Recursive attr.evolve() method, where any attr-based attributes
will be evolved too.
"""
for key, value in changes.items():
v = getattr(inst, key)
if has(type(v)) and isinstance(value, dict):
value = evolve_recursive(v, **value)
changes[key] = value
return evolve(inst, **changes)
def test_dvc(tmp_dir, scm, dvc: "Repo"):
datasets = dvc.datasets
tmp_dir.scm_gen("file", "file", commit="add file")
dataset = datasets.add("mydataset", tmp_dir.fs_path, "dvc", path="file")
expected = DVCDataset(
manifest_path=(tmp_dir / "dvc.yaml").fs_path,
spec=DVCDatasetSpec(
name="mydataset", url=tmp_dir.fs_path, type="dvc", path="file"
),
lock=DVCDatasetLock(
name="mydataset",
url=tmp_dir.fs_path,
type="dvc",
path="file",
rev_lock=scm.get_rev(),
),
)
assert "mydataset" in datasets
assert dataset == datasets["mydataset"] == expected
tmp_dir.scm_gen("file", "file", commit="update file")
old, new = datasets.update("mydataset")
assert old == dataset
assert old != new
expected = evolve_recursive(expected, lock={"rev_lock": scm.get_rev()})
assert new == datasets["mydataset"] == expected
# noop
old, new = datasets.update("mydataset")
assert old == new
def test_dvcx(tmp_dir, dvc, mocker):
datasets = dvc.datasets
version_info = [MockedDVCXVersionInfo(1), MockedDVCXVersionInfo(2)]
version_info.append(version_info[1])
mocker.patch("dvc.repo.datasets._get_dataset_info", side_effect=version_info)
dataset = datasets.add("mydataset", "dataset", "dvcx")
expected = DVCXDataset(
manifest_path=(tmp_dir / "dvc.yaml").fs_path,
spec=DatasetSpec(name="mydataset", url="dataset", type="dvcx"),
lock=DVCXDatasetLock(
name="mydataset",
url="dataset",
type="dvcx",
version=1,
created_at=version_info[0].created_at,
),
)
assert "mydataset" in datasets
assert dataset == datasets["mydataset"] == expected
old, new = datasets.update("mydataset")
assert old == dataset
assert old != new
expected = evolve_recursive(
expected, lock={"version": 2, "created_at": version_info[1].created_at}
)
assert new == datasets["mydataset"] == expected
# noop
old, new = datasets.update("mydataset")
assert old == new
def test_url(tmp_dir, dvc, mocker):
datasets = dvc.datasets
tree = Tree()
tree.add(("foo",), Meta(version_id="1"), None)
tree.digest(with_meta=True)
tree_meta = Meta(isdir=True)
def mocked_save(d):
d.meta, d.obj, d.hash_info = tree_meta, tree, HashInfo("md5", "value.dir")
mocker.patch.object(Dependency, "save", mocked_save)
dataset = datasets.add("mydataset", "s3://dataset", "url")
expected = URLDataset(
manifest_path=(tmp_dir / "dvc.yaml").fs_path,
spec=DatasetSpec(name="mydataset", url="s3://dataset", type="url"),
lock=URLDatasetLock(
name="mydataset",
url="s3://dataset",
type="url",
meta=Meta(isdir=True),
files=[FileInfo(relpath="foo", meta=Meta(version_id="1"))],
),
)
assert "mydataset" in datasets
assert dataset == datasets["mydataset"] == expected
tree.add(("bar",), Meta(version_id="2"), None)
old, new = datasets.update("mydataset")
assert old == dataset
assert old != new
assert expected.lock
new_files = [
*expected.lock.files,
FileInfo(relpath="bar", meta=Meta(version_id="2")),
]
expected = evolve_recursive(expected, lock={"files": new_files})
assert new == datasets["mydataset"] == expected
# noop
old, new = datasets.update("mydataset")
assert old == new
def test_dvc_dump(tmp_dir, dvc):
manifest_path = os.path.join(tmp_dir, "dvc.yaml")
spec = DVCDatasetSpec(
name="mydataset", url=tmp_dir.fs_path, type="dvc", path="path", rev="main"
)
lock = DVCDatasetLock(rev_lock="0" * 40, **spec.to_dict())
dataset = DVCDataset(manifest_path=manifest_path, spec=spec, lock=lock)
dvc.datasets.dump(dataset)
spec_d = {
"name": "mydataset",
"type": "dvc",
"url": tmp_dir.fs_path,
"path": "path",
"rev": "main",
}
assert (tmp_dir / "dvc.yaml").parse() == {"datasets": [spec_d]}
assert (tmp_dir / "dvc.lock").parse() == {
"schema": "2.0",
"stages": {},
"datasets": [{**spec_d, "rev_lock": "0" * 40}],
}
dvc._reset()
assert "_datasets" not in vars(dvc.datasets)
# test that we can read them back
assert dvc.datasets["mydataset"] == dataset
def test_dvcx_dump(tmp_dir, dvc):
manifest_path = os.path.join(tmp_dir, "dvc.yaml")
spec = DatasetSpec(name="mydataset", url="dataset", type="dvcx")
dt = datetime.now(tz=timezone.utc)
lock = DVCXDatasetLock(version=1, created_at=dt, **spec.to_dict())
dataset = DVCXDataset(manifest_path=manifest_path, spec=spec, lock=lock)
dvc.datasets.dump(dataset)
spec_d = {"name": "mydataset", "type": "dvcx", "url": "dataset"}
assert (tmp_dir / "dvc.yaml").parse() == {"datasets": [spec_d]}
assert (tmp_dir / "dvc.lock").parse() == {
"schema": "2.0",
"stages": {},
"datasets": [{**spec_d, "version": 1, "created_at": dt.isoformat()}],
}
dvc._reset()
assert "_datasets" not in vars(dvc.datasets)
# test that we can read them back
assert dvc.datasets["mydataset"] == dataset
def test_url_dump(tmp_dir, dvc):
manifest_path = os.path.join(tmp_dir, "dvc.yaml")
spec = DatasetSpec(name="mydataset", url="s3://dataset", type="url")
files = [FileInfo(relpath="foo", meta=Meta(version_id="1"))]
lock = URLDatasetLock(meta=Meta(isdir=True), files=files, **spec.to_dict())
dataset = URLDataset(manifest_path=manifest_path, spec=spec, lock=lock)
dvc.datasets.dump(dataset)
spec_d = {"name": "mydataset", "url": "s3://dataset", "type": "url"}
assert (tmp_dir / "dvc.yaml").parse() == {"datasets": [spec_d]}
assert (tmp_dir / "dvc.lock").parse() == {
"schema": "2.0",
"datasets": [
{
**spec_d,
"meta": {"isdir": True},
"files": [{"relpath": "foo", "meta": {"version_id": "1"}}],
}
],
"stages": {},
}
dvc._reset()
assert "_datasets" not in vars(dvc.datasets)
# test that we can read them back
assert dvc.datasets["mydataset"] == dataset
def test_invalidation(tmp_dir, dvc):
manifest_path = os.path.join(tmp_dir, "dvc.yaml")
spec = DatasetSpec(name="mydataset", url="url1", type="url")
lock = DVCXDatasetLock(
name="mydataset",
url="dataset",
type="dvcx",
version=1,
created_at=datetime.now(tz=timezone.utc),
)
dvc.datasets._dump_spec(manifest_path, spec)
dvc.datasets._dump_lock(manifest_path, lock)
assert dvc.datasets["mydataset"] == URLDataset(
manifest_path=manifest_path,
spec=spec,
lock=None, # lock should be discarded
)
def test_dvc_dataset_pipeline(tmp_dir, dvc, scm):
dvc.datasets.add("mydataset", tmp_dir.fs_path, "dvc")
stage = dvc.stage.add(cmd="echo", name="train", deps=["ds://mydataset"])
assert (tmp_dir / "dvc.yaml").parse() == {
"datasets": [{"name": "mydataset", "url": tmp_dir.fs_path, "type": "dvc"}],
"stages": {"train": {"cmd": "echo", "deps": ["ds://mydataset"]}},
}
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "new"}}]}
assert dvc.reproduce() == [stage]
d = (tmp_dir / "dvc.lock").parse()
assert d["stages"]["train"]["deps"][0] == {
"path": "ds://mydataset",
"dataset": d["datasets"][0],
}
assert dvc.status() == {}
assert dvc.reproduce() == []
tmp_dir.scm_gen("foo", "foo", commit="add foo")
dvc.datasets.update("mydataset")
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "modified"}}]}
assert dvc.reproduce() == [stage]
def test_dvcx_dataset_pipeline(mocker, tmp_dir, dvc):
version_info = [MockedDVCXVersionInfo(1), MockedDVCXVersionInfo(2)]
mocker.patch("dvc.repo.datasets._get_dataset_info", side_effect=version_info)
dvc.datasets.add("mydataset", "dataset", "dvcx")
stage = dvc.stage.add(cmd="echo", name="train", deps=["ds://mydataset"])
assert (tmp_dir / "dvc.yaml").parse() == {
"datasets": [{"name": "mydataset", "url": "dataset", "type": "dvcx"}],
"stages": {"train": {"cmd": "echo", "deps": ["ds://mydataset"]}},
}
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "new"}}]}
assert dvc.reproduce() == [stage]
d = (tmp_dir / "dvc.lock").parse()
assert d["stages"]["train"]["deps"][0] == {
"path": "ds://mydataset",
"dataset": d["datasets"][0],
}
assert dvc.status() == {}
assert dvc.reproduce() == []
dvc.datasets.update("mydataset")
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "modified"}}]}
assert dvc.reproduce() == [stage]
def test_url_dataset_pipeline(mocker, tmp_dir, dvc):
tree = Tree()
tree.add(("foo",), Meta(version_id="1"), None)
tree.digest(with_meta=True)
tree_meta = Meta(isdir=True)
def mocked_save(d):
d.meta, d.obj, d.hash_info = tree_meta, tree, HashInfo("md5", "value.dir")
mocker.patch.object(Dependency, "save", mocked_save)
dvc.datasets.add("mydataset", "s3://mydataset", "url")
stage = dvc.stage.add(cmd="echo", name="train", deps=["ds://mydataset"])
assert (tmp_dir / "dvc.yaml").parse() == {
"datasets": [{"name": "mydataset", "url": "s3://mydataset", "type": "url"}],
"stages": {"train": {"cmd": "echo", "deps": ["ds://mydataset"]}},
}
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "new"}}]}
assert dvc.reproduce() == [stage]
d = (tmp_dir / "dvc.lock").parse()
assert d["stages"]["train"]["deps"][0] == {
"path": "ds://mydataset",
"dataset": d["datasets"][0],
}
assert dvc.status() == {}
assert dvc.reproduce() == []
tree.add(("bar",), Meta(version_id="2"), None)
dvc.datasets.update("mydataset")
assert dvc.status() == {"train": [{"changed deps": {"ds://mydataset": "modified"}}]}
assert dvc.reproduce() == [stage]
def test_pipeline_when_not_in_sync(tmp_dir, dvc):
manifest_path = os.path.join(tmp_dir, "dvc.yaml")
spec = DatasetSpec(name="mydataset", url="url1", type="url")
lock = DVCXDatasetLock(
name="mydataset",
url="dataset",
type="dvcx",
version=1,
created_at=datetime.now(tz=timezone.utc),
)
dvc.datasets._dump_spec(manifest_path, spec)
dvc.datasets._dump_lock(manifest_path, lock)
dvc.stage.add(name="train", cmd="echo", deps=["ds://mydataset"])
assert dvc.status() == {
"train": [{"changed deps": {"ds://mydataset": "not in sync"}}]
}
with pytest.raises(ReproductionError) as exc:
dvc.reproduce()
assert "not in sync" in str(exc.value.__cause__)
def test_collect(tmp_dir, dvc):
manifest_path1 = os.path.join(tmp_dir, "dvc.yaml")
dt = datetime.now(tz=timezone.utc)
spec = DatasetSpec(name="mydataset1", url="url1", type="dvcx")
lock = DVCXDatasetLock(version=1, created_at=dt, **spec.to_dict())
mydataset1 = DVCXDataset(manifest_path=manifest_path1, spec=spec, lock=lock)
dvc.datasets.dump(mydataset1)
(tmp_dir / "sub").mkdir()
manifest_path2 = os.path.join(tmp_dir, "sub", "dvc.yaml")
spec = DVCDatasetSpec(
name="mydataset2", url=tmp_dir.fs_path, type="dvc", path="path"
)
lock = DVCDatasetLock(rev_lock="0" * 40, **spec.to_dict())
mydataset2 = DVCDataset(manifest_path=manifest_path2, spec=spec, lock=lock)
dvc.datasets.dump(mydataset2)
dvc._reset()
assert "_datasets" not in vars(dvc.datasets)
assert len(dvc.datasets) == 2
assert "mydataset1" in dvc.datasets
assert "mydataset2" in dvc.datasets
assert list(iter(dvc.datasets)) == ["mydataset1", "mydataset2"]
assert dvc.datasets["mydataset1"] == mydataset1
assert dvc.datasets["mydataset2"] == mydataset2
assert dict(dvc.datasets.items()) == {
"mydataset1": mydataset1,
"mydataset2": mydataset2,
}
with pytest.raises(DatasetNotFoundError, match=r"^dataset not found$"):
dvc.datasets["not-existing"]
+65 -6
View File
@@ -86,15 +86,27 @@ def repo_with_plots(tmp_dir, scm, dvc, run_copy_metrics):
@pytest.fixture
def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
def make():
# test subdir functionality
linear_subdir_a_v1 = [
{"x": 1, "y": 0.2},
{"x": 2, "y": 0.3},
{"x": 3, "y": 0.4},
]
# test subdir default values for x = step
linear_subdir_b_v1 = [
{"step": 1, "y": 0.2},
{"step": 2, "y": 0.3},
{"step": 3, "y": 0.4},
]
linear_train_v1 = [
{"x": 1, "y": 0.1},
{"x": 2, "y": 0.2},
{"x": 3, "y": 0.3},
]
linear_test_v1 = [
{"x": 1, "y": 0.2},
{"x": 2, "y": 0.3},
{"x": 3, "y": 0.4},
{"x": 1, "y": 0.3},
{"x": 2, "y": 0.4},
{"x": 3, "y": 0.5},
]
confusion_train_v1 = [
@@ -110,6 +122,8 @@ def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
{"actual": 1, "predicted": 0},
]
(tmp_dir / "linear_subdir_a_src.json").dump_json(linear_subdir_a_v1)
(tmp_dir / "linear_subdir_b_src.json").dump_json(linear_subdir_b_v1)
(tmp_dir / "linear_train_src.json").dump_json(linear_train_v1)
(tmp_dir / "linear_test_src.json").dump_json(linear_test_v1)
(tmp_dir / "confusion_train_src.json").dump_json(confusion_train_v1)
@@ -117,6 +131,8 @@ def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
scm.add(
[
"linear_subdir_a_src.json",
"linear_subdir_b_src.json",
"linear_train_src.json",
"linear_test_src.json",
"confusion_train_src.json",
@@ -125,6 +141,23 @@ def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
)
scm.commit("add data sources")
(tmp_dir / "subdirA").mkdir()
(tmp_dir / "subdirB").mkdir()
run_copy_metrics(
"linear_subdir_a_src.json",
"subdirA/linear_subdir.json",
name="linear_subdir",
outs=["subdirA/linear_subdir.json"],
commit="linear_subdir",
)
run_copy_metrics(
"linear_subdir_b_src.json",
"subdirB/linear_subdir.json",
name="linear_subdir",
outs=["subdirB/linear_subdir.json"],
commit="linear_subdir",
)
run_copy_metrics(
"linear_train_src.json",
"linear_train.json",
@@ -153,7 +186,16 @@ def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
outs=["confusion_test.json"],
commit="confusion_test",
)
plots_config = [
subdir_a_config = {
"x": "x",
"y": "y",
"title": "subdir plots with x and y defined",
}
subdir_b_config = {"title": "subdir plots with default x and y"}
other_plots_config = [
{
"linear_train_vs_test": {
"x": "x",
@@ -176,18 +218,35 @@ def repo_with_config_plots(tmp_dir, scm, dvc, run_copy_metrics):
from dvc.utils.serialize import modify_yaml
with modify_yaml("dvc.yaml") as dvcfile_content:
dvcfile_content["plots"] = plots_config
dvcfile_content["plots"] = [
{"subdirA": subdir_a_config},
{"subdirB": subdir_b_config},
*other_plots_config,
]
scm.add(["dvc.yaml", "dvc.lock"])
scm.commit("commit dvc files")
# remove generate .gitignore that are considered as plot otherwise
(tmp_dir / "subdirA" / ".gitignore").unlink()
(tmp_dir / "subdirB" / ".gitignore").unlink()
yield {
"data": {
"subdirA/linear_subdir.json": linear_subdir_a_v1,
"subdirB/linear_subdir.json": linear_subdir_b_v1,
"linear_train.json": linear_train_v1,
"linear_test.json": linear_test_v1,
"confusion_train.json": confusion_train_v1,
"confusion_test.json": confusion_test_v1,
},
"configs": {"dvc.yaml": plots_config},
"configs": {
"dvc.yaml": [
{"subdirA/linear_subdir.json": subdir_a_config},
{"subdirB/linear_subdir.json": subdir_b_config},
*other_plots_config,
]
},
}
return make
+153
View File
@@ -440,6 +440,8 @@ def test_repo_with_config_plots(tmp_dir, capsys, repo_with_config_plots):
html_result = extract_vega_specs(
html_path,
[
"subdirA/linear_subdir.json",
"subdirB/linear_subdir.json",
"linear_train_vs_test",
"confusion_train_vs_test",
],
@@ -466,6 +468,22 @@ def test_repo_with_config_plots(tmp_dir, capsys, repo_with_config_plots):
]
== ble
)
assert (
html_result["subdirA/linear_subdir.json"]["layer"][1]["encoding"]["x"]["field"]
== "x"
)
assert (
html_result["subdirA/linear_subdir.json"]["layer"][1]["encoding"]["y"]["field"]
== "y"
)
assert (
html_result["subdirB/linear_subdir.json"]["layer"][1]["encoding"]["x"]["field"]
== "step"
)
assert (
html_result["subdirB/linear_subdir.json"]["layer"][1]["encoding"]["y"]["field"]
== "y"
)
@pytest.mark.vscode
@@ -481,3 +499,138 @@ def test_repo_with_dvclive_plots(tmp_dir, capsys, repo_with_dvclive_plots):
}
assert json_result == expected_result
assert split_json_result == expected_result
@pytest.mark.vscode
def test_nested_x_defn_collection(tmp_dir, dvc, scm, capsys):
rel_pipeline_dir = "pipelines/data-increment"
pipeline_rel_dvclive_metrics_dir = "dvclive/plots/metrics"
pipeline_rel_other_logger_dir = "other/logger"
dvc_rel_dvclive_metrics_dir = (
f"{rel_pipeline_dir}/{pipeline_rel_dvclive_metrics_dir}"
)
dvc_rel_other_logger_dir = f"{rel_pipeline_dir}/{pipeline_rel_other_logger_dir}"
pipeline_dir = tmp_dir / rel_pipeline_dir
dvclive_metrics_dir = pipeline_dir / pipeline_rel_dvclive_metrics_dir
dvclive_metrics_dir.mkdir(parents=True)
other_logger_dir = pipeline_dir / pipeline_rel_other_logger_dir
other_logger_dir.mkdir(parents=True)
(pipeline_dir / "dvc.yaml").dump(
{
"plots": [
{
"Error vs max_leaf_nodes": {
"template": "simple",
"x": {
f"{pipeline_rel_dvclive_metrics_dir}"
"/Max_Leaf_Nodes.tsv": "Max_Leaf_Nodes"
},
"y": {f"{pipeline_rel_dvclive_metrics_dir}/Error.tsv": "Error"},
}
},
{
f"{pipeline_rel_other_logger_dir}/multiple_metrics.json": {
"x": "x",
"y": ["y1", "y2"],
},
},
{
f"{pipeline_rel_dvclive_metrics_dir}/Error.tsv": {"y": ["Error"]},
},
{
"max leaf nodes": {
"y": {
f"{pipeline_rel_dvclive_metrics_dir}"
"/Max_Leaf_Nodes.tsv": "Max_Leaf_Nodes"
}
},
},
]
},
)
dvclive_metrics_dir.gen(
{
"Error.tsv": "step\tError\n" "0\t0.11\n" "1\t0.22\n" "2\t0.44\n",
"Max_Leaf_Nodes.tsv": "step\tMax_Leaf_Nodes\n"
"0\t5\n"
"1\t50\n"
"2\t500\n",
}
)
(other_logger_dir / "multiple_metrics.json").dump(
[
{"x": 0, "y1": 0.1, "y2": 10},
{"x": 1, "y1": 0.2, "y2": 22},
]
)
scm.commit("add dvc.yaml and metrics")
_, _, split_json_result = call(capsys, subcommand="diff")
assert len(split_json_result.keys()) == 1
assert len(split_json_result["data"].keys()) == 4
separate_x_file = split_json_result["data"]["Error vs max_leaf_nodes"][0]
assert separate_x_file["anchor_definitions"]["<DVC_METRIC_DATA>"] == [
{"Error": "0.11", "Max_Leaf_Nodes": "5", "step": "0", "rev": "workspace"},
{"Error": "0.22", "Max_Leaf_Nodes": "50", "step": "1", "rev": "workspace"},
{"Error": "0.44", "Max_Leaf_Nodes": "500", "step": "2", "rev": "workspace"},
]
same_x_file = split_json_result["data"][
f"{dvc_rel_other_logger_dir}/multiple_metrics.json"
][0]
assert same_x_file["anchor_definitions"]["<DVC_METRIC_DATA>"] == [
{
"x": 0,
"y1": 0.1,
"y2": 10,
"dvc_inferred_y_value": 0.1,
"field": "y1",
"rev": "workspace",
},
{
"x": 1,
"y1": 0.2,
"y2": 22,
"dvc_inferred_y_value": 0.2,
"field": "y1",
"rev": "workspace",
},
{
"x": 0,
"y1": 0.1,
"y2": 10,
"dvc_inferred_y_value": 10,
"field": "y2",
"rev": "workspace",
},
{
"x": 1,
"y1": 0.2,
"y2": 22,
"dvc_inferred_y_value": 22,
"field": "y2",
"rev": "workspace",
},
]
inferred_x_from_str = split_json_result["data"][
f"{dvc_rel_dvclive_metrics_dir}/Error.tsv"
][0]
assert inferred_x_from_str["anchor_definitions"]["<DVC_METRIC_DATA>"] == [
{"step": 0, "Error": "0.11", "rev": "workspace"},
{"step": 1, "Error": "0.22", "rev": "workspace"},
{"step": 2, "Error": "0.44", "rev": "workspace"},
]
inferred_x_from_dict = split_json_result["data"]["max leaf nodes"][0]
assert inferred_x_from_dict["anchor_definitions"]["<DVC_METRIC_DATA>"] == [
{"step": 0, "Max_Leaf_Nodes": "5", "rev": "workspace"},
{"step": 1, "Max_Leaf_Nodes": "50", "rev": "workspace"},
{"step": 2, "Max_Leaf_Nodes": "500", "rev": "workspace"},
]
+103 -10
View File
@@ -8,8 +8,12 @@ from dvc.env import (
DVC_STUDIO_TOKEN,
DVC_STUDIO_URL,
)
from dvc.repo import Repo
from dvc.testing.scripts import COPY_SCRIPT
from dvc.utils.studio import get_subrepo_relpath
@pytest.mark.studio
@pytest.mark.parametrize("tmp", [True, False])
@pytest.mark.parametrize("offline", [True, False])
def test_post_to_studio(
@@ -66,10 +70,45 @@ def test_post_to_studio(
}
@pytest.mark.studio
@pytest.mark.parametrize("tmp", [True, False])
def test_post_to_studio_custom_message(
tmp_dir, dvc, scm, exp_stage, mocker, monkeypatch, tmp
):
def test_post_to_studio_subdir(tmp_dir, scm, mocker, monkeypatch, tmp):
live_exp_subdir = "project_a"
tmp_dir.scm_gen(
{
live_exp_subdir: {
"params.yaml": "foo: 1",
"metrics.yaml": "foo: 1",
"copy.py": COPY_SCRIPT.encode("utf-8"),
},
},
commit="git init",
)
project_a_dvc = Repo.init(tmp_dir / live_exp_subdir, subdir=True)
with monkeypatch.context() as m:
m.chdir(project_a_dvc.root_dir)
exp_stage = project_a_dvc.run(
cmd="python copy.py params.yaml metrics.yaml",
metrics_no_cache=["metrics.yaml"],
params=["foo"],
name="copy-file",
)
scm.add(
[
".gitignore",
"copy.py",
"dvc.lock",
"dvc.yaml",
"metrics.yaml",
"params.yaml",
]
)
scm.commit("dvc init project_a")
valid_response = mocker.MagicMock()
valid_response.status_code = 200
mocked_post = mocker.patch("requests.post", return_value=valid_response)
@@ -79,12 +118,16 @@ def test_post_to_studio_custom_message(
monkeypatch.setenv(env.STUDIO_TOKEN, "STUDIO_TOKEN")
baseline_sha = scm.get_rev()
exp_rev = first(
dvc.experiments.run(
exp_stage.addressing, params=["foo=1"], tmp_dir=tmp, message="foo"
with monkeypatch.context() as m:
m.chdir(project_a_dvc.root_dir)
exp_rev = first(
project_a_dvc.experiments.run(
exp_stage.addressing, params=["foo=24"], tmp_dir=tmp
)
)
)
name = dvc.experiments.get_exact_name([exp_rev])[exp_rev]
name = project_a_dvc.experiments.get_exact_name([exp_rev])[exp_rev]
project_a_dvc.close()
assert mocked_post.call_count == 2
start_call = mocked_post.call_args_list[0]
@@ -94,7 +137,57 @@ def test_post_to_studio_custom_message(
"repo_url": "STUDIO_REPO_URL",
"baseline_sha": baseline_sha,
"name": name,
"params": {"params.yaml": {"foo": 1}},
"params": {"params.yaml": {"foo": 24}},
"subdir": live_exp_subdir,
"client": "dvc",
"message": "foo",
}
@pytest.mark.studio
def test_monorepo_relpath(tmp_dir, scm):
from dvc.repo.destroy import destroy
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
non_monorepo = Repo.init(tmp_dir)
assert get_subrepo_relpath(non_monorepo) == ""
destroy(non_monorepo)
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
assert get_subrepo_relpath(monorepo_project_a) == "project_a"
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
assert get_subrepo_relpath(monorepo_project_b) == "subdir/project_b"
@pytest.mark.studio
def test_virtual_monorepo_relpath(tmp_dir, scm):
from dvc.fs.git import GitFileSystem
from dvc.repo.destroy import destroy
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
scm.commit("initial commit")
gfs = GitFileSystem(scm=scm, rev="master")
non_monorepo = Repo.init(tmp_dir)
non_monorepo.fs = gfs
non_monorepo.root_dir = "/"
assert get_subrepo_relpath(non_monorepo) == ""
destroy(non_monorepo)
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
monorepo_project_a.fs = gfs
monorepo_project_a.root_dir = "/project_a"
assert get_subrepo_relpath(monorepo_project_a) == "project_a"
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
monorepo_project_b.fs = gfs
monorepo_project_b.root_dir = "/subdir/project_b"
assert get_subrepo_relpath(monorepo_project_b) == "subdir/project_b"
+137
View File
@@ -0,0 +1,137 @@
from datetime import datetime
import pytest
from dvc.cli import main
@pytest.mark.parametrize(
"spec,lock,expected_output",
[
(
{"name": "ds", "url": "url", "type": "dvc", "path": "path", "rev": "main"},
{"rev_lock": "0" * 40},
"Adding ds (url:/path @ main)\n",
),
(
{"name": "mydataset", "url": "dvcx://dataset", "type": "dvcx"},
{"rev_lock": "0" * 40, "version": 1, "created_at": datetime.now()},
"Adding mydataset (dvcx://dataset @ v1)\n",
),
(
{"name": "mydataset", "url": "s3://bucket/path", "type": "url"},
{
"files": [{"relpath": "foo", "meta": {"version_id": 1}}],
"meta": {"isdir": True},
},
"Adding mydataset (s3://bucket/path)\n",
),
],
)
def test_add(dvc, capsys, mocker, spec, lock, expected_output):
dataset = dvc.datasets._build_dataset("dvc.yaml", spec, spec | lock)
m = mocker.patch("dvc.repo.datasets.Datasets.add", return_value=dataset)
assert main(["dataset", "add", spec["name"], f"--{spec['type']}", spec["url"]]) == 0
out, err = capsys.readouterr()
assert out == expected_output
assert not err
m.assert_called_once()
def test_add_already_exists(dvc, caplog, mocker):
spec = {"name": "ds", "url": "url", "type": "dvc"}
dataset = dvc.datasets._build_dataset("dvc.yaml", spec, None)
mocker.patch("dvc.repo.datasets.Datasets.get", return_value=dataset)
assert main(["dataset", "add", "ds", "--dvcx", "dataset"]) == 255
assert "ds already exists in dvc.yaml, use the --force to overwrite" in caplog.text
@pytest.mark.parametrize("lock", ["missing", "unchanged", "updated"])
@pytest.mark.parametrize(
"spec,old_lock,new_lock,expected_outputs",
[
(
{"name": "ds", "url": "url", "type": "dvc", "path": "path", "rev": "main"},
{"rev_lock": "0" * 40},
{"rev_lock": "1" * 40},
{
"missing": "Updating ds (url:/path @ main)\n",
"unchanged": "Nothing to update\n",
"updated": "Updating ds (000000000 -> 111111111)\n",
},
),
(
{"name": "mydataset", "url": "dvcx://dataset", "type": "dvcx"},
{"rev_lock": "0" * 40, "version": 1, "created_at": datetime.now()},
{"version": 2},
{
"missing": "Updating mydataset (dvcx://dataset @ v2)\n",
"unchanged": "Nothing to update\n",
"updated": "Updating mydataset (v1 -> v2)\n",
},
),
(
{"name": "mydataset", "url": "dvcx://dataset", "type": "dvcx"},
{"rev_lock": "0" * 40, "version": 2, "created_at": datetime.now()},
{"version": 1},
{
"missing": "Updating mydataset (dvcx://dataset @ v1)\n",
"unchanged": "Nothing to update\n",
"updated": "Downgrading mydataset (v2 -> v1)\n",
},
),
(
{"name": "mydataset", "url": "s3://bucket/path", "type": "url"},
{
"files": [
{"relpath": "bar", "meta": {"version_id": 2}},
{"relpath": "baz", "meta": {"version_id": 3}},
{"relpath": "foo", "meta": {"version_id": 1}},
],
"meta": {"isdir": True},
},
{
"files": [
{"relpath": "bar", "meta": {"version_id": 2}}, # unchanged
{"relpath": "baz", "meta": {"version_id": 4}}, # modified
# `foo` deleted
{"relpath": "foobar", "meta": {"version_id": 5}}, # new
],
"meta": {"isdir": True},
},
{
"missing": "Updating mydataset (s3://bucket/path)\n",
"unchanged": "Nothing to update\n",
"updated": (
"Updating mydataset (s3://bucket/path)\n"
"M\tbaz\n"
"A\tfoobar\n"
"D\tfoo\n"
).expandtabs(8),
},
),
],
)
def test_update(dvc, capsys, mocker, spec, old_lock, new_lock, expected_outputs, lock):
if lock == "missing":
new_lock = spec | old_lock | new_lock
old_lock = None
elif lock == "unchanged":
old_lock = new_lock = spec | old_lock
else:
old_lock = spec | old_lock
new_lock = old_lock | new_lock
old = dvc.datasets._build_dataset("dvc.yaml", spec, old_lock)
new = dvc.datasets._build_dataset("dvc.yaml", spec, new_lock)
m = mocker.patch("dvc.repo.datasets.Datasets.update", return_value=(old, new))
assert main(["dataset", "update", spec["name"]]) == 0
out, err = capsys.readouterr()
assert out == expected_outputs[lock]
assert not err
m.assert_called_once()
+1 -1
View File
@@ -29,7 +29,7 @@ def command_tuples():
# the no. of commands will usually go up,
# but if we ever remove commands and drop below, adjust the magic number accordingly
assert len(commands) >= 112
assert len(commands) >= 116
return sorted(commands)
+4 -4
View File
@@ -1,4 +1,4 @@
from dvc_studio_client.auth import AuthenticationExpired
from dvc_studio_client.auth import AuthorizationExpiredError
from dvc.cli import main
from dvc.utils.studio import STUDIO_URL
@@ -6,9 +6,9 @@ from dvc.utils.studio import STUDIO_URL
def test_studio_login_token_check_failed(mocker):
mocker.patch(
"dvc_studio_client.auth.get_access_token", side_effect=AuthenticationExpired
"dvc_studio_client.auth.get_access_token",
side_effect=AuthorizationExpiredError,
)
assert main(["studio", "login"]) == 1
@@ -52,8 +52,8 @@ def test_studio_login_arguments(mocker):
token_name="token_name",
hostname="https://example.com",
scopes="experiments",
use_device_code=True,
client_name="DVC",
open_browser=False,
)
-48
View File
@@ -135,51 +135,3 @@ def test_dynamic_cache_initialization(tmp_dir, scm):
dvc.close()
Repo(str(tmp_dir)).close()
def test_monorepo_relpath(tmp_dir, scm):
from dvc.repo.destroy import destroy
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
non_monorepo = Repo.init(tmp_dir)
assert non_monorepo.subrepo_relpath == ""
destroy(non_monorepo)
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
assert monorepo_project_a.subrepo_relpath == "project_a"
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
assert monorepo_project_b.subrepo_relpath == "subdir/project_b"
def test_virtual_monorepo_relpath(tmp_dir, scm):
from dvc.fs.git import GitFileSystem
from dvc.repo.destroy import destroy
tmp_dir.gen({"project_a": {}, "subdir/project_b": {}})
scm.commit("initial commit")
gfs = GitFileSystem(scm=scm, rev="master")
non_monorepo = Repo.init(tmp_dir)
non_monorepo.fs = gfs
non_monorepo.root_dir = "/"
assert non_monorepo.subrepo_relpath == ""
destroy(non_monorepo)
monorepo_project_a = Repo.init(tmp_dir / "project_a", subdir=True)
monorepo_project_a.fs = gfs
monorepo_project_a.root_dir = "/project_a"
assert monorepo_project_a.subrepo_relpath == "project_a"
monorepo_project_b = Repo.init(tmp_dir / "subdir" / "project_b", subdir=True)
monorepo_project_b.fs = gfs
monorepo_project_b.root_dir = "/subdir/project_b"
assert monorepo_project_b.subrepo_relpath == "subdir/project_b"