Compare commits

...
34 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 1cf8b5e582 install: pre-commit: install all deps by default
Fixes #9178
2023-03-15 01:19:42 +02:00
Dave Berenbaum 12955da3c3 exp: fail fast in empty git repo (#8840) 2023-03-14 17:16:17 +00:00
David de la Iglesia Castro e73cc9b40a exp: Include STUDIO_REPO_URL in run_env.
Closes #8951.
2023-03-14 13:43:03 +01:00
Ruslan Kuprieiev b56f6b69dd exp: push/pull: don't include baseline commit
Fixes #6592
2023-03-14 14:00:14 +02:00
Ruslan Kuprieiev 54a6709d99 Update pyproject.toml 2023-03-13 21:03:33 +02:00
Ruslan Kuprieiev e4a5a2aec5 deps: bump dvc-data to >=0.43.0 2023-03-13 21:03:33 +02:00
skshetry 16ecd53b71 tests: remove gc.collect() from windows (#9159) 2023-03-13 13:37:57 +05:45
Ruslan Kuprieiev 612f3039e0 repo: move state/index/data_index to site_cache_dir
We use all of these for optimization and should not depend on a potentially slow or broken
filesystem on which repository resides (could be HDD or CIFS/NFS/overlayfs/etc).

This PR means a few things:
    * all odd filesystems that dvc repo might be on will work quickly out of the box
    * when we start caching cloned repositories for `dvc get/import`, we will be
      able to cache indexes for them as well (note that bare clones don't have a
      worktree so there is no place for .dvc/tmp with the old approach).
    * `index.dir` and `state.dir` are now obsolete.
    * `dvc init` will cleanup site_cache_dir.

Related #5562
2023-03-13 02:58:23 +02:00
Ruslan Kuprieiev 17f523bf86 deps: bump dvc-data to >=0.42.3 2023-03-11 02:01:31 +02:00
Ruslan Kuprieiev a0bf2d997a repo: data_index: fallback to in-memory
Makes for a much simpler resource management elsewhere.
2023-03-11 01:23:12 +02:00
Ruslan Kuprieiev 8ed9b911db deps: bump dvc-data to >= 0.42.2 2023-03-10 22:40:59 +02:00
skshetry ef22b1d77e plots: disable syntax highlighting on plots json output (#9155)
I don't think it's worth it to highlight given the size of json output.
2023-03-11 00:50:06 +05:45
skshetry aaa825a9a8 config: ignore unspecified keys from feature section (#9153)
A warning is shown to the user for the unsupported/ignored keys, and then ignored.
The warning is raised only once per interpreter session.
2023-03-10 21:52:05 +05:45
Ivan Shcheklein 2a40a55bb4 fix(exp): cleanup help messages 2023-03-10 11:13:06 +09:00
Ruslan Kuprieiev 3aa9f51e36 output: suppress cloud.pull errors on cache collection
Fixes https://github.com/iterative/studio/issues/5261
2023-03-09 22:44:32 +02:00
dependabot[bot] 1cfdc22549 build(deps-dev): bump pylint from 2.16.4 to 2.17.0 (#9141)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.4 to 2.17.0.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.4...v2.17.0)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-09 16:23:47 +05:45
skshetry 2b889baa5e plots: avoid printing anything on --json output (#9144) 2023-03-09 16:10:46 +05:45
Peter Rowlands 8589aa4001 tests: mark test_queue_remove_done xfail 2023-03-09 16:25:09 +09:00
Peter Rowlands 8cbc4c43f1 tests: use session-scoped exp celery fixtures when possible 2023-03-09 16:25:09 +09:00
Peter Rowlands 417c327143 exp: add session-scoped celery test fixtures 2023-03-09 16:25:09 +09:00
Peter Rowlands 204eebaf15 tests: use brancher.switch 2023-03-09 15:02:26 +09:00
Peter Rowlands 3a6fd75d98 repo: use brancher.switch 2023-03-09 15:02:26 +09:00
Peter Rowlands 351b952d5f brancher: add brancher.switch helper 2023-03-09 15:02:26 +09:00
dberenbaum fe7db32311 studio: handle revision errors when posting 2023-03-08 22:19:32 +01:00
Ivan Shcheklein 0bd34c8977 fix(push): allow no default remote 2023-03-08 21:26:47 +02:00
skshetry 48a63ca588 try to improve test speed (#9136)
* tests: fix logic to gc.collect() on Windows + 3.11

* dvc.cli: cache get_main_parser()

* tests: isolate environment on a session-level

* bring back gc.collect()
2023-03-08 22:11:42 +05:45
Gao 4459d6ac57 Add back an dvc ignore test
Original from
https://github.com/iterative/dvc/pull/8767#discussion_r1070778749
2023-03-08 12:03:07 +02:00
skshetry 08a50ee36e tests: error on ResourceWarning (#9123)
* tests: error on ResourceWarning

* run-cache: avoid leaking temp files on Windows

* set returncode to popen to avoid ResourceWarning on gc.collect()
2023-03-08 15:36:10 +05:45
David de la Iglesia Castro f1ea64c4ef studio: Send metrics on done event. (#9027) 2023-03-07 19:47:22 +01:00
dependabot[bot] 7ede45af2d build(deps-dev): bump mypy from 1.0.1 to 1.1.1 (#9126)
Bumps [mypy](https://github.com/python/mypy) from 1.0.1 to 1.1.1.
- [Release notes](https://github.com/python/mypy/releases)
- [Commits](https://github.com/python/mypy/compare/v1.0.1...v1.1.1)

---
updated-dependencies:
- dependency-name: mypy
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-07 23:13:54 +05:45
skshetry 51db1765ee migrate from appdirs to platformdirs (#9131)
appdirs has been deprecated/unmaintained, platformdirs is now recommended.
Closes #9130.
2023-03-07 23:13:28 +05:45
Peter Rowlands 9101fce761 proc: remove dvc.proc 2023-03-07 18:17:01 +09:00
Peter Rowlands 3f842a205f exp show: fix cached running checkpoint state bug 2023-03-07 16:55:23 +09:00
dependabot[bot] 77e900fed2 build(deps-dev): bump pylint from 2.16.3 to 2.16.4 (#9127)
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.16.3 to 2.16.4.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.16.3...v2.16.4)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-07 10:17:04 +05:45
57 changed files with 471 additions and 787 deletions
+2
View File
@@ -2,6 +2,7 @@
import argparse
import logging
import os
from functools import lru_cache
from dvc import __version__
from dvc.commands import (
@@ -150,6 +151,7 @@ def get_parent_parser():
return parent_parser
@lru_cache(maxsize=1)
def get_main_parser():
parent_parser = get_parent_parser()
+1 -1
View File
@@ -14,7 +14,7 @@ class CmdExperimentsClean(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_CLEAN_HELP = "Cleanup dvc exp internal tempfiles."
EXPERIMENTS_CLEAN_HELP = "Cleanup experiments temporary internal files."
experiments_clean_parser = experiments_subparsers.add_parser(
"clean",
parents=[parent_parser],
+1 -1
View File
@@ -52,7 +52,7 @@ class CmdExperimentsDiff(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_DIFF_HELP = "Show changes between experiments in the DVC repository."
EXPERIMENTS_DIFF_HELP = "Show changes between experiments."
experiments_diff_parser = experiments_subparsers.add_parser(
"diff",
+2 -1
View File
@@ -78,7 +78,8 @@ class CmdExperimentsGC(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_GC_HELP = "Garbage collect unneeded experiments."
EXPERIMENTS_GC_DESCRIPTION = (
"Removes all experiments which are not derived from the specifiedGit revisions."
"Removes all experiments which are not derived"
" from the specified Git revisions."
)
experiments_gc_parser = experiments_subparsers.add_parser(
"gc",
+1 -1
View File
@@ -35,7 +35,7 @@ class CmdExperimentsSave(CmdBase):
def add_parser(experiments_subparsers, parent_parser):
EXPERIMENTS_SAVE_HELP = "Save current workspace as a dvc experiment."
EXPERIMENTS_SAVE_HELP = "Save current workspace as an experiment."
save_parser = experiments_subparsers.add_parser(
"save",
parents=[parent_parser],
+2 -2
View File
@@ -19,7 +19,7 @@ def _show_json(renderers, split=False):
from dvc.render.convert import to_json
result = {renderer.name: to_json(renderer, split) for renderer in renderers}
ui.write_json(result)
ui.write_json(result, highlight=False)
def _adjust_vega_renderers(renderers):
@@ -112,7 +112,7 @@ class CmdPlots(CmdBase):
props=self._props(),
)
if not plots_data:
if not plots_data and not self.args.json:
ui.error_write(
"No plots were loaded, visualization file will not be created."
)
+1 -1
View File
@@ -117,7 +117,7 @@ class Config(dict):
@classmethod
def get_dir(cls, level):
from appdirs import site_config_dir, user_config_dir
from platformdirs import site_config_dir, user_config_dir
assert level in ("global", "system")
+44 -10
View File
@@ -1,8 +1,21 @@
import logging
import os
from urllib.parse import urlparse
from funcy import walk_values
from voluptuous import All, Any, Coerce, Invalid, Lower, Optional, Range, Schema
from funcy import once, walk_values
from voluptuous import (
REMOVE_EXTRA,
All,
Any,
Coerce,
Invalid,
Lower,
Optional,
Range,
Schema,
)
logger = logging.getLogger(__name__)
Bool = All(
Lower,
@@ -65,6 +78,27 @@ class RelPath(str):
pass
class FeatureSchema(Schema):
def __init__(self, schema, required=False):
super().__init__(schema, required=required, extra=REMOVE_EXTRA)
@staticmethod
@once
def _log_deprecated(keys):
# only run this once per session
message = "%s config option%s unsupported"
paths = ", ".join(f"'feature.{key}'" for key in keys)
pluralize = " is" if len(keys) == 1 else "s are"
logger.warning(message, paths, pluralize)
def __call__(self, data):
ret = super().__call__(data)
extra_keys = data.keys() - ret.keys()
if extra_keys:
self._log_deprecated(sorted(extra_keys))
return ret
REMOTE_COMMON = {
"url": str,
"checksum_jobs": All(Coerce(int), Range(1)),
@@ -239,12 +273,12 @@ SCHEMA = {
)
},
"state": {
"dir": str,
"dir": str, # obsoleted
"row_limit": All(Coerce(int), Range(1)), # obsoleted
"row_cleanup_quota": All(Coerce(int), Range(0, 100)), # obsoleted
},
"index": {
"dir": str,
"dir": str, # obsoleted
},
"machine": {
str: {
@@ -262,12 +296,12 @@ SCHEMA = {
},
},
# section for experimental features
"feature": {
Optional("machine", default=False): Bool,
# enabled by default. It's of no use, kept for backward compatibility.
Optional("data_index_cache", default=False): Bool,
Optional("parametrization", default=True): Bool,
},
# only specified keys are validated, others get logged and then ignored/removed
"feature": FeatureSchema(
{
Optional("machine", default=False): Bool,
},
),
"plots": {
"html_template": str,
Optional("auto_open", default=False): Bool,
+12 -2
View File
@@ -14,7 +14,14 @@ from dvc.utils import fix_env, is_binary
logger = logging.getLogger(__name__)
def _popen(cmd, **kwargs):
def _suppress_resource_warning(popen: Popen):
"""Sets the returncode to avoid ResourceWarning when popen is garbage collected."""
# only use for daemon processes.
# See https://bugs.python.org/issue38890.
popen.returncode = 0
def _popen(cmd, **kwargs) -> Popen:
prefix = [sys.executable]
if not is_binary():
main_entrypoint = os.path.join(
@@ -40,7 +47,10 @@ def _spawn_windows(cmd, env):
startupinfo = STARTUPINFO()
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
_popen(cmd, env=env, creationflags=creationflags, startupinfo=startupinfo)
popen = _popen(
cmd, env=env, creationflags=creationflags, startupinfo=startupinfo
)
_suppress_resource_warning(popen)
def _spawn_posix(cmd, env):
+1 -1
View File
@@ -75,7 +75,7 @@ class DataCloud:
config["version_aware"] = True
fs = cls(**config)
config["tmp_dir"] = self.repo.index_db_dir
config["tmp_dir"] = self.repo.site_cache_dir
if self.repo.data_index is not None:
index = self.repo.data_index.view(("remote", name))
else:
+1 -1
View File
@@ -121,7 +121,7 @@ def _get_cloud_fs(repo_config, **kwargs):
remote_conf = get_fs_config(repo_config, **kwargs)
try:
remote_conf = SCHEMA["remote"][str](remote_conf)
remote_conf = SCHEMA["remote"][str](remote_conf) # type: ignore[index]
except Invalid as exc:
raise RepoConfigError(str(exc)) from None
+1
View File
@@ -54,6 +54,7 @@ def get_dvc_info():
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append(f"Repo: {_get_dvc_repo_info(repo)}")
info.append(f"Repo.site_cache_dir: {repo.site_cache_dir}")
except NotDvcRepoError:
pass
except SCMError:
+3 -1
View File
@@ -2,6 +2,7 @@ import logging
import os
import posixpath
from collections import defaultdict
from contextlib import suppress
from operator import itemgetter
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type
from urllib.parse import urlparse
@@ -1017,7 +1018,8 @@ class Output:
except FileNotFoundError:
if self.remote:
kwargs["remote"] = self.remote
self.repo.cloud.pull([obj.hash_info], **kwargs)
with suppress(Exception):
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
return self.obj
View File
-23
View File
@@ -1,23 +0,0 @@
from dvc.exceptions import DvcException
class ProcessNotTerminatedError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' has not been terminated.")
class ProcessNotFoundError(DvcException):
def __init__(self, name):
super().__init__(f"Managed process '{name}' does not exist.")
class TimeoutExpired(DvcException):
def __init__(self, cmd, timeout):
super().__init__(f"'{cmd}' did not complete before timeout '{timeout}'")
self.cmd = cmd
self.timeout = timeout
class UnsupportedSignalError(DvcException):
def __init__(self, sig):
super().__init__(f"Unsupported signal: {sig}")
-149
View File
@@ -1,149 +0,0 @@
"""Serverless process manager."""
import json
import logging
import os
import signal
import sys
from typing import Generator, List, Optional, Tuple, Union
from funcy.flow import reraise
from shortuuid import uuid
from .exceptions import ProcessNotTerminatedError, UnsupportedSignalError
from .process import ManagedProcess, ProcessInfo
logger = logging.getLogger(__name__)
class ProcessManager:
"""Manager for controlling background ManagedProcess(es).
Spawned process entries are kept in the manager directory until they
are explicitly removed (with remove() or cleanup()) so that return
value and log information can be accessed after a process has completed.
"""
def __init__(self, wdir: Optional[str] = None):
self.wdir = wdir or "."
def __iter__(self) -> Generator[str, None, None]:
if not os.path.exists(self.wdir):
return
yield from os.listdir(self.wdir)
def __getitem__(self, key: str) -> "ProcessInfo":
info_path = os.path.join(self.wdir, key, f"{key}.json")
try:
with open(info_path, encoding="utf-8") as fobj:
return ProcessInfo.from_dict(json.load(fobj))
except FileNotFoundError:
raise KeyError # noqa: B904
@reraise(FileNotFoundError, KeyError)
def __setitem__(self, key: str, value: "ProcessInfo"):
info_path = os.path.join(self.wdir, key, f"{key}.json")
with open(info_path, "w", encoding="utf-8") as fobj:
return json.dump(value.asdict(), fobj)
def __delitem__(self, key: str) -> None:
from dvc.utils.fs import remove
path = os.path.join(self.wdir, key)
if os.path.exists(path):
remove(path)
def get(self, key: str, default=None):
try:
return self[key]
except KeyError:
return default
def processes(self) -> Generator[Tuple[str, "ProcessInfo"], None, None]:
for name in self:
try:
yield name, self[name]
except KeyError:
continue
def spawn(self, args: Union[str, List[str]], name: Optional[str] = None):
"""Run the given command in the background."""
name = name or uuid()
pid = ManagedProcess.spawn(
args,
wdir=os.path.join(self.wdir, name),
name=name,
)
logger.debug(
"Spawned managed process '%s' (PID: '%d')",
name,
pid,
)
def send_signal(self, name: str, sig: int):
"""Send `signal` to the specified named process."""
process_info = self[name]
if sys.platform == "win32" and sig not in (
signal.SIGTERM,
signal.CTRL_C_EVENT, # pylint: disable=no-member
signal.CTRL_BREAK_EVENT, # pylint: disable=no-member
):
raise UnsupportedSignalError(sig)
def handle_closed_process():
logging.warning("Process %s had already aborted unexpectedly.", name)
process_info.returncode = -1
self[name] = process_info
if process_info.returncode is None:
try:
os.kill(process_info.pid, sig)
except ProcessLookupError:
handle_closed_process()
raise
except OSError as exc:
if getattr(exc, "winerror", None) == 87:
handle_closed_process()
raise ProcessLookupError from exc
raise
def terminate(self, name: str):
"""Terminate the specified named process."""
self.send_signal(name, signal.SIGTERM)
def kill(self, name: str):
"""Kill the specified named process."""
if sys.platform == "win32":
self.send_signal(name, signal.SIGTERM)
else:
self.send_signal(name, signal.SIGKILL) # pylint: disable=no-member
def remove(self, name: str, force: bool = False):
"""Remove the specified named process from this manager.
If the specified process is still running, it will be forcefully killed
if `force` is True`, otherwise an exception will be raised.
Raises:
ProcessNotTerminatedError if the specified process is still
running and was not forcefully killed.
"""
try:
process_info = self[name]
except KeyError:
return
if process_info.returncode is None and not force:
raise ProcessNotTerminatedError(name)
try:
self.kill(name)
except ProcessLookupError:
pass
del self[name]
def cleanup(self, force: bool = False):
"""Remove stale (terminated) processes from this manager."""
for name in self:
try:
self.remove(name, force)
except ProcessNotTerminatedError:
continue
-179
View File
@@ -1,179 +0,0 @@
import json
import logging
import os
import shlex
import subprocess
from contextlib import AbstractContextManager
from dataclasses import asdict, dataclass
from typing import BinaryIO, List, Optional, Union
from shortuuid import uuid
from dvc.utils.objects import cached_property
from .exceptions import TimeoutExpired
logger = logging.getLogger(__name__)
@dataclass
class ProcessInfo:
pid: int
stdin: Optional[str]
stdout: Optional[str]
stderr: Optional[str]
returncode: Optional[int]
@classmethod
def from_dict(cls, d):
return cls(**d)
def asdict(self):
return asdict(self)
class ManagedProcess(AbstractContextManager):
"""Run the specified command with redirected output.
stdout and stderr will both be redirected to <name>.out.
Interactive processes (requiring stdin input) are currently unsupported.
Parameters:
args: Command to be run.
wdir: If specified, redirected output files will be placed in `wdir`.
name: Name to use for this process, if not specified a UUID will be
generated instead.
"""
def __init__(
self,
args: Union[str, List[str]],
wdir: Optional[str] = None,
name: Optional[str] = None,
):
self.args: List[str] = (
shlex.split(args, posix=os.name == "posix")
if isinstance(args, str)
else list(args)
)
self.wdir = wdir
self.name = name or uuid()
self.returncode: Optional[int] = None
self._stdout: Optional[BinaryIO] = None
self._stderr: Optional[BinaryIO] = None
self._run()
def __exit__(self, exc_type, exc_value, traceback):
self.wait()
def _close_fds(self):
if self._stdout:
self._stdout.close()
self._stdout = None
if self._stderr:
self._stderr.close()
self._stderr = None
def _make_path(self, path: str) -> str:
return os.path.join(self.wdir, path) if self.wdir else path
@cached_property
def stdout_path(self) -> str:
return self._make_path(f"{self.name}.out")
@cached_property
def info_path(self) -> str:
return self._make_path(f"{self.name}.json")
@cached_property
def pidfile_path(self) -> str:
return self._make_path(f"{self.name}.pid")
@property
def info(self) -> "ProcessInfo":
return ProcessInfo(
pid=self.pid,
stdin=None,
stdout=self.stdout_path,
stderr=None,
returncode=self.returncode,
)
def _make_wdir(self):
if self.wdir:
os.makedirs(self.wdir, exist_ok=True)
def _dump(self):
self._make_wdir()
with open(self.info_path, "w", encoding="utf-8") as fobj:
json.dump(self.info.asdict(), fobj)
with open(self.pidfile_path, "w", encoding="utf-8") as fobj:
fobj.write(str(self.pid))
def _run(self) -> None:
self._make_wdir()
logger.debug(
"Appending output to '%s'",
self.stdout_path,
)
self._stdout = open(self.stdout_path, "ab") # noqa: SIM115
try:
self._proc = subprocess.Popen(
self.args,
stdin=subprocess.DEVNULL,
stdout=self._stdout,
stderr=subprocess.STDOUT,
close_fds=True,
shell=False,
)
self.pid: int = self._proc.pid
self._dump()
except Exception:
if self._proc is not None:
self._proc.kill()
self._close_fds()
raise
def wait(self, timeout: Optional[int] = None) -> Optional[int]:
"""Block until a process started with `run` has completed.
Raises:
TimeoutExpired if `timeout` was set and the process
did not terminate after `timeout` seconds.
"""
if self.returncode is not None:
return self.returncode
try:
self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise TimeoutExpired(exc.cmd, exc.timeout) from exc
self.returncode = self._proc.returncode
self._close_fds()
self._dump()
return self.returncode
@classmethod
def spawn(cls, *args, **kwargs) -> Optional[int]:
"""Spawn a ManagedProcess command in the background.
Returns: The spawned process PID.
"""
import multiprocessing as mp
proc = mp.Process(
target=cls._spawn,
args=args,
kwargs=kwargs,
daemon=True,
)
proc.start()
# Do not terminate the child daemon when the main process exits
# pylint: disable=protected-access
mp.process._children.discard(proc) # type: ignore[attr-defined]
return proc.pid
@classmethod
def _spawn(cls, *args, **kwargs):
with cls(*args, **kwargs):
pass
+40 -43
View File
@@ -3,7 +3,16 @@ import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Iterable, List, Optional, Tuple, Union
from typing import (
TYPE_CHECKING,
Callable,
ContextManager,
Iterable,
List,
Optional,
Tuple,
Union,
)
from dvc.exceptions import FileMissingError
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
@@ -125,38 +134,6 @@ class Repo:
assert root_dir
return root_dir, dvc_dir
def _get_database_dir(self, db_name: str) -> Optional[str]:
from dvc.fs import localfs
# NOTE: by default, store SQLite-based remote indexes and state's
# `links` and `md5s` caches in the repository itself to avoid any
# possible state corruption in 'shared cache dir' scenario, but allow
# user to override this through config when, say, the repository is
# located on a mounted volume — see
# https://github.com/iterative/dvc/issues/4420
base_db_dir = self.config.get(db_name, {}).get("dir", None)
if not base_db_dir:
return self.tmp_dir
import hashlib
if self.local_dvc_dir:
fs: "FileSystem" = localfs
local_root = fs.path.parent(self.local_dvc_dir)
else:
fs = self.fs
local_root = self.root_dir
root_dir_hash = hashlib.sha224(local_root.encode("utf-8")).hexdigest()
db_dir = fs.path.join(
base_db_dir,
self.DVC_DIR,
f"{fs.path.name(local_root)}-{root_dir_hash[0:7]}",
)
fs.makedirs(db_dir, exist_ok=True)
return db_dir
def __init__( # noqa: PLR0915
self,
root_dir: Optional[str] = None,
@@ -232,8 +209,8 @@ class Repo:
hardlink_lock=self.config["core"].get("hardlink_lock", False),
friendly=True,
)
state_db_dir = self._get_database_dir("state")
self.state = State(self.root_dir, state_db_dir, self.dvcignore)
os.makedirs(self.site_cache_dir, exist_ok=True)
self.state = State(self.root_dir, self.site_cache_dir, self.dvcignore)
else:
self.lock = LockNoop()
self.state = StateNoop()
@@ -388,16 +365,12 @@ class Repo:
self._reset()
@property
def data_index(self) -> Optional["DataIndex"]:
def data_index(self) -> "DataIndex":
from dvc_data.index import DataIndex
if not self.index_db_dir:
return None
if self._data_index is None:
index_dir = os.path.join(self.index_db_dir, "index", "data")
index_dir = os.path.join(self.site_cache_dir, "index", "data")
os.makedirs(index_dir, exist_ok=True)
self._data_index = DataIndex.open(os.path.join(index_dir, "db.db"))
return self._data_index
@@ -463,6 +436,11 @@ class Repo:
return brancher(self, *args, **kwargs)
def switch(self, rev: str) -> ContextManager[str]:
from dvc.repo.brancher import switch
return switch(self, rev)
def used_objs( # noqa: PLR0913
self,
targets=None,
@@ -576,8 +554,27 @@ class Repo:
return DVCFileSystem(repo=self, subrepos=self.subrepos, **self._fs_conf)
@cached_property
def index_db_dir(self):
return self._get_database_dir("index")
def site_cache_dir(self) -> str:
import hashlib
import platformdirs
from dvc.fs import GitFileSystem
cache_dir = platformdirs.site_cache_dir("dvc", "iterative", opinion=True)
if isinstance(self.fs, GitFileSystem):
relparts = ()
if self.root_dir != "/":
# subrepo
relparts = self.fs.path.relparts(self.root_dir, "/")
root_dir = os.path.join(self.scm.root_dir, *relparts)
else:
root_dir = self.root_dir
repo_token = hashlib.md5(os.fsencode(root_dir)).hexdigest() # noqa: S324
return os.path.join(cache_dir, "repo", repo_token)
@contextmanager
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
+68 -16
View File
@@ -1,8 +1,15 @@
import logging
from typing import Optional
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional, Tuple
from scmrepo.git import Git
from dvc.exceptions import NotDvcRepoError
from dvc.scm import iter_revs
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
@@ -83,23 +90,68 @@ def brancher( # noqa: E302
)
try:
from dvc.fs import GitFileSystem
for sha, names in found_revs.items():
logger.trace( # type: ignore[attr-defined]
"switching fs to revision %s [%s]", sha[:7], ", ".join(names)
)
self.fs = GitFileSystem(scm=scm, rev=sha)
self.root_dir = self.fs.path.join("/", *repo_root_parts)
if cwd_parts:
cwd = self.fs.path.join("/", *cwd_parts) # type: ignore[unreachable]
self.fs.path.chdir(cwd)
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
if self.fs.exists(self.root_dir):
try:
_switch_fs(self, sha, repo_root_parts, cwd_parts)
yield sha if sha_only else ",".join(names)
except NotDvcRepoError:
# ignore revs that don't contain repo root
# (i.e. revs from before a subdir=True repo was init'ed)
pass
finally:
self.fs = saved_fs
self.root_dir = saved_root
def _switch_fs(
repo: "Repo",
rev: str,
repo_root_parts: Tuple[str, ...],
cwd_parts: Tuple[str, ...],
):
from dvc.fs import GitFileSystem, LocalFileSystem
if rev == "workspace":
logger.trace("switching fs to workspace") # type: ignore[attr-defined]
repo.fs = LocalFileSystem(url=repo.root_dir)
return
logger.trace("switching fs to revision %s", rev[:7]) # type: ignore[attr-defined]
assert isinstance(repo.scm, Git)
fs = GitFileSystem(scm=repo.scm, rev=rev)
root_dir = repo.fs.path.join("/", *repo_root_parts)
if not fs.exists(root_dir):
raise NotDvcRepoError(f"Commit '{rev[:7]}' does not contain a DVC repo")
repo.fs = fs
repo.root_dir = root_dir
if cwd_parts:
cwd = repo.fs.path.join("/", *cwd_parts)
repo.fs.path.chdir(cwd)
@contextmanager
def switch(repo: "Repo", rev: str) -> Iterator[str]:
"""Switch to a specific revision."""
from dvc.scm import resolve_rev
if rev != "workspace":
rev = resolve_rev(repo.scm, rev)
repo_root_parts = ()
if repo.fs.path.isin(repo.root_dir, repo.scm.root_dir):
repo_root_parts = repo.fs.path.relparts(repo.root_dir, repo.scm.root_dir)
cwd_parts = ()
if repo.fs.path.isin(repo.fs.path.getcwd(), repo.scm.root_dir):
cwd_parts = repo.fs.path.relparts(repo.fs.path.getcwd(), repo.scm.root_dir)
saved_fs = repo.fs
saved_root = repo.root_dir
try:
_switch_fs(repo, rev, repo_root_parts, cwd_parts)
yield rev
finally:
repo.fs = saved_fs
repo.root_dir = saved_root
+1 -4
View File
@@ -182,10 +182,7 @@ def _diff_head_to_index(
) -> Dict[str, List[str]]:
index = repo.index.data["repo"]
for rev in repo.brancher(revs=[head]):
if rev == "workspace":
continue
with repo.switch(head):
head_index = repo.index.data["repo"]
with ui.status("Calculating diff between head/index"):
+5
View File
@@ -65,6 +65,11 @@ class Experiments:
@property
def scm(self):
from dvc.scm import SCMError
if self.repo.scm.no_commits:
raise SCMError("Empty Git repo. Add a commit to use experiments.")
return self.repo.scm
@cached_property
+2
View File
@@ -21,6 +21,7 @@ from typing import (
Union,
)
from funcy import get_in
from scmrepo.exceptions import SCMError
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
@@ -590,6 +591,7 @@ class BaseExecutor(ABC):
info.name,
"dvc",
experiment_rev=dvc.experiments.scm.get_ref(EXEC_BRANCH),
metrics=get_in(dvc.metrics.show(), ["", "data"]),
)
if infofile is not None:
+6
View File
@@ -17,6 +17,8 @@ from typing import (
Union,
)
from dvc_studio_client.env import STUDIO_REPO_URL
from dvc_studio_client.post_live_metrics import get_studio_repo_url
from funcy import retry
from dvc.dependency import ParamsDependency
@@ -415,6 +417,10 @@ class BaseStashQueue(ABC):
run_env[DVC_EXP_NAME] = name
if resume_rev:
run_env[DVCLIVE_RESUME] = "1"
studio_repo_url = get_studio_repo_url()
if studio_repo_url is not None:
run_env[STUDIO_REPO_URL] = studio_repo_url
self._pack_args(*args, run_env=run_env, **kwargs)
# save experiment as a stash commit
+12 -7
View File
@@ -19,7 +19,7 @@ from typing import (
from scmrepo.exceptions import SCMError as InnerScmError
from dvc.exceptions import DvcException
from dvc.scm import Git, RevError, SCMError, iter_revs, resolve_rev
from dvc.scm import Git, SCMError, iter_revs, resolve_rev
from .refs import ExpRefInfo
from .serialize import SerializableError, SerializableExp
@@ -81,7 +81,10 @@ def collect_experiment_commit(
if exp_rev != "workspace" and not (force or param_deps):
cached_exp = cache.get(exp_rev)
if cached_exp:
if status == ExpStatus.Running:
if status == ExpStatus.Running or (
isinstance(cached_exp, SerializableExp)
and cached_exp.status == ExpStatus.Running.name
):
# expire cached queued exp entry once we start running it
cache.delete(exp_rev)
elif isinstance(cached_exp, SerializableError):
@@ -97,7 +100,12 @@ def collect_experiment_commit(
force=force,
**kwargs,
)
if exp_rev != "workspace" and not param_deps and not exp.contains_error:
if not (
exp_rev == "workspace"
or status == ExpStatus.Running
or param_deps
or exp.contains_error
):
cache.put(exp, force=True)
return _format_exp(exp)
except Exception as exc: # noqa: BLE001, pylint: disable=broad-except
@@ -114,10 +122,8 @@ def _collect_from_repo(
**kwargs,
) -> "SerializableExp":
running = running or {}
for rev in repo.brancher(revs=[exp_rev]):
with repo.switch(exp_rev) as rev:
if rev == "workspace":
if exp_rev != "workspace":
continue
timestamp: Optional[datetime] = None
else:
commit = repo.scm.resolve_commit(rev)
@@ -143,7 +149,6 @@ def _collect_from_repo(
executor=executor,
error=error,
)
raise RevError(f"nonexistent exp rev: '{exp_rev}'")
def _collect_complete_experiment(
+6 -5
View File
@@ -35,6 +35,7 @@ from .refs import (
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.scm import NoSCM
EXEC_TMP_DIR = "exps"
@@ -108,7 +109,9 @@ def exp_refs_by_rev(scm: "Git", rev: str) -> Generator[ExpRefInfo, None, None]:
def exp_refs_by_baseline(
scm: "Git", revs: Optional[Set[str]] = None, url: Optional[str] = None
scm: "Git",
revs: Optional[Set[str]] = None,
url: Optional[str] = None,
) -> Mapping[str, List[ExpRefInfo]]:
"""Iterate over all experiment refs with the specified baseline."""
all_exp_refs = exp_refs(scm, url)
@@ -212,8 +215,6 @@ def exp_commits(
refs = ref_infos if ref_infos else exp_refs(scm)
for ref_info in refs:
shas.update(scm.branch_revs(str(ref_info), ref_info.baseline_sha))
if ref_info.baseline_sha:
shas.add(ref_info.baseline_sha)
yield from shas
@@ -233,7 +234,7 @@ def remove_exp_refs(scm: "Git", ref_infos: Iterable[ExpRefInfo]):
scm.remove_ref(str(ref_info))
def fix_exp_head(scm: "Git", ref: Optional[str]) -> Optional[str]:
def fix_exp_head(scm: Union["Git", "NoSCM"], ref: Optional[str]) -> Optional[str]:
if ref:
name, tail = Git.split_ref_pattern(ref)
if name == "HEAD" and scm.get_ref(EXEC_BASELINE):
@@ -337,7 +338,7 @@ def to_studio_params(dvc_params):
if not dvc_params:
return result
for rev_data in dvc_params.values():
for file_name, file_data in rev_data["data"].items():
for file_name, file_data in rev_data.get("data", {}).items():
result[file_name] = file_data["data"]
return result
+15 -23
View File
@@ -354,42 +354,34 @@ class Index:
@cached_property
def data(self) -> "Dict[str, DataIndex]":
from dvc_data.index import DataIndex
prefix: "DataIndexKey"
loaded = False
index = self.repo.data_index
if index is None:
index = DataIndex()
prefix = ("tree", self.data_tree.hash_info.value)
if index.has_node(prefix):
loaded = True
try:
if not loaded:
_load_data_from_outs(index, prefix, self.outs)
index.commit()
if not loaded:
_load_data_from_outs(index, prefix, self.outs)
index.commit()
by_workspace = {}
by_workspace["repo"] = index.view((*prefix, "repo"))
by_workspace["local"] = index.view((*prefix, "local"))
by_workspace = {}
by_workspace["repo"] = index.view((*prefix, "repo"))
by_workspace["local"] = index.view((*prefix, "local"))
for out in self.outs:
if not out.use_cache:
continue
for out in self.outs:
if not out.use_cache:
continue
ws, key = out.index_key
if ws not in by_workspace:
by_workspace[ws] = index.view((*prefix, ws))
ws, key = out.index_key
if ws not in by_workspace:
by_workspace[ws] = index.view((*prefix, ws))
data_index = by_workspace[ws]
_load_storage_from_out(data_index.storage_map, key, out)
data_index = by_workspace[ws]
_load_storage_from_out(data_index.storage_map, key, out)
return by_workspace
finally:
index.close()
return by_workspace
@staticmethod
def _hash_targets(
+6 -1
View File
@@ -12,7 +12,7 @@ from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False): # noqa: C901
"""
Creates an empty repo on the given directory -- basically a
`.dvc` directory with subdirectories for configuration and cache.
@@ -75,6 +75,11 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
proj = Repo(root_dir)
if os.path.isdir(proj.site_cache_dir):
proj.close()
remove(proj.site_cache_dir)
proj = Repo(root_dir)
with proj.scm_context(autostage=True) as context:
files = [
config.files["repo"],
+3
View File
@@ -20,16 +20,19 @@ def pre_commit_install(scm: "Git") -> None:
"hooks": [
{
"id": "dvc-pre-commit",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["commit"],
},
{
"id": "dvc-pre-push",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["push"],
},
{
"id": "dvc-post-checkout",
"additional_dependencies": [".[all]"],
"language_version": "python3",
"stages": ["post-checkout"],
"always_run": True,
+6 -3
View File
@@ -1,5 +1,7 @@
from contextlib import suppress
from typing import TYPE_CHECKING, Optional, Sequence
from dvc.config import NoRemoteError
from dvc.exceptions import InvalidArgumentError, UploadError
from dvc.utils import glob_targets
@@ -30,9 +32,10 @@ def push( # noqa: C901, PLR0913
include_imports=False,
):
worktree_remote: Optional["Remote"] = None
_remote = self.cloud.get_remote(name=remote)
if _remote.worktree or _remote.fs.version_aware:
worktree_remote = _remote
with suppress(NoRemoteError):
_remote = self.cloud.get_remote(name=remote)
if _remote and (_remote.worktree or _remote.fs.version_aware):
worktree_remote = _remote
pushed = 0
used_run_cache = self.stage_cache.push(remote, odb=odb) if run_cache else []
+2 -1
View File
@@ -165,7 +165,7 @@ def clone(url: str, to_path: str, **kwargs):
raise CloneError("SCM error") from exc
def resolve_rev(scm: "Git", rev: str) -> str:
def resolve_rev(scm: Union["Git", "NoSCM"], rev: str) -> str:
from scmrepo.exceptions import RevError as InternalRevError
from dvc.repo.experiments.utils import fix_exp_head
@@ -173,6 +173,7 @@ def resolve_rev(scm: "Git", rev: str) -> str:
try:
return scm.resolve_rev(fix_exp_head(scm, rev))
except InternalRevError as exc:
assert isinstance(scm, Git)
# `scm` will only resolve git branch and tag names,
# if rev is not a sha it may be an abbreviated experiment name
if not (rev == "HEAD" or rev.startswith("refs/")):
+3 -3
View File
@@ -1,6 +1,5 @@
import logging
import os
import tempfile
from contextlib import contextmanager
from typing import TYPE_CHECKING, List, Optional, Tuple
@@ -167,9 +166,10 @@ class StageCache:
COMPILED_LOCK_FILE_STAGE_SCHEMA(cache)
path = self._get_cache_path(cache_key, cache_value)
parent = self.repo.cache.local.fs.path.parent(path)
local_fs = self.repo.cache.local.fs
parent = local_fs.path.parent(path)
self.repo.cache.local.makedirs(parent)
tmp = tempfile.NamedTemporaryFile(delete=False, dir=parent).name
tmp = local_fs.path.join(parent, fs.utils.tmp_fname())
assert os.path.exists(parent)
assert os.path.isdir(parent)
dump_yaml(tmp, cache)
+63 -5
View File
@@ -17,7 +17,9 @@ from dvc.utils.objects import cached_property
if TYPE_CHECKING:
from rich.console import Console as RichConsole
from rich.console import JustifyMethod, OverflowMethod
from rich.status import Status
from rich.style import Style
from rich.text import Text as RichText
from dvc.progress import Tqdm
@@ -105,8 +107,9 @@ class Console:
if indent is None and self.isatty():
indent = 2
console = self.error_console if stderr else self.rich_console
return console.print_json(
from rich.json import JSON
json = JSON.from_data(
data=data,
indent=indent,
highlight=bool(highlight),
@@ -117,6 +120,50 @@ class Console:
default=default,
sort_keys=sort_keys,
)
if not highlight:
return self.write(json.text, stderr=stderr)
return self.rich_print(json, stderr=stderr, soft_wrap=True)
def rich_print(
self,
*objects: Any,
sep: str = " ",
end: str = "\n",
stderr: bool = False,
style: Optional[Union[str, "Style"]] = None,
justify: Optional["JustifyMethod"] = None,
overflow: Optional["OverflowMethod"] = None,
no_wrap: Optional[bool] = None,
emoji: Optional[bool] = None,
markup: Optional[bool] = None,
highlight: Optional[bool] = None,
width: Optional[int] = None,
height: Optional[int] = None,
crop: bool = True,
soft_wrap: Optional[bool] = None,
new_line_start: bool = False,
) -> None:
if stderr:
console = self.error_console
else:
console = self.rich_console
return console.print(
*objects,
sep=sep,
end=end,
style=style,
justify=justify,
overflow=overflow,
no_wrap=no_wrap,
emoji=emoji,
markup=markup,
highlight=highlight,
width=width,
height=height,
crop=crop,
soft_wrap=soft_wrap,
new_line_start=new_line_start,
)
def write(
self,
@@ -142,10 +189,21 @@ class Console:
with Tqdm.external_write_mode(file=file):
# if we are inside pager context, send the output to rich's buffer
if styled or self._paginate.get():
console = self.error_console if stderr else self.rich_console
if styled:
return console.print(*objects, sep=sep, end=end)
return console.out(*objects, sep=sep, end=end, highlight=False)
return self.rich_print(*objects, sep=sep, end=end, stderr=stderr)
return self.rich_print(
sep.join(str(_object) for _object in objects),
style=None,
highlight=False,
emoji=False,
markup=False,
no_wrap=True,
overflow="ignore",
crop=False,
sep=sep,
end=end,
stderr=stderr,
)
values = (self.formatter.format(obj, style) for obj in objects)
return print(*values, sep=sep, end=end, file=file)
+11 -6
View File
@@ -30,15 +30,14 @@ classifiers = [
]
dynamic = ["version"]
dependencies = [
"appdirs>=1.4.3",
"colorama>=0.3.9",
"configobj>=5.0.6",
"distro>=1.3",
"dpath<3,>=2.1.0",
"dvc-data>=0.42.1,<0.43",
"dvc-data>=0.43.0,<0.44",
"dvc-http",
"dvc-render>=0.1.2",
"dvc-studio-client>=0.1.1",
"dvc-studio-client>=0.5.0,<1",
"dvc-task>=0.2.0,<1",
"flatten_dict<1,>=0.4.1",
"flufl.lock>=5",
@@ -49,6 +48,7 @@ dependencies = [
"networkx>=2.5",
"packaging>=19",
"pathspec>=0.10.3",
"platformdirs<4,>=3.1.1",
"psutil>=5.8",
"pydot>=1.2.4",
"pygtrie>=2.3.2",
@@ -73,9 +73,8 @@ gdrive = ["dvc-gdrive==2.19.1"]
gs = ["dvc-gs==2.22.0"]
hdfs = ["dvc-hdfs==2.19"]
lint = [
"mypy==1.0.1",
"pylint==2.16.3",
"types-appdirs",
"mypy==1.1.1",
"pylint==2.17.0",
"types-colorama",
"types-psutil",
"types-requests",
@@ -133,6 +132,8 @@ profile = "black"
[tool.pytest.ini_options]
addopts = "-ra --cov-config pyproject.toml --dist worksteal"
filterwarnings = [
"error::ResourceWarning",
"error::pytest.PytestUnraisableExceptionWarning",
"error::pytest_mock.PytestMockWarning",
# remove when aiobotocore supports botocore>=1.29.13
"ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
@@ -204,6 +205,7 @@ module = [
"celery.*",
"configobj.*",
"dpath.*",
"distro",
"dvc_http",
"dvc_render.*",
"dvc_ssh",
@@ -218,9 +220,12 @@ module = [
"networkx.*", # https://github.com/networkx/networkx/issues/3988
"pygtrie.*",
"pyinstrument",
"pyparsing",
"pytest_docker.plugin",
"ruamel.*",
"ruamel.yaml.*",
"shortuuid",
"shtab",
"tpi.*",
"viztracer",
"voluptuous",
+6 -23
View File
@@ -1,4 +1,3 @@
import gc
import json
import os
import sys
@@ -174,22 +173,20 @@ def mocked_webbrowser_open(mocker):
mocker.patch("webbrowser.open")
@pytest.fixture(autouse=True)
def isolate(tmp_path_factory, monkeypatch) -> None:
@pytest.fixture(scope="session", autouse=True)
def isolate(tmp_path_factory):
path = tmp_path_factory.mktemp("mock")
home_dir = path / "home"
home_dir.mkdir()
monkeypatch = pytest.MonkeyPatch()
if sys.platform == "win32":
home_drive, home_path = os.path.splitdrive(home_dir)
monkeypatch.setenv("USERPROFILE", str(home_dir))
monkeypatch.setenv("HOMEDRIVE", home_drive)
monkeypatch.setenv("HOMEPATH", home_path)
for env_var, sub_path in (
("APPDATA", "Roaming"),
("LOCALAPPDATA", "Local"),
):
for env_var, sub_path in (("APPDATA", "Roaming"), ("LOCALAPPDATA", "Local")):
path = home_dir / "AppData" / sub_path
path.mkdir(parents=True)
monkeypatch.setenv(env_var, os.fspath(path))
@@ -209,6 +206,8 @@ defaultBranch=master
import pygit2
pygit2.settings.search_path[pygit2.GIT_CONFIG_LEVEL_GLOBAL] = str(home_dir)
yield
monkeypatch.undo()
@pytest.fixture
@@ -244,19 +243,3 @@ def run_copy_metrics(tmp_dir, copy_script):
return stage
return run
@pytest.fixture(autouse=True)
def gc_collect_on_dvc_close_on_win_311(mocker):
if sys.version_info < (3, 11) and os.name != "nt":
return
from dvc.repo import Repo
close = Repo.close
def wrapped(repo):
close(repo)
gc.collect()
mocker.patch("dvc.repo.Repo.close", wrapped)
+4 -1
View File
@@ -5,6 +5,9 @@ from tests.unit.repo.experiments.conftest import ( # noqa, pylint disable=unuse
exp_stage,
failed_checkpoint_stage,
failed_exp_stage,
session_app,
session_queue,
session_worker,
test_queue,
)
@@ -24,7 +27,7 @@ def http_auth_patch(mocker):
@pytest.fixture(params=[True, False])
def workspace(request, test_queue) -> bool: # noqa: F811
def workspace(request, session_queue) -> bool: # noqa: F811
return request.param
+1 -1
View File
@@ -26,7 +26,7 @@ def test_apply(tmp_dir, scm, dvc, exp_stage):
assert (tmp_dir / "metrics.yaml").read_text().strip() == "foo: 3"
def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, test_queue):
def test_apply_failed(tmp_dir, scm, dvc, failed_exp_stage, session_queue):
from dvc.exceptions import InvalidArgumentError
dvc.experiments.run(failed_exp_stage.addressing, params=["foo=2"], queue=True)
+5 -15
View File
@@ -25,9 +25,7 @@ def test_new_checkpoint(tmp_dir, scm, dvc, checkpoint_stage, mocker, workspace,
exp = first(results)
new_mock.assert_called_once()
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(checkpoint_stage.iterations)
@@ -69,9 +67,7 @@ def test_resume_checkpoint(
)
exp = first(results)
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
@@ -94,9 +90,7 @@ def test_reset_checkpoint(tmp_dir, scm, dvc, checkpoint_stage, caplog, workspace
)
exp = first(results)
for rev in dvc.brancher([exp]):
if rev == "workspace":
continue
with dvc.switch(exp):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(checkpoint_stage.iterations)
@@ -132,18 +126,14 @@ def test_resume_branch(tmp_dir, scm, dvc, checkpoint_stage, workspace):
)
checkpoint_b = first(results)
for rev in dvc.brancher([checkpoint_a]):
if rev == "workspace":
continue
with dvc.switch(checkpoint_a):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
with fs.open("metrics.yaml") as fobj:
assert fobj.read().strip() == "foo: 2"
for rev in dvc.brancher([checkpoint_b]):
if rev == "workspace":
continue
with dvc.switch(checkpoint_b):
fs = dvc.dvcfs
with fs.open("foo") as fobj:
assert fobj.read().strip() == str(2 * checkpoint_stage.iterations)
-10
View File
@@ -1,16 +1,6 @@
from funcy import first
def test_diff_no_commits(tmp_dir):
from dvc.repo import Repo
from dvc.scm import Git
git = Git.init(tmp_dir.fs_path)
assert git.no_commits
assert Repo.init().experiments.diff() == {}
def test_diff_empty(tmp_dir, scm, dvc, exp_stage):
assert dvc.experiments.diff() == {"params": {}, "metrics": {}}
+29 -6
View File
@@ -13,7 +13,7 @@ from dvc.repo.experiments.exceptions import ExperimentExistsError
from dvc.repo.experiments.queue.base import BaseStashQueue
from dvc.repo.experiments.refs import CELERY_STASH
from dvc.repo.experiments.utils import exp_refs_by_rev
from dvc.scm import resolve_rev
from dvc.scm import SCMError, resolve_rev
from dvc.stage.exceptions import StageFileDoesNotExistError
from dvc.utils.serialize import PythonFileCorruptedError
from tests.scripts import COPY_SCRIPT
@@ -131,7 +131,7 @@ def test_get_baseline(tmp_dir, scm, dvc, exp_stage):
assert dvc.experiments.get_baseline(f"{CELERY_STASH}@{{1}}") == init_rev
def test_update_py_params(tmp_dir, scm, dvc, test_queue, copy_script):
def test_update_py_params(tmp_dir, scm, dvc, session_queue, copy_script):
tmp_dir.gen("params.py", "INT = 1\n")
stage = dvc.run(
cmd="python copy.py params.py metrics.py",
@@ -597,17 +597,21 @@ def test_experiments_workspace_not_log_exception(caplog, dvc, scm):
assert not caplog.text
@pytest.mark.vscode
def test_run_env(tmp_dir, dvc, scm, mocker):
dump_run_env = dedent(
"""\
import os
from dvc_studio_client.env import STUDIO_REPO_URL
from dvc.env import DVC_EXP_BASELINE_REV, DVC_EXP_NAME
with open("DVC_EXP_BASELINE_REV", "w") as f:
f.write(os.environ.get(DVC_EXP_BASELINE_REV, ""))
with open("DVC_EXP_NAME", "w") as f:
f.write(os.environ.get(DVC_EXP_NAME, ""))
for v in (DVC_EXP_BASELINE_REV, DVC_EXP_NAME, STUDIO_REPO_URL):
with open(v, "w") as f:
f.write(os.environ.get(v, ""))
"""
)
mocker.patch(
"dvc.repo.experiments.queue.base.get_studio_repo_url", return_value="REPO_URL"
)
(tmp_dir / "dump_run_env.py").write_text(dump_run_env)
baseline = scm.get_rev()
dvc.stage.add(
@@ -617,10 +621,12 @@ def test_run_env(tmp_dir, dvc, scm, mocker):
dvc.experiments.run()
assert (tmp_dir / "DVC_EXP_BASELINE_REV").read_text().strip() == baseline
assert (tmp_dir / "DVC_EXP_NAME").read_text().strip() != ""
assert (tmp_dir / "STUDIO_REPO_URL").read_text().strip() == "REPO_URL"
dvc.experiments.run(name="foo")
assert (tmp_dir / "DVC_EXP_BASELINE_REV").read_text().strip() == baseline
assert (tmp_dir / "DVC_EXP_NAME").read_text().strip() == "foo"
assert (tmp_dir / "STUDIO_REPO_URL").read_text().strip() == "REPO_URL"
def test_experiment_unchanged(tmp_dir, scm, dvc, exp_stage):
@@ -640,3 +646,20 @@ def test_clean(tmp_dir, scm, dvc, mocker):
clean = mocker.spy(dvc.experiments.celery_queue.celery, "clean")
dvc.experiments.clean()
clean.assert_called_once_with()
def test_experiment_no_commit(tmp_dir):
from scmrepo.git import Git
from dvc.repo import Repo
Git.init(tmp_dir.fs_path).close()
repo = Repo.init()
assert repo.scm.no_commits
try:
with pytest.raises(SCMError): # noqa: PT011
repo.experiments.ls()
finally:
repo.close()
+6
View File
@@ -19,6 +19,7 @@ def test_celery_logs(
failed_exp_stage,
follow,
capsys,
test_queue,
):
celery_queue = dvc.experiments.celery_queue
dvc.experiments.run(failed_exp_stage.addressing, queue=True)
@@ -33,10 +34,15 @@ def test_celery_logs(
assert "failed to reproduce 'failed-copy-file'" in captured.out
@pytest.mark.xfail(
strict=False,
reason="https://github.com/iterative/dvc/issues/9143",
)
def test_queue_remove_done(
dvc,
exp_stage,
failed_exp_stage,
test_queue,
):
queue_length = 3
success_tasks = []
-11
View File
@@ -41,17 +41,6 @@ def make_executor_info(**kwargs):
return ExecutorInfo(**kwargs)
@pytest.mark.vscode
def test_show_no_commits(tmp_dir):
from dvc.repo import Repo
from dvc.scm import Git
git = Git.init(tmp_dir.fs_path)
assert git.no_commits
assert Repo.init().experiments.show() == {}
@pytest.mark.vscode
def test_show_branch_and_tag_name(tmp_dir, scm, dvc, exp_stage):
with tmp_dir.branch("new/branch", new=True):
+1 -7
View File
@@ -276,8 +276,7 @@ def test_gc_all_experiments(tmp_dir, scm, dvc):
(foo,) = tmp_dir.dvc_gen("foo", "foo", commit="foo")
foo_hash = foo.outs[0].hash_info.value
(bar,) = tmp_dir.dvc_gen("foo", "bar", commit="bar")
bar_hash = bar.outs[0].hash_info.value
tmp_dir.dvc_gen("foo", "bar", commit="bar")
baseline = scm.get_rev()
(baz,) = tmp_dir.dvc_gen("foo", "baz", commit="baz")
@@ -288,12 +287,7 @@ def test_gc_all_experiments(tmp_dir, scm, dvc):
dvc.gc(all_experiments=True, force=True)
# all_experiments will include the experiment commit (baz) plus baseline
# commit (bar)
assert not (tmp_dir / ".dvc" / "cache" / foo_hash[:2] / foo_hash[2:]).exists()
assert (
tmp_dir / ".dvc" / "cache" / bar_hash[:2] / bar_hash[2:]
).read_text() == "bar"
assert (
tmp_dir / ".dvc" / "cache" / baz_hash[:2] / baz_hash[2:]
).read_text() == "baz"
-14
View File
@@ -1,7 +1,5 @@
import os
import re
from dvc.repo import Repo
from dvc_data.hashfile.hash import file_md5
from dvc_data.hashfile.hash_info import HashInfo
from dvc_data.hashfile.state import State
@@ -71,15 +69,3 @@ def test_get_unused_links(tmp_dir, dvc):
dvc.fs,
)
) == {"bar"}
def test_state_dir_config(make_tmp_dir, dvc):
assert dvc.state.tmp_dir == dvc.tmp_dir
index_dir = str(make_tmp_dir("tmp_index"))
repo = Repo(config={"state": {"dir": index_dir}})
assert os.path.dirname(repo.state.tmp_dir) == os.path.join(index_dir, ".dvc")
assert re.match(
r"^test_state_dir_config0-([0-9a-f]+)$",
os.path.basename(repo.state.tmp_dir),
)
@@ -38,4 +38,5 @@ def test_post_to_studio(tmp_dir, dvc, scm, exp_stage, mocker, monkeypatch, tmp):
"name": name,
"client": "dvc",
"experiment_rev": exp_rev,
"metrics": {"metrics.yaml": {"data": {"foo": 1}}},
}
+1 -4
View File
@@ -89,10 +89,7 @@ def test_open_in_history(tmp_dir, scm, dvc):
dvc.scm.add(["foo.dvc", ".gitignore"])
dvc.scm.commit("foofoo")
for rev in dvc.brancher(revs=["HEAD~1"]):
if rev == "workspace":
continue
with dvc.switch("HEAD~1"):
fs = DataFileSystem(index=dvc.index.data["repo"])
with fs.open("foo", "r") as fobj:
assert fobj.read() == "foo"
+1 -4
View File
@@ -59,10 +59,7 @@ def test_open_in_history(tmp_dir, scm, dvc):
dvc.scm.add(["foo.dvc", ".gitignore"])
dvc.scm.commit("foofoo")
for rev in dvc.brancher(revs=["HEAD~1"]):
if rev == "workspace":
continue
with dvc.switch("HEAD~1"):
fs = DVCFileSystem(repo=dvc)
with fs.open("foo", "r") as fobj:
assert fobj.read() == "foo"
+1 -1
View File
@@ -116,7 +116,7 @@ def test_remote_missing_dependency_on_dir_pull(tmp_dir, scm, dvc, mocker):
remove(dvc.cache.local.path)
mocker.patch(
"dvc.data_cloud.DataCloud.get_remote_odb",
"dvc.data_cloud.DataCloud.get_remote",
side_effect=RemoteMissingDepsError(dvc.fs, "azure", "azure://", []),
)
with pytest.raises(RemoteMissingDepsError):
View File
-114
View File
@@ -1,114 +0,0 @@
import json
import os
import signal
import sys
import pytest
from dvc.proc.exceptions import ProcessNotTerminatedError, UnsupportedSignalError
from dvc.proc.manager import ProcessManager
from dvc.proc.process import ProcessInfo
PID_FINISHED = 1234
PID_RUNNING = 5678
def create_process(root: str, name: str, pid: int, returncode=None):
info_path = os.path.join(root, name, f"{name}.json")
os.makedirs(os.path.join(root, name))
process_info = ProcessInfo(
pid=pid, stdin=None, stdout=None, stderr=None, returncode=returncode
)
with open(info_path, "w", encoding="utf-8") as fobj:
json.dump(process_info.asdict(), fobj)
@pytest.fixture
def finished_process(tmp_dir):
key = "finished"
create_process(tmp_dir, key, PID_FINISHED, 0)
return key
@pytest.fixture
def running_process(tmp_dir):
key = "running"
create_process(tmp_dir, key, PID_RUNNING)
return key
def test_send_signal(tmp_dir, mocker, finished_process, running_process):
m = mocker.patch("os.kill")
process_manager = ProcessManager(tmp_dir)
process_manager.send_signal(running_process, signal.SIGTERM)
m.assert_called_once_with(PID_RUNNING, signal.SIGTERM)
m.reset_mock()
process_manager.send_signal(finished_process, signal.SIGTERM)
m.assert_not_called()
if sys.platform == "win32":
with pytest.raises(UnsupportedSignalError):
process_manager.send_signal(finished_process, signal.SIGABRT)
def test_dead_process(tmp_dir, mocker, running_process):
process_manager = ProcessManager(tmp_dir)
def side_effect(*args):
if sys.platform == "win32":
err = OSError()
err.winerror = 87
raise err
raise ProcessLookupError()
mocker.patch("os.kill", side_effect=side_effect)
with pytest.raises(ProcessLookupError):
process_manager.send_signal(running_process, signal.SIGTERM)
assert process_manager[running_process].returncode == -1
def test_kill(tmp_dir, mocker, finished_process, running_process):
m = mocker.patch("os.kill")
process_manager = ProcessManager(tmp_dir)
process_manager.kill(running_process)
if sys.platform == "win32":
m.assert_called_once_with(PID_RUNNING, signal.SIGTERM)
else:
m.assert_called_once_with(PID_RUNNING, signal.SIGKILL)
m.reset_mock()
process_manager.kill(finished_process)
m.assert_not_called()
def test_terminate(tmp_dir, mocker, running_process, finished_process):
m = mocker.patch("os.kill")
process_manager = ProcessManager(tmp_dir)
process_manager.terminate(running_process)
m.assert_called_once_with(PID_RUNNING, signal.SIGTERM)
m.reset_mock()
process_manager.terminate(finished_process)
m.assert_not_called()
def test_remove(mocker, tmp_dir, running_process, finished_process):
mocker.patch("os.kill", return_value=None)
process_manager = ProcessManager(tmp_dir)
process_manager.remove(finished_process)
assert not (tmp_dir / finished_process).exists()
with pytest.raises(ProcessNotTerminatedError):
process_manager.remove(running_process)
assert (tmp_dir / running_process).exists()
process_manager.remove(running_process, True)
assert not (tmp_dir / running_process).exists()
@pytest.mark.parametrize("force", [True, False])
def test_cleanup(mocker, tmp_dir, running_process, finished_process, force):
mocker.patch("os.kill", return_value=None)
process_manager = ProcessManager(tmp_dir)
process_manager.cleanup(force)
assert (tmp_dir / running_process).exists() != force
assert not (tmp_dir / finished_process).exists()
-61
View File
@@ -1,61 +0,0 @@
import json
import subprocess
import pytest
from dvc.proc.process import ManagedProcess, ProcessInfo
TEST_PID = 1234
@pytest.fixture(autouse=True)
def mock_popen(mocker):
mocker.patch(
"subprocess.Popen",
return_value=mocker.MagicMock(pid=TEST_PID, returncode=None),
)
@pytest.mark.parametrize(
"args",
[
"/bin/foo -o option",
["/bin/foo", "-o", "option"],
],
)
def test_init_args(tmp_dir, request, args, mocker):
expected = ["/bin/foo", "-o", "option"]
proc = ManagedProcess(args)
request.addfinalizer(proc._close_fds)
assert expected == proc.args
def test_run(tmp_dir, request, mocker):
proc = ManagedProcess("/bin/foo")
request.addfinalizer(proc._close_fds)
assert proc.pid == TEST_PID
with open(proc.info_path, encoding="utf-8") as fobj:
info = ProcessInfo.from_dict(json.load(fobj))
assert info.pid == TEST_PID
def test_wait(tmp_dir, mocker):
from dvc.proc.exceptions import TimeoutExpired
proc = ManagedProcess("/bin/foo")
proc._proc.wait = mocker.Mock(side_effect=subprocess.TimeoutExpired("/bin/foo", 5))
with pytest.raises(TimeoutExpired):
proc.wait(5)
proc._proc.wait = mocker.Mock(return_value=None)
proc._proc.returncode = 0
assert proc.wait() == 0
proc._proc.wait.assert_called_once()
# once subprocess return code is set, future ManagedProcess.wait() calls
# should not block
proc._proc.wait.reset_mock()
assert proc.wait() == 0
proc._proc.wait.assert_not_called()
-18
View File
@@ -1,11 +1,8 @@
import os
import pytest
from dvc_gs import GSFileSystem
from dvc_s3 import S3FileSystem
from dvc.fs import get_cloud_fs
from dvc_data.hashfile.db import get_index
def test_remote_with_hash_jobs(dvc):
@@ -57,18 +54,3 @@ def test_makedirs_not_create_for_top_level_path(fs_cls, dvc, mocker):
fs.makedirs(url)
assert not mocked_client.called
def test_remote_index_dir_config(make_tmp_dir, dvc):
index_dir = str(make_tmp_dir("tmp_index"))
with dvc.config.edit() as conf:
conf["index"]["dir"] = index_dir
conf["remote"]["s3"] = {"url": "s3://bucket/name"}
dvc.root_dir = "/usr/local/test_repo"
dvc.dvc_dir = "/usr/local/test_repo/.dvc"
dvc.__dict__.pop("local_dvc_dir")
assert os.path.dirname(
get_index(dvc.cloud.get_remote_odb(name="s3")).index_dir
) == os.path.join(index_dir, ".dvc", "test_repo-a473718", "index")
+52 -1
View File
@@ -3,6 +3,8 @@ from textwrap import dedent
import pytest
from dvc_task.app import FSApp
DEFAULT_ITERATIONS = 2
CHECKPOINT_SCRIPT_FORMAT = dedent(
"""\
@@ -147,11 +149,60 @@ def _thread_worker(app, **kwargs):
return worker.start_worker(app, pool="threads", **kwargs)
@pytest.fixture(scope="session")
def session_app(tmp_path_factory) -> FSApp:
"""Session scoped experiments queue celery app."""
from kombu.transport.filesystem import Channel
# related to https://github.com/iterative/dvc-task/issues/61
Channel.QoS.restore_at_shutdown = False
from dvc_task.app import FSApp
wdir = tmp_path_factory.mktemp("dvc-test-celery")
app = FSApp(
"dvc-exp-local",
wdir=wdir,
mkdir=True,
include=[
"dvc.repo.experiments.queue.tasks",
"dvc_task.proc.tasks",
],
)
app.conf.update({"task_acks_late": True, "result_expires": None})
return app
@pytest.fixture(scope="session")
def session_worker(session_app):
"""Session scoped celery worker that runs in separate thread(s)."""
with _thread_worker(
session_app,
concurrency=1,
ping_task_timeout=20,
loglevel="DEBUG",
) as worker:
yield worker
@pytest.fixture
def session_queue(tmp_dir, dvc, scm, mocker, session_app, session_worker):
"""Patches experiments celery queue for pytest testing.
Uses session-scoped celery worker.
"""
queue = dvc.experiments.celery_queue
queue.celery = session_app
queue.worker = session_worker
mocker.patch.object(queue, "_spawn_worker")
return queue
@pytest.fixture
def test_queue(tmp_dir, dvc, scm, mocker):
"""Patches experiments celery queue for pytest testing.
Test queue worker runs for the duration of the test in separate thread(s).
Uses function-scoped celery worker which runs in separate thread(s).
"""
import celery
@@ -49,10 +49,10 @@ def test_shutdown_with_kill(test_queue, mocker):
)
def test_post_run_after_kill(test_queue):
def test_post_run_after_kill(session_queue):
from celery import chain
sig_bar = test_queue.proc.run_signature(
sig_bar = session_queue.proc.run_signature(
["python3", "-c", "import time; time.sleep(10)"], name="bar"
)
sig_bar.freeze()
@@ -65,7 +65,7 @@ def test_post_run_after_kill(test_queue):
while True:
try:
test_queue.proc.kill("bar")
session_queue.proc.kill("bar")
assert result_foo.status == "PENDING"
break
except ProcessLookupError:
+18 -1
View File
@@ -2,7 +2,7 @@ import pytest
from dvc.exceptions import InvalidArgumentError
from dvc.repo.experiments.refs import EXPS_NAMESPACE, ExpRefInfo
from dvc.repo.experiments.utils import check_ref_format, resolve_name
from dvc.repo.experiments.utils import check_ref_format, resolve_name, to_studio_params
def commit_exp_ref(tmp_dir, scm, file="foo", contents="foo", name="foo"):
@@ -53,3 +53,20 @@ def test_run_check_ref_format(scm, name, result):
else:
with pytest.raises(InvalidArgumentError):
check_ref_format(scm, ref)
@pytest.mark.parametrize(
"params,expected",
[
(
{"workspace": {"data": {"params.yaml": {"data": {"foo": 1}}}}},
{"params.yaml": {"foo": 1}},
),
(
{"workspace": {"error": "something went wrong"}},
{},
),
],
)
def test_to_studio_params(params, expected):
assert to_studio_params(params) == expected
-1
View File
@@ -64,7 +64,6 @@ def test_iter_revs(
assert gen == {
rev_new: [rev_new],
rev_old: [rev_old],
rev_root: [rev_root],
}
def _resolve_commit(rev):
+20
View File
@@ -1,3 +1,4 @@
import logging
import os
import textwrap
@@ -94,3 +95,22 @@ def test_load_configob_error(tmp_dir, dvc, mocker):
with pytest.raises(ConfigError):
with config.edit():
pass
def test_feature_section_supports_arbitrary_values(caplog):
with caplog.at_level(logging.WARNING, logger="dvc.config_schema"):
data = Config.validate(
{
"feature": {
"random_key_1": "random_value_1",
"random_key_2": 42,
}
}
)
assert "random_key_1" not in data
assert "random_key_2" not in data
assert (
"'feature.random_key_1', 'feature.random_key_2' "
"config options are unsupported"
) in caplog.text
+1 -1
View File
@@ -87,7 +87,7 @@ def mock_dvcignore(dvcignore_path, patterns):
("to_ignore.txt", ["/*.txt"], True),
(os.path.join("path", "to_ignore.txt"), ["/*.txt"], False),
(os.path.join("data", "file.txt"), ["data/*"], True),
# (os.path.join("data", "subdir", "file.txt"), ["data/*"], False),
(os.path.join("data", "subdir", "file.txt"), ["data/*"], True),
(os.path.join("data", "file.txt"), ["data/"], True),
(os.path.join("data", "subdir", "file.txt"), ["data/"], True),
(os.path.join("data", "subdir", "file.txt"), ["subdir/"], True),