Compare commits

...
20 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 8661abf224 fetch: introduce --max-size (#9696)
Needed for iterative/studio#4782
2023-07-04 02:17:53 +03:00
Jon Crallandskshetry 541def388b environment variable to show debug options (#9693)
* environment variable to show debug options

* lint

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

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

* Add message to debug opts

* spelling

* Add argument group and move cprofile-dump

line length

linelen

lint

* use DVC_DEBUG instead of DVC_SHOW_DEBUG_OPTIONS

* use -v instead of environ

* Update dvc/_debug.py

Co-authored-by: skshetry <[email protected]>

* fix

---------

Co-authored-by: skshetry <[email protected]>
2023-07-04 01:35:33 +03:00
Ruslan Kuprieiev 58eb145b6a Delete hook-google_compute_engine.logger.py 2023-07-03 22:13:22 +03:00
Ruslan Kuprieiev 16465e0b63 Delete hook-dvc.system.py 2023-07-03 22:13:22 +03:00
Ruslan Kuprieiev 6614efd35a Delete hook-dvc.tree.gs.py 2023-07-03 22:13:22 +03:00
skshetry 2becfd9344 refactor repro: split logic for planning stages to run for repro (#9690)
The planning logic has now been moved to `plan_repro` api, that allows us
to test without creating stages.
2023-07-03 17:53:48 +05:45
skshetry b7b6642428 repro: do not autostage stage files (#9689)
The logic is now moved to experiments, where it tracks all the stage related files
itself. The `_get_stage_files()` has been modified to also return lockfile path,
so that it can be staged, and has been moved to `stage/utils.py`, and out of `reproduce.py`.

Fixes #9688.
2023-07-03 14:42:22 +05:45
skshetry fea2e6bb30 repro: simplify _reproduce_stages (#9687) 2023-07-03 12:25:25 +05:45
skshetry bfa5b1a0c3 repro: make stages run in order of the graph (#9675)
* repro: make stages run in order of the graph.

In case of --single-item, this means it'll run in the order of arguments passed.
For --downstream, this fixes running of stage out of order when multiple arguments are passed.
For normal case, this enforces the same logic, so that they are never run out-of-order.

* add tests

* tests: repro: add test for --single-item with multiple targets

* graph: support when no nodes are provided
2023-07-03 11:44:51 +05:45
Peter Rowlands 24d9a05d69 fetch: warn if cloud-versioned file has no version ID 2023-07-03 14:05:15 +09:00
skshetry 1f31a9c696 bandit: add exclusion for dvc/repo/experiments/utils.py back (#9686)
See https://github.com/PyCQA/bandit/pull/940.

Will not be needed when new version of bandit comes.
2023-07-03 09:56:45 +05:45
skshetry 097e48fcc9 remove scripts/ related suppresses (#9685) 2023-07-03 09:46:27 +05:45
skshetry 453a8f9516 ci: use kebab-case (#9684) 2023-07-03 09:16:53 +05:45
skshetry 9f59d8652e remove build scripts and scripts directory (#9683) 2023-07-03 09:05:06 +05:45
Ruslan Kuprieiev b1834f34e7 deps: limit flufl-lock to <8 (#9681)
Looks like flufl-lock switched to pdm in 8.0 which triggered a

```
pkg_resources.DistributionNotFound: The 'flufl.lock>=5' distribution was not found and is required by dvc
```

error in multiple scenarious. Can't put my finger on anything specific
in flufl-lock yet, so will just limit its version for now.

Fixes https://github.com/iterative/dvc/issues/9654
2023-07-03 03:13:50 +03:00
Ruslan Kuprieiev 536273ab1e build: pyinstaller: use __pyinstaller (#9680) 2023-07-03 01:47:31 +03:00
Ruslan Kuprieiev 5cff4891fd deps: bump dvc-data to >=2.3.2 (#9679)
Fixes #9462
Fixes #9512
2023-07-02 23:09:27 +03:00
Jon Crall e29da6eae1 Add doctest for dagascii.draw (#9678) 2023-07-01 20:36:14 +03:00
Ruslan Kuprieiev 046ebb05dd stage: cache: catch CollectCacheError (#9677) 2023-07-01 02:19:52 +03:00
skshetry 36ec25ffd7 repro: remove on_unchanged kwarg and _repro_callback (#9674)
repro: remove on_unchaged kwarg and _repro_callback
2023-06-30 10:05:44 +05:45
34 changed files with 540 additions and 355 deletions
@@ -1,4 +1,4 @@
name: Build packages
name: Build package
on:
push:
branches:
@@ -7,39 +7,35 @@ on:
types: [released, prereleased]
workflow_dispatch:
permissions: # added using https://github.com/step-security/secure-workflows
permissions:
contents: read
id-token: write
jobs:
pip:
runs-on: ubuntu-20.04
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/setup-python@v4
- uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Install
run: pip install --upgrade pip wheel
- name: Force version for Test PyPI uploads
- run: python -m pip install --upgrade pip setuptools_scm build twine
- name: Force version for Test PyPI upload
if: ${{ !startsWith(github.ref, 'refs/tags') }}
run: |
pip install setuptools_scm
echo version=$(python -m setuptools_scm | awk -F+ '{print $1}' | tail -1) >> $GITHUB_ENV
run: echo version=$(python -m setuptools_scm | awk -F+ '{print $1}' | tail -1) >> $GITHUB_ENV
- name: Build packages
run: ./scripts/build_package.sh
- run: |
echo 'PKG = "pip"'>dvc/_build.py
python -m build
env:
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.version }}
- run: twine check --strict dist/*
- name: Publish packages to PyPI
- name: Publish to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@release/v1
@@ -47,5 +43,5 @@ jobs:
if: ${{ github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository_url: https://test.pypi.org/legacy/
skip_existing: true
repository-url: https://test.pypi.org/legacy/
skip-existing: true
+5
View File
@@ -0,0 +1,5 @@
import os
def get_hook_dirs():
return [os.path.dirname(__file__)]
@@ -1 +1,2 @@
# ruff: noqa: N999
hiddenimports = ["win32timezone"]
@@ -1,3 +1,4 @@
# ruff: noqa: N999
# pylint: disable=import-error
from PyInstaller.utils.hooks import collect_submodules, is_module_or_submodule
@@ -1,3 +1,4 @@
# ruff: noqa: N999
from PyInstaller.utils.hooks import copy_metadata # pylint:disable=import-error
# needed for `dvc doctor` to show dep versions
@@ -1,3 +1,4 @@
# ruff: noqa: N999
from PyInstaller.utils.hooks import copy_metadata # pylint:disable=import-error
datas = copy_metadata("flatten-dict")
@@ -1,3 +1,4 @@
# ruff: noqa: N999
# pylint: disable=import-error
from PyInstaller.utils.hooks import collect_submodules
@@ -1 +1,2 @@
# ruff: noqa: N999
hiddenimports = ["fsspec.implementations.memory"]
@@ -1,3 +1,4 @@
# ruff: noqa: N999
from PyInstaller.utils.hooks import copy_metadata # pylint:disable=import-error
datas = copy_metadata("pydrive2")
+66 -15
View File
@@ -198,33 +198,84 @@ def debugtools(args: Optional["Namespace"] = None, **kwargs):
def add_debugging_flags(parser):
from argparse import SUPPRESS
parser.add_argument("--cprofile", action="store_true", default=False, help=SUPPRESS)
parser.add_argument("--yappi", action="store_true", default=False, help=SUPPRESS)
# For detailed info see:
# https://github.com/iterative/dvc/wiki/Debugging,-Profiling-and-Benchmarking-DVC
args, _ = parser.parse_known_args()
verbose = args.verbose
def debug_help(msg):
if verbose:
return msg
return SUPPRESS
parser = parser.add_argument_group("debug options")
parser.add_argument(
"--cprofile",
action="store_true",
default=False,
help=debug_help("Generate cprofile data for tools like snakeviz / tuna"),
)
parser.add_argument(
"--cprofile-dump", help=debug_help("Location to dump cprofile file")
)
parser.add_argument(
"--yappi",
action="store_true",
default=False,
help=debug_help(
"Generate a callgrind file for use with tools like "
"kcachegrind / qcachegrind"
),
)
parser.add_argument(
"--yappi-separate-threads",
action="store_true",
default=False,
help=SUPPRESS,
help=debug_help("Generate one callgrind file per thread"),
)
parser.add_argument(
"--viztracer", action="store_true", default=False, help=SUPPRESS
)
parser.add_argument("--viztracer-depth", type=int, help=SUPPRESS)
parser.add_argument(
"--viztracer-async", action="store_true", default=False, help=SUPPRESS
)
parser.add_argument("--cprofile-dump", help=SUPPRESS)
parser.add_argument("--pdb", action="store_true", default=False, help=SUPPRESS)
parser.add_argument(
"--instrument", action="store_true", default=False, help=SUPPRESS
"--viztracer",
action="store_true",
default=False,
help=debug_help("Generate a viztracer file for use with vizviewer"),
)
parser.add_argument(
"--instrument-open", action="store_true", default=False, help=SUPPRESS
"--viztracer-depth",
type=int,
help=debug_help("Set viztracer maximum stack depth"),
)
parser.add_argument(
"--viztracer-async",
action="store_true",
default=False,
help=debug_help("Treat async tasks as threads"),
)
parser.add_argument(
"--pdb",
action="store_true",
default=False,
help=debug_help("Drop into the pdb/ipdb debugger on any exception"),
)
parser.add_argument(
"--instrument",
action="store_true",
default=False,
help=debug_help("Use pyinstrument CLI profiler"),
)
parser.add_argument(
"--instrument-open",
action="store_true",
default=False,
help=debug_help("Use pyinstrument web profiler"),
)
parser.add_argument(
"--show-stack",
"--ss",
action="store_true",
default=False,
help=SUPPRESS,
help=debug_help(
r"Use Ctrl+T on macOS or Ctrl+\ on Linux to print the stack "
"frame currently executing. Unavailable on Windows."
),
)
+1 -1
View File
@@ -137,7 +137,6 @@ def get_parent_parser():
from dvc._debug import add_debugging_flags
parent_parser = argparse.ArgumentParser(add_help=False)
add_debugging_flags(parent_parser)
log_level_group = parent_parser.add_mutually_exclusive_group()
log_level_group.add_argument(
"-q", "--quiet", action="count", default=0, help="Be quiet."
@@ -145,6 +144,7 @@ def get_parent_parser():
log_level_group.add_argument(
"-v", "--verbose", action="count", default=0, help="Be verbose."
)
add_debugging_flags(parent_parser)
return parent_parser
+6
View File
@@ -91,6 +91,7 @@ class CmdDataFetch(CmdDataBase):
with_deps=self.args.with_deps,
recursive=self.args.recursive,
run_cache=self.args.run_cache,
max_size=self.args.max_size,
)
self.log_summary({"fetched": processed_files_count})
except DvcException:
@@ -322,6 +323,11 @@ def add_parser(subparsers, _parent_parser):
default=False,
help="Fetch run history for all stages.",
)
fetch_parser.add_argument(
"--max-size",
type=int,
help="Fetch data files/directories that are each below specified size (bytes).",
)
fetch_parser.set_defaults(func=CmdDataFetch)
# Status
+24
View File
@@ -209,6 +209,30 @@ def draw(vertices, edges):
Args:
vertices (list): list of graph vertices.
edges (list): list of graph edges.
Returns:
str: ASCII representation
Example:
>>> from dvc.dagascii import draw
>>> vertices = [1, 2, 3, 4]
>>> edges = [(1, 2), (2, 3), (2, 4), (1, 4)]
>>> print(draw(vertices, edges))
+---+ +---+
| 3 | | 4 |
+---+ *+---+
* ** *
* ** *
* * *
+---+ *
| 2 | *
+---+ *
* *
* *
**
+---+
| 1 |
+---+
"""
# pylint: disable=too-many-locals
# NOTE: coordinates might me negative, so we need to shift
+14 -16
View File
@@ -44,7 +44,7 @@ if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.repo.experiments.stash import ExpStashEntry
from dvc.scm import Git
from dvc.stage import PipelineStage
from dvc.stage import PipelineStage, Stage
logger = logging.getLogger(__name__)
@@ -255,6 +255,15 @@ class BaseExecutor(ABC):
**kwargs,
)
@classmethod
def _get_stage_files(cls, stages: List["Stage"]) -> List[str]:
from dvc.stage.utils import _get_stage_files
ret: List[str] = []
for stage in stages:
ret.extend(_get_stage_files(stage))
return ret
@classmethod
def _get_top_level_paths(cls, repo: "Repo") -> List["str"]:
return list(
@@ -459,25 +468,16 @@ class BaseExecutor(ABC):
should force overwrite any existing duplicates.
"""
from dvc.repo.checkout import checkout as dvc_checkout
from dvc.repo.reproduce import reproduce as dvc_reproduce
from dvc.stage import PipelineStage
from dvc.ui import ui
auto_push = env2bool(DVC_EXP_AUTO_PUSH)
git_remote = os.getenv(DVC_EXP_GIT_REMOTE, None)
unchanged = []
if queue is not None:
queue.put((rev, os.getpid()))
if log_errors and log_level is not None:
cls._set_log_level(log_level)
def filter_pipeline(stages):
unchanged.extend(
[stage for stage in stages if isinstance(stage, PipelineStage)]
)
exp_hash: Optional[str] = None
exp_ref: Optional["ExpRefInfo"] = None
repro_force: bool = False
@@ -519,12 +519,10 @@ class BaseExecutor(ABC):
recursive=kwargs.get("recursive", False),
)
stages = dvc_reproduce(
dvc,
*args,
on_unchanged=filter_pipeline,
**kwargs,
)
stages = dvc.reproduce(*args, **kwargs)
if paths := cls._get_stage_files(stages):
logger.debug("Staging stage-related files: %s", paths)
dvc.scm_context.add(paths)
if paths := cls._get_top_level_paths(dvc):
logger.debug("Staging top-level files: %s", paths)
dvc.scm_context.add(paths)
+33
View File
@@ -1,6 +1,8 @@
import logging
from typing import Dict, List, Tuple
from dvc.exceptions import DownloadError
from dvc_data.index import DataIndex, FileStorage
from . import locked
@@ -20,6 +22,7 @@ def fetch( # noqa: C901, PLR0913
all_commits=False,
run_cache=False,
revs=None,
max_size=None,
) -> int:
"""Download data items from a cloud and imported repositories
@@ -68,6 +71,7 @@ def fetch( # noqa: C901, PLR0913
targets,
with_deps=with_deps,
recursive=recursive,
max_size=max_size,
)
index_keys.add(idx.data_tree.hash_info.value)
indexes.append(idx.data["repo"])
@@ -84,6 +88,7 @@ def fetch( # noqa: C901, PLR0913
data = collect(
indexes, cache_index=self.data_index, cache_key=cache_key, callback=cb
)
failed_count += _log_unversioned(data)
with Callback.as_tqdm_callback(
desc="Fetching",
@@ -109,3 +114,31 @@ def fetch( # noqa: C901, PLR0913
raise DownloadError(failed_count)
return transferred_count
def _log_unversioned(data: Dict[Tuple[str, str], "DataIndex"]) -> int:
unversioned: List[str] = []
for by_fs, fs_index in data.items():
remote = fs_index.storage_map[()].remote
if not isinstance(remote, FileStorage) or not remote.fs.version_aware:
continue
fs = remote.fs
index = DataIndex()
index.storage_map = fs_index.storage_map
for key, entry in fs_index.iteritems():
if entry.meta and not entry.meta.isdir and entry.meta.version_id is None:
unversioned.append(fs.unstrip_protocol(fs.path.join(remote.path, *key)))
else:
index[key] = entry
fs_index.close()
data[by_fs] = index
if unversioned:
logger.warning(
(
"Some files are missing cloud version information and will not be "
"fetched from the remote:\n%s"
),
"\n".join(unversioned),
)
return len(unversioned)
+27 -1
View File
@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Iterator, List, Set
from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Set, TypeVar
from dvc.fs import localfs
from dvc.utils.fs import path_isin
@@ -8,6 +8,8 @@ if TYPE_CHECKING:
from dvc.stage import Stage
T = TypeVar("T")
def check_acyclic(graph: "DiGraph") -> None:
import networkx as nx
@@ -39,6 +41,30 @@ def get_pipelines(graph: "DiGraph"):
return [graph.subgraph(c).copy() for c in nx.weakly_connected_components(graph)]
def get_subgraph_of_nodes(
graph: "DiGraph", sources: Optional[List[Any]] = None, downstream: bool = False
) -> "DiGraph":
from networkx import dfs_postorder_nodes, reverse_view
if not sources:
return graph
g = reverse_view(graph) if downstream else graph
nodes = []
for source in sources:
nodes.extend(dfs_postorder_nodes(g, source))
return graph.subgraph(nodes)
def get_steps(
graph: "DiGraph", sources: Optional[List[T]] = None, downstream: bool = False
) -> List[T]:
from networkx import dfs_postorder_nodes
sub = get_subgraph_of_nodes(graph, sources, downstream=downstream)
return list(dfs_postorder_nodes(sub))
def collect_pipeline(stage: "Stage", graph: "DiGraph") -> Iterator["Stage"]:
import networkx as nx
+12 -1
View File
@@ -492,6 +492,7 @@ class Index:
targets: Optional["TargetType"],
stage_filter: Optional[Callable[["Stage"], bool]] = None,
outs_filter: Optional[Callable[["Output"], bool]] = None,
max_size: Optional[int] = None,
**kwargs: Any,
) -> "IndexView":
"""Return read-only view of index for the specified targets.
@@ -514,7 +515,17 @@ class Index:
for stage_info in self.collect_targets(targets, **kwargs)
if not stage_filter or stage_filter(stage_info.stage)
]
return IndexView(self, stage_infos, outs_filter=outs_filter)
def _outs_filter(out):
if max_size and out.meta and out.meta.size and out.meta.size >= max_size:
return False
if outs_filter:
return outs_filter(out)
return True
return IndexView(self, stage_infos, outs_filter=_outs_filter)
class _DataPrefixes(NamedTuple):
+12 -7
View File
@@ -1,5 +1,6 @@
import logging
from dvc.exceptions import CheckoutError
from dvc.repo import locked
from dvc.utils import glob_targets
@@ -38,13 +39,17 @@ def pull( # noqa: PLR0913
recursive=recursive,
run_cache=run_cache,
)
stats = self.checkout(
targets=expanded_targets,
with_deps=with_deps,
force=force,
recursive=recursive,
allow_missing=allow_missing,
)
try:
stats = self.checkout(
targets=expanded_targets,
with_deps=with_deps,
force=force,
recursive=recursive,
allow_missing=allow_missing,
)
except CheckoutError as exc:
exc.stats["fetched"] = processed_files_count
raise
stats["fetched"] = processed_files_count
return stats
+109 -165
View File
@@ -1,13 +1,18 @@
import logging
from typing import TYPE_CHECKING, Iterator, List
from typing import TYPE_CHECKING, Iterable, List, Optional, Union, cast
from funcy import ldistinct
from dvc.exceptions import ReproductionError
from dvc.repo.scm_context import scm_context
from dvc.stage.cache import RunCacheNotSupported
from dvc.utils.collections import ensure_list
from . import locked
if TYPE_CHECKING:
from networkx import DiGraph
from dvc.stage import Stage
from . import Repo
@@ -15,116 +20,47 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _reproduce_stage(stage: "Stage", **kwargs) -> List["Stage"]:
def _reproduce_stage(stage: "Stage", **kwargs) -> Optional["Stage"]:
if stage.frozen and not stage.is_import:
logger.warning(
"%s is frozen. Its dependencies are not going to be reproduced.",
stage,
)
stage = stage.reproduce(**kwargs)
if not stage:
return []
if not kwargs.get("dry", False):
ret = stage.reproduce(**kwargs)
if ret and not kwargs.get("dry", False):
stage.dump(update_pipeline=False)
_track_stage(stage)
return [stage]
return ret
def _get_stage_files(stage: "Stage") -> Iterator[str]:
yield stage.dvcfile.relpath
for dep in stage.deps:
if (
not dep.use_scm_ignore
and dep.is_in_repo
and not stage.repo.dvcfs.isdvc(stage.repo.dvcfs.from_os_path(str(dep)))
):
yield dep.fs_path
for out in stage.outs:
if not out.use_scm_ignore and out.is_in_repo:
yield out.fs_path
def collect_stages(
repo: "Repo",
targets: Iterable[str],
recursive: bool = False,
glob: bool = False,
) -> List["Stage"]:
stages: List["Stage"] = []
for target in targets:
stages.extend(repo.stage.collect(target, recursive=recursive, glob=glob))
return ldistinct(stages)
def _track_stage(stage: "Stage") -> None:
from dvc.utils import relpath
context = stage.repo.scm_context
for path in _get_stage_files(stage):
context.track_file(relpath(path))
return context.track_changed_files()
def _remove_frozen_stages(graph: "DiGraph") -> "DiGraph":
g = cast("DiGraph", graph.copy())
for stage in graph:
if stage.frozen:
# NOTE: disconnect frozen stage from its dependencies
g.remove_edges_from(graph.out_edges(stage))
return g
@locked
@scm_context
def reproduce( # noqa: C901, PLR0912
self: "Repo",
targets=None,
recursive=False,
pipeline=False,
all_pipelines=False,
**kwargs,
):
from .graph import get_pipeline, get_pipelines
glob = kwargs.pop("glob", False)
if isinstance(targets, str):
targets = [targets]
if not all_pipelines and not targets:
from dvc.dvcfile import PROJECT_FILE
targets = [PROJECT_FILE]
interactive = kwargs.get("interactive", False)
if not interactive:
kwargs["interactive"] = self.config["core"].get("interactive", False)
stages = set()
if pipeline or all_pipelines:
pipelines = get_pipelines(self.index.graph)
if all_pipelines:
used_pipelines = pipelines
else:
used_pipelines = []
for target in targets:
stage = self.stage.get_target(target)
used_pipelines.append(get_pipeline(pipelines, stage))
for pline in used_pipelines:
for stage in pline:
if pline.in_degree(stage) == 0:
stages.add(stage)
else:
for target in targets:
stages.update(
self.stage.collect(
target,
recursive=recursive,
glob=glob,
)
)
if kwargs.get("pull", False) and kwargs.get("run_cache", True):
logger.debug("Pulling run cache")
try:
self.stage_cache.pull(None)
except RunCacheNotSupported as e:
logger.warning("Failed to pull run cache: %s", e)
return _reproduce_stages(self.index.graph, list(stages), **kwargs)
def _reproduce_stages( # noqa: C901
graph,
stages,
downstream=False,
single_item=False,
on_unchanged=None,
**kwargs,
):
def plan_repro(
graph: "DiGraph",
stages: Optional[List["Stage"]] = None,
pipeline: bool = False,
downstream: bool = False,
all_pipelines: bool = False,
) -> List["Stage"]:
r"""Derive the evaluation of the given node for the given graph.
When you _reproduce a stage_, you want to _evaluate the descendants_
@@ -160,80 +96,88 @@ def _reproduce_stages( # noqa: C901
The derived evaluation of _downstream_ B would be: [B, D, E]
"""
steps = _get_steps(graph, stages, downstream, single_item)
from .graph import get_pipeline, get_pipelines, get_steps
force_downstream = kwargs.pop("force_downstream", False)
result: List["Stage"] = []
unchanged: List["Stage"] = []
# `ret` is used to add a cosmetic newline.
ret: List["Stage"] = []
if pipeline or all_pipelines:
pipelines = get_pipelines(graph)
if stages and pipeline:
pipelines = [get_pipeline(pipelines, stage) for stage in stages]
for stage in steps:
if ret:
logger.info("")
leaves: List["Stage"] = []
for pline in pipelines:
leaves.extend(node for node in pline if pline.in_degree(node) == 0)
stages = ldistinct(leaves)
active = _remove_frozen_stages(graph)
return get_steps(active, stages, downstream=downstream)
@locked
@scm_context
def reproduce( # noqa: C901
self: "Repo",
targets: Union[Iterable[str], str, None] = None,
recursive: bool = False,
pipeline: bool = False,
all_pipelines: bool = False,
downstream: bool = False,
single_item: bool = False,
glob: bool = False,
**kwargs,
):
from dvc.dvcfile import PROJECT_FILE
if not kwargs.get("interactive", False):
kwargs["interactive"] = self.config["core"].get("interactive", False)
stages: List["Stage"] = []
if not all_pipelines:
targets_list = ensure_list(targets or PROJECT_FILE)
stages = collect_stages(self, targets_list, recursive=recursive, glob=glob)
if kwargs.get("pull", False) and kwargs.get("run_cache", True):
logger.debug("Pulling run cache")
try:
kwargs["upstream"] = result + unchanged
ret = _reproduce_stage(stage, **kwargs)
self.stage_cache.pull(None)
except RunCacheNotSupported as e:
logger.warning("Failed to pull run cache: %s", e)
if len(ret) == 0:
unchanged.extend([stage])
elif force_downstream:
# NOTE: we are walking our pipeline from the top to the
# bottom. If one stage is changed, it will be reproduced,
# which tells us that we should force reproducing all of
# the other stages down below, even if their direct
# dependencies didn't change.
kwargs["force"] = True
steps = stages
if pipeline or all_pipelines or not single_item:
graph = self.index.graph
steps = plan_repro(
graph,
stages,
pipeline=pipeline,
downstream=downstream,
all_pipelines=all_pipelines,
)
return _reproduce_stages(steps, **kwargs)
if ret:
result.extend(ret)
def _reproduce_stages(
stages: List["Stage"],
force_downstream: bool = False,
**kwargs,
) -> List["Stage"]:
result: List["Stage"] = []
for i, stage in enumerate(stages):
try:
ret = _reproduce_stage(stage, upstream=stages[:i], **kwargs)
except Exception as exc: # noqa: BLE001
raise ReproductionError(stage.addressing) from exc
if on_unchanged is not None:
on_unchanged(unchanged)
if not ret:
continue
result.append(ret)
if force_downstream:
# NOTE: we are walking our pipeline from the top to the
# bottom. If one stage is changed, it will be reproduced,
# which tells us that we should force reproducing all of
# the other stages down below, even if their direct
# dependencies didn't change.
kwargs["force"] = True
if i < len(stages) - 1:
logger.info("") # add a newline
return result
def _get_steps(graph, stages, downstream, single_item):
import networkx as nx
active = graph.copy()
if not single_item:
# NOTE: frozen stages don't matter for single_item
for stage in graph:
if stage.frozen:
# NOTE: disconnect frozen stage from its dependencies
active.remove_edges_from(graph.out_edges(stage))
all_pipelines: List["Stage"] = []
for stage in stages:
if downstream:
# NOTE (py3 only):
# Python's `deepcopy` defaults to pickle/unpickle the object.
# Stages are complex objects (with references to `repo`,
# `outs`, and `deps`) that cause struggles when you try
# to serialize them. We need to create a copy of the graph
# itself, and then reverse it, instead of using
# graph.reverse() directly because it calls `deepcopy`
# underneath -- unless copy=False is specified.
nodes = nx.dfs_postorder_nodes(active.reverse(copy=False), stage)
all_pipelines += reversed(list(nodes))
else:
all_pipelines += nx.dfs_postorder_nodes(active, stage)
steps = []
for stage in all_pipelines:
if stage not in steps:
# NOTE: order of steps still matters for single_item
if single_item and stage not in stages:
continue
steps.append(stage)
return steps
def _repro_callback(experiments_callback, unchanged, stages):
experiments_callback(unchanged, stages)
+7 -4
View File
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, List, Optional, Tuple
from funcy import first
from dvc import fs
from dvc.exceptions import DvcException
from dvc.exceptions import CollectCacheError, DvcException
from dvc.utils import dict_sha256, relpath
if TYPE_CHECKING:
@@ -179,7 +179,7 @@ class StageCache:
dump_yaml(tmp, cache)
self.repo.cache.legacy.move(tmp, path)
def restore(self, stage, run_cache=True, pull=False, dry=False):
def restore(self, stage, run_cache=True, pull=False, dry=False): # noqa: C901
from .serialize import to_single_stage_lockfile
if not _can_hash(stage):
@@ -203,8 +203,11 @@ class StageCache:
cached_stage = self._create_stage(cache, wdir=stage.wdir)
if pull and not dry:
for objs in cached_stage.get_used_objs().values():
self.repo.cloud.pull(objs)
try:
for objs in cached_stage.get_used_objs().values():
self.repo.cloud.pull(objs)
except CollectCacheError as exc:
raise RunCacheNotFoundError(stage) from exc
if not cached_stage.outs_cached():
raise RunCacheNotFoundError(stage)
+24
View File
@@ -280,3 +280,27 @@ def validate_kwargs(
kwargs.pop("name", None)
return kwargs
def _get_stage_files(stage: "Stage") -> List[str]:
from dvc.dvcfile import ProjectFile
from dvc.utils import relpath
ret: List[str] = []
file = stage.dvcfile
ret.append(file.relpath)
if isinstance(file, ProjectFile):
ret.append(file._lockfile.relpath) # pylint: disable=protected-access
for dep in stage.deps:
if (
not dep.use_scm_ignore
and dep.is_in_repo
and not stage.repo.dvcfs.isdvc(stage.repo.dvcfs.from_os_path(str(dep)))
):
ret.append(relpath(dep.fs_path))
for out in stage.outs:
if not out.use_scm_ignore and out.is_in_repo:
ret.append(relpath(out.fs_path))
return ret
+9 -4
View File
@@ -34,13 +34,14 @@ dependencies = [
"configobj>=5.0.6",
"distro>=1.3",
"dpath<3,>=2.1.0",
"dvc-data>=2.3.0,<2.4.0",
"dvc-data>=2.3.2,<2.4.0",
"dvc-http>=2.29.0",
"dvc-render>=0.3.1,<1",
"dvc-studio-client>=0.9.2,<1",
"dvc-task>=0.3.0,<1",
"flatten_dict<1,>=0.4.1",
"flufl.lock>=5",
# https://github.com/iterative/dvc/issues/9654
"flufl.lock>=5,<8",
"funcy>=1.14",
"grandalf<1,>=0.7",
"hydra-core>=1.1",
@@ -82,6 +83,7 @@ lint = [
"types-tabulate",
"types-toml",
"types-tqdm",
"types-pyinstaller",
]
oss = ["dvc-oss==2.19"]
s3 = ["dvc-s3==2.23.0"]
@@ -128,6 +130,10 @@ dvc = "dvc.api:DVCFileSystem"
[project.entry-points."pytest11"]
dvc-testing = "dvc.testing.plugin"
[project.entry-points."pyinstaller40"]
hook-dirs = "dvc.__pyinstaller:get_hook_dirs"
tests = "dvc.__pyinstaller:get_PyInstaller_tests"
[tool.setuptools]
license-files = ["LICENSE"]
@@ -292,11 +298,10 @@ ignore-names = ["M", "SCM"]
"dvc/commands/**" = ["N806"]
"dvc/testing/**" = ["ARG002"]
"dvc/testing/benchmarks/**" = ["ARG001"]
"scripts/**" = ["T201", "INP001", "TRY002"]
[tool.ruff.pylint]
max-args = 10
[tool.bandit]
exclude_dirs = ["tests", "scripts", "dvc/repo/experiments/utils.py"]
exclude_dirs = ["tests", "dvc/repo/experiments/utils.py"]
skips = ["B101"]
-3
View File
@@ -1,3 +0,0 @@
# NOTE: using strict version due to
# https://github.com/iterative/dvc/issues/7949
PyInstaller==5.9.0
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
set -e
set -x
if [ ! -d "dvc" ]; then
echo "Please run this script from repository root" >&2
exit 1
fi
echo 'PKG = "pip"' >dvc/_build.py
python -m pip install build twine
python -m build
twine check dist/*
-4
View File
@@ -1,4 +0,0 @@
/build
/dist
/dvc.spec
/dvc
-47
View File
@@ -1,47 +0,0 @@
import os
import pathlib
from subprocess import STDOUT, check_call, check_output
path = pathlib.Path(__file__).parent.absolute()
hooks = path / "hooks"
dvc = path.parent.parent / "dvc"
entry = dvc / "__main__.py"
check_call(
[
"pyinstaller",
"--additional-hooks-dir",
os.fspath(hooks),
"--name",
"dvc",
"-y",
os.fspath(entry),
],
cwd=path,
stderr=STDOUT,
)
out = check_output(
[
path / "dist" / "dvc" / "dvc",
"doctor",
],
stderr=STDOUT,
).decode()
remotes = [
"s3",
"oss",
"gdrive",
"gs",
"hdfs",
"http",
"webhdfs",
"azure",
"ssh",
"webdav",
]
print(out)
for remote in remotes:
assert f"\t{remote}" in out, f"Missing support for {remote}"
@@ -1,4 +0,0 @@
import os
if os.name == "nt":
hiddenimports = ["win32file"]
@@ -1,9 +0,0 @@
from PyInstaller.utils.hooks import copy_metadata # pylint:disable=import-error
datas = copy_metadata("google-cloud-storage")
# NOTE: including "requests" metadata because of google-resumable-media
# bug. See [1] for more info.
#
# [1] https://github.com/googleapis/google-resumable-media-python/issues/59
datas += copy_metadata("requests")
@@ -1,4 +0,0 @@
from PyInstaller.utils.hooks import copy_metadata # pylint:disable=import-error
datas = copy_metadata("google-compute-engine")
hiddenimports = ["google_cloud_engine"]
+14
View File
@@ -1282,3 +1282,17 @@ def test_repro_rm_recursive(tmp_dir, dvc):
dvc.reproduce()
assert (tmp_dir / "dir").exists()
assert not (tmp_dir / "dir" / "foo").exists()
def test_repro_single_item_with_multiple_targets(tmp_dir, dvc, copy_script):
stage1 = dvc.stage.add(cmd="echo foo > foo", outs=["foo"], name="gen-foo")
with dvc.lock:
stage1.run()
stage2 = dvc.stage.add(
cmd="python copy.py foo bar", deps=["foo"], outs=["bar"], name="copy-foo-bar"
)
assert dvc.reproduce(["copy-foo-bar", "gen-foo"], single_item=True) == [
stage2,
stage1,
]
+3
View File
@@ -18,6 +18,8 @@ def test_fetch(mocker, dvc):
"--with-deps",
"--recursive",
"--run-cache",
"--max-size",
"10",
]
)
assert cli_args.func == CmdDataFetch
@@ -37,6 +39,7 @@ def test_fetch(mocker, dvc):
with_deps=True,
recursive=True,
run_cache=True,
max_size=10,
)
+69
View File
@@ -0,0 +1,69 @@
import pytest
from networkx import DiGraph
from networkx.utils import graphs_equal
from dvc.repo.graph import get_steps, get_subgraph_of_nodes
@pytest.mark.parametrize(
"nodes,downstream,expected_edges",
[
([], False, {1: [2, 3], 2: [4, 5], 3: [6, 7], 8: [9]}),
([1], False, {1: [2, 3], 2: [4, 5], 3: [6, 7]}),
([2], False, {2: [4, 5]}),
([3], False, {3: [6, 7]}),
([8], False, [(8, 9)]),
([2, 3, 8], False, {2: [4, 5], 3: [6, 7], 8: [9]}),
([4], False, {4: []}),
([], True, {1: [2, 3], 2: [4, 5], 3: [6, 7], 8: [9]}),
([1], True, {1: []}),
([9], True, [(8, 9)]),
([2], True, [(1, 2)]),
([6], True, [(1, 3), (3, 6)]),
([2, 3, 8], True, {1: [2, 3], 8: []}),
([4, 7], True, {1: [2, 3], 2: [4], 3: [7]}),
],
)
def test_subgraph_of_nodes(nodes, downstream, expected_edges):
r"""
1
/ \
2 3 8
/ \ / \ |
4 5 6 7 9
"""
graph = DiGraph({1: [2, 3], 2: [4, 5], 3: [6, 7], 8: [9]})
subgraph = get_subgraph_of_nodes(graph, nodes, downstream=downstream)
expected = DiGraph(expected_edges)
assert graphs_equal(expected, subgraph)
@pytest.mark.parametrize(
"nodes,downstream,expected_steps",
[
([], False, [4, 5, 2, 6, 7, 3, 1, 9, 8]),
([1], False, [4, 5, 2, 6, 7, 3, 1]),
([2], False, [4, 5, 2]),
([3], False, [6, 7, 3]),
([8], False, [9, 8]),
([2, 3, 8], False, [4, 5, 2, 6, 7, 3, 9, 8]),
([4], False, [4]),
([], True, [4, 5, 2, 6, 7, 3, 1, 9, 8]),
([1], True, [1]),
([9], True, [9, 8]),
([2], True, [2, 1]),
([6], True, [6, 3, 1]),
([2, 3, 8], True, [8, 2, 3, 1]),
([4, 7], True, [4, 2, 7, 3, 1]),
],
)
def test_steps(nodes, downstream, expected_steps):
r"""
1
/ \
2 3 8
/ \ / \ |
4 5 6 7 9
"""
graph = DiGraph({1: [2, 3], 2: [4, 5], 3: [6, 7], 8: [9]})
assert get_steps(graph, nodes, downstream=downstream) == expected_steps
+45 -35
View File
@@ -1,6 +1,8 @@
import os
from itertools import chain
from dvc.repo.reproduce import _get_stage_files
from networkx import DiGraph
from dvc.repo.reproduce import plan_repro
def test_number_reproduces(tmp_dir, dvc, mocker):
@@ -21,40 +23,48 @@ def test_number_reproduces(tmp_dir, dvc, mocker):
assert reproduce_stage_mock.call_count == 5
def test_get_stage_files(tmp_dir, dvc):
tmp_dir.dvc_gen("dvc-dep", "dvc-dep")
tmp_dir.gen("other-dep", "other-dep")
def test_repro_plan(mocker):
r"""
n1
/ \
n2 m3 n8
/ \ / \ |
n4 n5 n6 n7 n9
"""
stage = dvc.stage.add(
name="stage",
cmd="foo",
deps=["dvc-dep", "other-dep"],
outs=["dvc-out"],
outs_no_cache=["other-out"],
)
result = set(_get_stage_files(stage))
assert result == {
stage.dvcfile.relpath,
str(tmp_dir / "other-dep"),
str(tmp_dir / "other-out"),
}
# note: downstream steps may not be stable, use AnyOf in such cases
class AnyOf:
def __init__(self, *items):
self.items = items
def __eq__(self, other: object) -> bool:
return any(item == other for item in self.items)
def test_get_stage_files_wdir(tmp_dir, dvc):
tmp_dir.gen({"dir": {"dvc-dep": "dvc-dep", "other-dep": "other-dep"}})
dvc.add(os.path.join("dir", "dvc-dep"))
n = mocker.sentinel
n1, n2, n3, n4, n5, n6, n7, n8, n9 = (getattr(n, f"n{i}") for i in range(1, 10))
edges = {n1: [n2, n3], n2: [n4, n5], n3: [n6, n7], n8: [n9]}
for node in chain.from_iterable([n, *v] for n, v in edges.items()):
node.frozen = False
stage = dvc.stage.add(
name="stage",
cmd="foo",
wdir="dir",
deps=["dvc-dep", "other-dep"],
outs=["dvc-out"],
outs_no_cache=["other-out"],
)
result = set(_get_stage_files(stage))
assert result == {
stage.dvcfile.relpath,
str(tmp_dir / "dir" / "other-dep"),
str(tmp_dir / "dir" / "other-out"),
}
g = DiGraph(edges)
assert plan_repro(g) == [n4, n5, n2, n6, n7, n3, n1, n9, n8]
assert plan_repro(g, [n1]) == [n4, n5, n2, n6, n7, n3, n1]
assert plan_repro(g, [n4], downstream=True) == [n4, n2, n1]
assert plan_repro(g, [n8], True) == plan_repro(g, [n9], True) == [n9, n8]
assert plan_repro(g, [n2, n8], True) == [n4, n5, n2, n6, n7, n3, n1, n9, n8]
assert plan_repro(g, [n2, n3], downstream=True) == [
AnyOf(n2, n3),
AnyOf(n2, n3),
n1,
]
n2.frozen = True
assert plan_repro(g) == [n2, n6, n7, n3, n1, n9, n8, n4, n5]
assert plan_repro(g, [n1]) == [n2, n6, n7, n3, n1]
assert plan_repro(g, [n4], downstream=True) == [n4]
assert plan_repro(g, [n2, n8], pipeline=True) == [n2, n6, n7, n3, n1, n9, n8]
assert plan_repro(g, [n2, n3], downstream=True) == [
AnyOf(n2, n3),
AnyOf(n2, n3),
n1,
]
+38 -1
View File
@@ -1,7 +1,7 @@
import os
from dvc.fs import localfs
from dvc.stage.utils import resolve_paths
from dvc.stage.utils import _get_stage_files, resolve_paths
def test_resolve_paths():
@@ -19,3 +19,40 @@ def test_resolve_paths():
path, wdir = resolve_paths(fs=localfs, path=file_path, wdir="../../some-dir")
assert path == os.path.abspath(file_path)
assert wdir == os.path.abspath("some-dir")
def test_get_stage_files(tmp_dir, dvc):
tmp_dir.dvc_gen("dvc-dep", "dvc-dep")
tmp_dir.gen("other-dep", "other-dep")
stage = dvc.stage.create(
name="stage",
cmd="foo",
deps=["dvc-dep", "other-dep"],
outs=["dvc-out"],
outs_no_cache=["other-out"],
)
assert _get_stage_files(stage) == [
"dvc.yaml",
"dvc.lock",
"other-dep",
"other-out",
]
def test_get_stage_files_wdir(tmp_dir, dvc):
tmp_dir.gen({"dir": {"dvc-dep": "dvc-dep", "other-dep": "other-dep"}})
dvc.add(os.path.join("dir", "dvc-dep"))
stage = dvc.stage.create(
name="stage",
cmd="foo",
wdir="dir",
deps=["dvc-dep", "other-dep"],
outs=["dvc-out"],
outs_no_cache=["other-out"],
)
assert _get_stage_files(stage) == [
"dvc.yaml",
"dvc.lock",
os.path.join("dir", "other-dep"),
os.path.join("dir", "other-out"),
]