mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d70bc5ec4 | ||
|
|
176a23e291 | ||
|
|
c884c94d61 | ||
|
|
f6f18bc201 | ||
|
|
3a3fa7cdde | ||
|
|
1e1a0999f6 | ||
|
|
7f0537038c | ||
|
|
7c45711e34 | ||
|
|
1e52bc94a3 | ||
|
|
38cbd998e1 | ||
|
|
09a88b8ea6 | ||
|
|
bc1d10532a | ||
|
|
56322d0a53 | ||
|
|
f4f0554884 | ||
|
|
011bd18e9f | ||
|
|
6d745eb4f0 | ||
|
|
788d840e7d | ||
|
|
7586c32d95 | ||
|
|
dcba0e8da4 | ||
|
|
1294450a46 | ||
|
|
c989b28eae | ||
|
|
abb78ce19d | ||
|
|
bc4c9fe9ea | ||
|
|
d138bdd72a | ||
|
|
7c3c2e8123 | ||
|
|
04c53601c0 | ||
|
|
3ba70d375e | ||
|
|
85ad7358d9 | ||
|
|
cb56f57908 | ||
|
|
a7a007e4f9 | ||
|
|
e33274723d | ||
|
|
f1efc8f409 | ||
|
|
1a2c91eb5a | ||
|
|
806f8032d9 | ||
|
|
8cb4202292 | ||
|
|
5faae2a6e9 | ||
|
|
6237a665c3 | ||
|
|
011bd35a6c | ||
|
|
133018af78 | ||
|
|
c65fdcabae | ||
|
|
e1692a6be0 | ||
|
|
c8f37fdb88 | ||
|
|
7f66db7039 | ||
|
|
470f411cd6 | ||
|
|
d721e919e4 | ||
|
|
c03fbb0056 | ||
|
|
cb3e52829b | ||
|
|
4b7d130003 | ||
|
|
d5312e56a1 | ||
|
|
2a6c4291ce | ||
|
|
40c3bbd1ba | ||
|
|
92b17e3a89 | ||
|
|
153c374c02 | ||
|
|
4cf2f8139b | ||
|
|
c2ec8b27ca | ||
|
|
6324bd8a69 | ||
|
|
251a3269c2 | ||
|
|
b340254556 | ||
|
|
7501302baa | ||
|
|
d7564e9965 | ||
|
|
d06dd3caa9 | ||
|
|
9741f6d706 | ||
|
|
ca2e6a004c | ||
|
|
40bdf613f0 | ||
|
|
379ed8e6fc | ||
|
|
2f6a8a7349 | ||
|
|
d5b7f7cf2c | ||
|
|
1788fe95dd | ||
|
|
96ead9425a | ||
|
|
f4349aa09b | ||
|
|
2f620e9226 | ||
|
|
d3931def53 | ||
|
|
3ec64301b9 | ||
|
|
8e738262ad | ||
|
|
3daeeaffdb | ||
|
|
eeba567774 | ||
|
|
72eb4a5737 | ||
|
|
72ec2a8541 | ||
|
|
07676b2531 | ||
|
|
556143fd7e | ||
|
|
b8cd02457a | ||
|
|
de01b11483 | ||
|
|
38d811035c | ||
|
|
389efd7ae6 | ||
|
|
f0ad1b4f93 | ||
|
|
f6d40948d6 | ||
|
|
4b27fd7c23 | ||
|
|
4220baa602 | ||
|
|
2e9fa0607e | ||
|
|
685e76cfbe | ||
|
|
dca48fe8d7 | ||
|
|
9f1330a387 | ||
|
|
f4ee0e16b4 | ||
|
|
4531aa34ca | ||
|
|
80e181f2fc | ||
|
|
c85a46cdda | ||
|
|
89bf7fce13 | ||
|
|
42d50f084f | ||
|
|
edd8344659 | ||
|
|
4b17685351 | ||
|
|
e1b82c5222 | ||
|
|
e9a9415fbf | ||
|
|
6494bd0151 | ||
|
|
d1441ee16a | ||
|
|
7da3de451f | ||
|
|
4a3f06df3a | ||
|
|
2225764438 | ||
|
|
644444b5a7 | ||
|
|
60b69b93cb | ||
|
|
fec1499c67 | ||
|
|
a38941b47e | ||
|
|
6a9ab9cdfb | ||
|
|
e211423398 | ||
|
|
96cdd073bd | ||
|
|
59d5c73c5e | ||
|
|
58bed0e9ba | ||
|
|
92752e0392 | ||
|
|
4a7bb49bb0 | ||
|
|
3d58f4b8ce | ||
|
|
f827d641d5 | ||
|
|
9940540917 | ||
|
|
32fa8e33c4 | ||
|
|
911534116e | ||
|
|
9b136f0c3f | ||
|
|
411b388960 | ||
|
|
e6016ca202 | ||
|
|
b2495aa230 | ||
|
|
f4522760ba | ||
|
|
0736c64188 | ||
|
|
6cf194f85f | ||
|
|
5038fd225b | ||
|
|
bfecd7d8e8 | ||
|
|
a5bdfa90f0 | ||
|
|
4d75e75631 | ||
|
|
f8ba5daae2 | ||
|
|
831e85682e | ||
|
|
ec05e6026f | ||
|
|
0669d8d885 | ||
|
|
2c7d89939c | ||
|
|
e8c4924997 | ||
|
|
2747e77012 | ||
|
|
87f1c42acf | ||
|
|
8a5dc6a781 | ||
|
|
33ece011fb | ||
|
|
79232dfedf | ||
|
|
aa7b4c6340 | ||
|
|
668002cb01 | ||
|
|
25efd0efe8 | ||
|
|
b0ae5e2251 | ||
|
|
e48055077d | ||
|
|
7527a220f6 | ||
|
|
feaa5cfcb2 | ||
|
|
10ae13132d | ||
|
|
924323f54f | ||
|
|
235d4c9a94 | ||
|
|
4b6ebc7527 | ||
|
|
699602f4d0 | ||
|
|
10a2eeb2b8 | ||
|
|
b9f547637c | ||
|
|
052b15a2a8 | ||
|
|
68220cbea0 | ||
|
|
21a6be7c58 | ||
|
|
2b90d7dfaf | ||
|
|
9d7e483599 | ||
|
|
8f8c561d1b | ||
|
|
32cef509b7 | ||
|
|
6a79111047 | ||
|
|
09e51e655d | ||
|
|
4aa8fd0fbb | ||
|
|
3cebd98c54 | ||
|
|
b11fe83828 | ||
|
|
cc8d0ef43f | ||
|
|
3e011d70d5 | ||
|
|
0649242578 | ||
|
|
549f342db9 | ||
|
|
4e53491f5c | ||
|
|
0da1664f3f | ||
|
|
3b6a1c441e | ||
|
|
ca490f699e | ||
|
|
59105ec639 | ||
|
|
1d550f35af | ||
|
|
fba94a4569 | ||
|
|
0e257adc63 | ||
|
|
8b28a268e8 | ||
|
|
09f9506ca0 | ||
|
|
f20adf5e0f | ||
|
|
9f90c806d5 | ||
|
|
c16d3fa43d | ||
|
|
3ceef7dbf1 | ||
|
|
34804b5636 | ||
|
|
060fc4fbb7 |
+10
-1
@@ -1 +1,10 @@
|
||||
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: 85
|
||||
threshold: 10%
|
||||
patch:
|
||||
default:
|
||||
threshold: 25%
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
[run]
|
||||
branch = True
|
||||
source = dvc
|
||||
omit =
|
||||
*__main__.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
if __name__ == .__main__.:
|
||||
if typing.TYPE_CHECKING:
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
raise NotImplementedError
|
||||
# pragma: no cover
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-16.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up Ruby 2.4
|
||||
@@ -43,9 +43,9 @@ jobs:
|
||||
mac:
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up Ruby 2.4
|
||||
@@ -67,9 +67,9 @@ jobs:
|
||||
windows:
|
||||
runs-on: windows-2019
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Build
|
||||
@@ -77,9 +77,9 @@ jobs:
|
||||
shell: cmd
|
||||
- name: Publish
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: dvc-${{ github.event.release.tag_name }}.exe
|
||||
@@ -88,9 +88,9 @@ jobs:
|
||||
python:
|
||||
runs-on: ubuntu-16.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
snap:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Build the package
|
||||
uses: snapcore/action-build@v1
|
||||
id: snapcraft
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
name: Tests build
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
DVC_TEST: "true"
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
SHELL: /bin/bash
|
||||
GDRIVE_CREDENTIALS_DATA: ${{ secrets.GDRIVE_CREDENTIALS_DATA }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
GS_CREDS_JSON: ${{ secrets.GS_CREDS_JSON }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v2.3.2
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v2.1.4
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install requirements
|
||||
@@ -29,33 +38,49 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04, windows-2019, macos-10.15]
|
||||
pyv: [3.6, 3.7, 3.8, "3.9-dev"]
|
||||
pyv: ["3.6", "3.7", "3.8", "3.9"]
|
||||
include:
|
||||
- os: "macos-11.0"
|
||||
pyv: "3.9"
|
||||
exclude:
|
||||
- os: windows-2019
|
||||
pyv: "3.9-dev"
|
||||
- os: macos-10.15
|
||||
pyv: "3.9"
|
||||
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python
|
||||
uses: actions/[email protected].2
|
||||
uses: actions/[email protected].4
|
||||
with:
|
||||
python-version: ${{ matrix.pyv }}
|
||||
- name: Install libxml2 and libxslt
|
||||
if: matrix.pyv == '3.9-dev' && matrix.os == 'ubuntu-18.04'
|
||||
# need to install for building lxml, a dependency of webdavclient3
|
||||
run: |
|
||||
sudo apt install libxml2 libxslt-dev
|
||||
- name: install
|
||||
run: |
|
||||
pip install ".[all,tests]"
|
||||
pip install wheel
|
||||
pip install -e ".[all,tests]"
|
||||
- name: setup git
|
||||
run: |
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "DVC Tester"
|
||||
- name: setup gs creds
|
||||
shell: bash
|
||||
if: env.GS_CREDS_JSON != ''
|
||||
run: |
|
||||
mkdir -p scripts/ci
|
||||
echo "$GS_CREDS_JSON" > scripts/ci/gcp-creds.json
|
||||
- name: run tests
|
||||
run: python -m tests -ra --cov-report=xml
|
||||
run: python -m tests --all --cov-report=xml --cov-report=term --tap-combined
|
||||
- name: upload coverage report
|
||||
if: github.event.name == 'push'
|
||||
uses: codecov/[email protected]
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
- name: Post TAP
|
||||
# Running custom fork with an option, that does not fail CI on error
|
||||
uses: skshetry/flaky-service/packages/action@master
|
||||
if: always() && github.ref == 'refs/heads/master'
|
||||
with:
|
||||
file-path: ${{github.workspace}}/testresults.tap
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
os: ${{runner.os}}
|
||||
matrix: ${{toJson(matrix)}}
|
||||
repo-description: ${{github.event.repository.description}}
|
||||
fail_ci_if_error: false
|
||||
|
||||
@@ -5,6 +5,7 @@ neatlynx/__pycache__
|
||||
*.pyc
|
||||
.env/
|
||||
.env2.7/
|
||||
.python-version
|
||||
|
||||
.dvc.conf.lock
|
||||
.DS_Store
|
||||
|
||||
@@ -8,7 +8,7 @@ repos:
|
||||
- id: isort
|
||||
language_version: python3
|
||||
repo: https://github.com/timothycrosley/isort
|
||||
rev: 5.4.2
|
||||
rev: 5.6.4
|
||||
- hooks:
|
||||
- id: flake8
|
||||
language_version: python3
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
- flake8-debugger
|
||||
- flake8-string-format
|
||||
repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.8.3
|
||||
rev: 3.8.4
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
@@ -33,7 +33,7 @@ repos:
|
||||
id: beautysh
|
||||
language_version: python3
|
||||
repo: https://github.com/lovesegfault/beautysh
|
||||
rev: master
|
||||
rev: 6.0.1
|
||||
- hooks:
|
||||
- args:
|
||||
- git-hook
|
||||
|
||||
@@ -265,7 +265,7 @@ contextmanager-decorators=contextlib.contextmanager
|
||||
# List of members which are set dynamically and missed by pylint inference
|
||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
||||
# expressions are accepted.
|
||||
generated-members=pytest.lazy_fixture,logging.TRACE,logger.trace,sys.getwindowsversion
|
||||
generated-members=hdfs.*,pytest.lazy_fixture,logging.TRACE,logger.trace,sys.getwindowsversion
|
||||
|
||||
# Tells whether missing members accessed in mixin class should be ignored. A
|
||||
# mixin class is detected if its name ends with "mixin" (case insensitive).
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
restylers:
|
||||
- name: black
|
||||
image: restyled/restyler-black:v19.10b0
|
||||
command:
|
||||
- black
|
||||
arguments: []
|
||||
include:
|
||||
- "**/*.py"
|
||||
interpreters:
|
||||
- python
|
||||
- name: isort
|
||||
image: restyled/restyler-isort:v5.4.2
|
||||
command:
|
||||
- isort
|
||||
arguments: []
|
||||
include:
|
||||
- "**/*.py"
|
||||
interpreters:
|
||||
- python
|
||||
+3
-3
@@ -262,9 +262,9 @@ Iterative, *DVC: Data Version Control - Git for Data & Models* (2020)
|
||||
:target: https://travis-ci.com/iterative/dvc/branches
|
||||
:alt: Release
|
||||
|
||||
.. |CI| image:: https://img.shields.io/travis/com/iterative/dvc/master?label=dev&logo=travis
|
||||
:target: https://travis-ci.com/iterative/dvc/builds
|
||||
:alt: Travis dev branch
|
||||
.. |CI| image:: https://github.com/iterative/dvc/workflows/Tests/badge.svg?branch=master
|
||||
:target: https://github.com/iterative/dvc/actions
|
||||
:alt: GHA Tests
|
||||
|
||||
.. |Maintainability| image:: https://codeclimate.com/github/iterative/dvc/badges/gpa.svg
|
||||
:target: https://codeclimate.com/github/iterative/dvc
|
||||
|
||||
+5
-5
@@ -20,7 +20,7 @@ def get_url(path, repo=None, rev=None, remote=None):
|
||||
in a DVC repo. For Git repos, HEAD is used unless a rev argument is
|
||||
supplied. The default remote is tried unless a remote argument is supplied.
|
||||
|
||||
Raises OutputNotFoundError if the file is not a dvc-tracked file.
|
||||
Raises OutputNotFoundError if the file is not tracked by DVC.
|
||||
|
||||
NOTE: This function does not check for the actual existence of the file or
|
||||
directory in the remote storage.
|
||||
@@ -34,8 +34,7 @@ def get_url(path, repo=None, rev=None, remote=None):
|
||||
raise OutputNotFoundError(path, repo)
|
||||
|
||||
cloud = metadata.repo.cloud
|
||||
with _repo.state:
|
||||
hash_info = _repo.repo_tree.get_hash(path_info)
|
||||
hash_info = _repo.repo_tree.get_hash(path_info)
|
||||
return cloud.get_url_for(remote, checksum=hash_info.value)
|
||||
|
||||
|
||||
@@ -122,12 +121,13 @@ def make_checkpoint():
|
||||
import builtins
|
||||
from time import sleep
|
||||
|
||||
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
|
||||
from dvc.stage.run import CHECKPOINT_SIGNAL_FILE
|
||||
|
||||
if os.getenv("DVC_CHECKPOINT") is None:
|
||||
if os.getenv(DVC_CHECKPOINT) is None:
|
||||
return
|
||||
|
||||
root_dir = Repo.find_root()
|
||||
root_dir = os.getenv(DVC_ROOT, Repo.find_root())
|
||||
signal_file = os.path.join(
|
||||
root_dir, Repo.DVC_DIR, "tmp", CHECKPOINT_SIGNAL_FILE
|
||||
)
|
||||
|
||||
Vendored
+39
-42
@@ -1,45 +1,31 @@
|
||||
"""Manages cache of a DVC repo."""
|
||||
from collections import defaultdict
|
||||
|
||||
from funcy import cached_property
|
||||
from ..scheme import Schemes
|
||||
|
||||
|
||||
def _make_remote_property(name):
|
||||
"""
|
||||
The config file is stored in a way that allows you to have a
|
||||
cache for each remote.
|
||||
def get_cloud_cache(tree):
|
||||
from .base import CloudCache
|
||||
from .local import LocalCache
|
||||
from .ssh import SSHCache
|
||||
|
||||
This is needed when specifying external outputs
|
||||
(as they require you to have an external cache location).
|
||||
if tree.scheme == Schemes.LOCAL:
|
||||
return LocalCache(tree)
|
||||
|
||||
Imagine a config file like the following:
|
||||
if tree.scheme == Schemes.SSH:
|
||||
return SSHCache(tree)
|
||||
|
||||
['remote "dvc-storage"']
|
||||
url = ssh://localhost/tmp
|
||||
ask_password = true
|
||||
return CloudCache(tree)
|
||||
|
||||
[cache]
|
||||
ssh = dvc-storage
|
||||
|
||||
This method creates a cached property, containing cache named `name`:
|
||||
def _get_cache(repo, settings):
|
||||
from ..tree import get_cloud_tree
|
||||
|
||||
self.config == {'ssh': 'dvc-storage'}
|
||||
self.ssh # a RemoteSSH instance
|
||||
"""
|
||||
if not settings:
|
||||
return None
|
||||
|
||||
def getter(self):
|
||||
from ..tree import get_cloud_tree
|
||||
from .base import CloudCache
|
||||
|
||||
remote = self.config.get(name)
|
||||
if not remote:
|
||||
return None
|
||||
|
||||
tree = get_cloud_tree(self.repo, name=remote)
|
||||
return CloudCache(tree)
|
||||
|
||||
getter.__name__ = name
|
||||
return cached_property(getter)
|
||||
tree = get_cloud_tree(repo, **settings)
|
||||
return get_cloud_cache(tree)
|
||||
|
||||
|
||||
class Cache:
|
||||
@@ -50,21 +36,25 @@ class Cache:
|
||||
"""
|
||||
|
||||
CACHE_DIR = "cache"
|
||||
CLOUD_SCHEMES = [
|
||||
Schemes.S3,
|
||||
Schemes.GS,
|
||||
Schemes.SSH,
|
||||
Schemes.HDFS,
|
||||
Schemes.WEBHDFS,
|
||||
]
|
||||
|
||||
def __init__(self, repo):
|
||||
from ..tree import get_cloud_tree
|
||||
from .local import LocalCache
|
||||
|
||||
self.repo = repo
|
||||
self.config = config = repo.config["cache"]
|
||||
self._cache = {}
|
||||
|
||||
local = config.get("local")
|
||||
|
||||
if local:
|
||||
settings = {"name": local}
|
||||
elif "dir" not in config:
|
||||
self.local = None
|
||||
return
|
||||
settings = None
|
||||
else:
|
||||
from ..config import LOCAL_COMMON
|
||||
|
||||
@@ -73,14 +63,21 @@ class Cache:
|
||||
if opt in config:
|
||||
settings[str(opt)] = config.get(opt)
|
||||
|
||||
tree = get_cloud_tree(repo, **settings)
|
||||
self.local = LocalCache(tree)
|
||||
self._cache[Schemes.LOCAL] = _get_cache(repo, settings)
|
||||
|
||||
s3 = _make_remote_property("s3")
|
||||
gs = _make_remote_property("gs")
|
||||
ssh = _make_remote_property("ssh")
|
||||
hdfs = _make_remote_property("hdfs")
|
||||
azure = _make_remote_property("azure")
|
||||
for scheme in self.CLOUD_SCHEMES:
|
||||
remote = self.config.get(scheme)
|
||||
settings = {"name": remote} if remote else None
|
||||
self._cache[scheme] = _get_cache(repo, settings)
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self._cache[name]
|
||||
except KeyError as exc:
|
||||
raise AttributeError from exc
|
||||
|
||||
def by_scheme(self):
|
||||
yield from self._cache.items()
|
||||
|
||||
|
||||
class NamedCacheItem:
|
||||
|
||||
Vendored
+378
-148
@@ -1,39 +1,30 @@
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import copy
|
||||
from operator import itemgetter
|
||||
from typing import Optional
|
||||
|
||||
from funcy import decorator
|
||||
from shortuuid import uuid
|
||||
|
||||
import dvc.prompt as prompt
|
||||
from dvc.dir_info import DirInfo
|
||||
from dvc.exceptions import (
|
||||
CacheLinkError,
|
||||
CheckoutError,
|
||||
ConfirmRemoveError,
|
||||
DvcException,
|
||||
MergeError,
|
||||
)
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.path_info import WindowsPathInfo
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.remote.slow_link_detection import slow_link_guard
|
||||
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
|
||||
slow_link_guard,
|
||||
)
|
||||
|
||||
from ..tree.base import RemoteActionNotImplemented
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_OK = 1
|
||||
STATUS_MISSING = 2
|
||||
STATUS_NEW = 3
|
||||
STATUS_DELETED = 4
|
||||
|
||||
STATUS_MAP = {
|
||||
# (local_exists, remote_exists)
|
||||
(True, True): STATUS_OK,
|
||||
(False, False): STATUS_MISSING,
|
||||
(True, False): STATUS_NEW,
|
||||
(False, True): STATUS_DELETED,
|
||||
}
|
||||
|
||||
|
||||
class DirCacheError(DvcException):
|
||||
def __init__(self, hash_):
|
||||
@@ -42,11 +33,18 @@ class DirCacheError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
@decorator
|
||||
def use_state(call):
|
||||
tree = call._args[0].tree # pylint: disable=protected-access
|
||||
with tree.state:
|
||||
return call()
|
||||
|
||||
|
||||
class CloudCache:
|
||||
"""Cloud cache class."""
|
||||
|
||||
DEFAULT_CACHE_TYPES = ["copy"]
|
||||
CACHE_MODE = None
|
||||
CACHE_MODE: Optional[int] = None
|
||||
|
||||
def __init__(self, tree):
|
||||
self.tree = tree
|
||||
@@ -68,7 +66,7 @@ class CloudCache:
|
||||
try:
|
||||
dir_info = self.load_dir_cache(hash_info)
|
||||
except DirCacheError:
|
||||
dir_info = []
|
||||
dir_info = DirInfo()
|
||||
|
||||
self._dir_info[hash_info.value] = dir_info
|
||||
return dir_info
|
||||
@@ -87,18 +85,9 @@ class CloudCache:
|
||||
"dir cache file format error '%s' [skipping the file]",
|
||||
path_info,
|
||||
)
|
||||
return []
|
||||
d = []
|
||||
|
||||
if self.tree.PATH_CLS == WindowsPathInfo:
|
||||
# only need to convert it for Windows
|
||||
for info in d:
|
||||
# NOTE: here is a BUG, see comment to .as_posix() below
|
||||
relpath = info[self.tree.PARAM_RELPATH]
|
||||
info[self.tree.PARAM_RELPATH] = relpath.replace(
|
||||
"/", self.tree.PATH_CLS.sep
|
||||
)
|
||||
|
||||
return d
|
||||
return DirInfo.from_list(d)
|
||||
|
||||
def changed(self, path_info, hash_info):
|
||||
"""Checks if data has changed.
|
||||
@@ -184,7 +173,7 @@ class CloudCache:
|
||||
)
|
||||
del link_types[0]
|
||||
|
||||
raise DvcException("no possible cache types left to try out.")
|
||||
raise CacheLinkError([to_info])
|
||||
|
||||
def _do_link(self, from_info, to_info, link_method):
|
||||
if self.tree.exists(to_info):
|
||||
@@ -218,7 +207,7 @@ class CloudCache:
|
||||
# we need to update path and cache, since in case of reflink,
|
||||
# or copy cache type moving original file results in updates on
|
||||
# next executed command, which causes md5 recalculation
|
||||
self.tree.state.save(path_info, hash_info.value)
|
||||
self.tree.state.save(path_info, hash_info)
|
||||
else:
|
||||
if self.changed_cache(hash_info):
|
||||
with tree.open(path_info, mode="rb") as fobj:
|
||||
@@ -232,7 +221,7 @@ class CloudCache:
|
||||
if callback:
|
||||
callback(1)
|
||||
|
||||
self.tree.state.save(cache_info, hash_info.value)
|
||||
self.tree.state.save(cache_info, hash_info)
|
||||
|
||||
def _cache_is_copy(self, path_info):
|
||||
"""Checks whether cache uses copies."""
|
||||
@@ -262,14 +251,9 @@ class CloudCache:
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
# Sorting the list by path to ensure reproducibility
|
||||
if isinstance(dir_info, dict):
|
||||
dir_info = self._from_dict(dir_info)
|
||||
dir_info = sorted(dir_info, key=itemgetter(self.tree.PARAM_RELPATH))
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False).name
|
||||
with open(tmp, "w+") as fobj:
|
||||
json.dump(dir_info, fobj, sort_keys=True)
|
||||
json.dump(dir_info.to_list(), fobj, sort_keys=True)
|
||||
|
||||
from_info = PathInfo(tmp)
|
||||
to_info = self.tree.path_info / tmp_fname("")
|
||||
@@ -277,10 +261,12 @@ class CloudCache:
|
||||
|
||||
hash_info = self.tree.get_file_hash(to_info)
|
||||
hash_info.value += self.tree.CHECKSUM_DIR_SUFFIX
|
||||
hash_info.dir_info = self._to_dict(dir_info)
|
||||
hash_info.dir_info = dir_info
|
||||
hash_info.nfiles = dir_info.nfiles
|
||||
|
||||
return hash_info, to_info
|
||||
|
||||
@use_state
|
||||
def save_dir_info(self, dir_info, hash_info=None):
|
||||
if (
|
||||
hash_info
|
||||
@@ -295,20 +281,19 @@ class CloudCache:
|
||||
self.tree.makedirs(new_info.parent)
|
||||
self.tree.move(tmp_info, new_info, mode=self.CACHE_MODE)
|
||||
|
||||
self.tree.state.save(new_info, hi.value)
|
||||
self.tree.state.save(new_info, hi)
|
||||
|
||||
return hi
|
||||
|
||||
def _save_dir(self, path_info, tree, hash_info, save_link=True, **kwargs):
|
||||
if not hash_info.dir_info:
|
||||
hash_info.dir_info = self._to_dict(
|
||||
tree.cache.get_dir_cache(hash_info)
|
||||
)
|
||||
hash_info.dir_info = tree.cache.get_dir_cache(hash_info)
|
||||
hi = self.save_dir_info(hash_info.dir_info, hash_info)
|
||||
for relpath, entry_hash in Tqdm(
|
||||
hi.dir_info.items(), desc="Saving " + path_info.name, unit="file"
|
||||
for entry_info, entry_hash in Tqdm(
|
||||
hi.dir_info.items(path_info),
|
||||
desc="Saving " + path_info.name,
|
||||
unit="file",
|
||||
):
|
||||
entry_info = path_info / relpath
|
||||
self._save_file(
|
||||
entry_info, tree, entry_hash, save_link=False, **kwargs
|
||||
)
|
||||
@@ -316,11 +301,12 @@ class CloudCache:
|
||||
if save_link:
|
||||
self.tree.state.save_link(path_info)
|
||||
if self.tree.exists(path_info):
|
||||
self.tree.state.save(path_info, hi.value)
|
||||
self.tree.state.save(path_info, hi)
|
||||
|
||||
cache_info = self.tree.hash_to_path_info(hi.value)
|
||||
self.tree.state.save(cache_info, hi.value)
|
||||
self.tree.state.save(cache_info, hi)
|
||||
|
||||
@use_state
|
||||
def save(self, path_info, tree, hash_info, save_link=True, **kwargs):
|
||||
if path_info.scheme != self.tree.scheme:
|
||||
raise RemoteActionNotImplemented(
|
||||
@@ -392,13 +378,9 @@ class CloudCache:
|
||||
if self.changed_cache_file(hash_info):
|
||||
return True
|
||||
|
||||
for entry in self.get_dir_cache(hash_info):
|
||||
entry_hash = HashInfo(
|
||||
self.tree.PARAM_CHECKSUM, entry[self.tree.PARAM_CHECKSUM]
|
||||
)
|
||||
|
||||
dir_info = self.get_dir_cache(hash_info)
|
||||
for entry_info, entry_hash in dir_info.items(path_info):
|
||||
if path_info and filter_info:
|
||||
entry_info = path_info / entry[self.tree.PARAM_RELPATH]
|
||||
if not entry_info.isin_or_eq(filter_info):
|
||||
continue
|
||||
|
||||
@@ -450,7 +432,7 @@ class CloudCache:
|
||||
|
||||
self.link(cache_info, path_info)
|
||||
self.tree.state.save_link(path_info)
|
||||
self.tree.state.save(path_info, hash_info.value)
|
||||
self.tree.state.save(path_info, hash_info)
|
||||
if progress_callback:
|
||||
progress_callback(str(path_info))
|
||||
|
||||
@@ -476,21 +458,19 @@ class CloudCache:
|
||||
|
||||
logger.debug("Linking directory '%s'.", path_info)
|
||||
|
||||
for entry in dir_info:
|
||||
relative_path = entry[self.tree.PARAM_RELPATH]
|
||||
entry_hash = entry[self.tree.PARAM_CHECKSUM]
|
||||
entry_cache_info = self.tree.hash_to_path_info(entry_hash)
|
||||
entry_info = path_info / relative_path
|
||||
for entry_info, entry_hash_info in dir_info.items(path_info):
|
||||
entry_cache_info = self.tree.hash_to_path_info(
|
||||
entry_hash_info.value
|
||||
)
|
||||
|
||||
if filter_info and not entry_info.isin_or_eq(filter_info):
|
||||
continue
|
||||
|
||||
entry_hash_info = HashInfo(self.tree.PARAM_CHECKSUM, entry_hash)
|
||||
if relink or self.changed(entry_info, entry_hash_info):
|
||||
modified = True
|
||||
self.safe_remove(entry_info, force=force)
|
||||
self.link(entry_cache_info, entry_info)
|
||||
self.tree.state.save(entry_info, entry_hash)
|
||||
self.tree.state.save(entry_info, entry_hash_info)
|
||||
if progress_callback:
|
||||
progress_callback(str(entry_info))
|
||||
|
||||
@@ -500,7 +480,7 @@ class CloudCache:
|
||||
)
|
||||
|
||||
self.tree.state.save_link(path_info)
|
||||
self.tree.state.save(path_info, hash_info.value)
|
||||
self.tree.state.save(path_info, hash_info)
|
||||
|
||||
# relink is not modified, assume it as nochange
|
||||
return added, not added and modified and not relink
|
||||
@@ -508,15 +488,14 @@ class CloudCache:
|
||||
def _remove_redundant_files(self, path_info, dir_info, force):
|
||||
existing_files = set(self.tree.walk_files(path_info))
|
||||
|
||||
needed_files = {
|
||||
path_info / entry[self.tree.PARAM_RELPATH] for entry in dir_info
|
||||
}
|
||||
needed_files = {info for info, _ in dir_info.items(path_info)}
|
||||
redundant_files = existing_files - needed_files
|
||||
for path in redundant_files:
|
||||
self.safe_remove(path, force)
|
||||
|
||||
return bool(redundant_files)
|
||||
|
||||
@use_state
|
||||
def checkout(
|
||||
self,
|
||||
path_info,
|
||||
@@ -525,6 +504,7 @@ class CloudCache:
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
quiet=False,
|
||||
):
|
||||
if path_info.scheme not in ["local", self.tree.scheme]:
|
||||
raise NotImplementedError
|
||||
@@ -532,10 +512,11 @@ class CloudCache:
|
||||
failed = None
|
||||
skip = False
|
||||
if not hash_info:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. " "It won't be created.",
|
||||
path_info,
|
||||
)
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. It won't be created.",
|
||||
path_info,
|
||||
)
|
||||
self.safe_remove(path_info, force=force)
|
||||
failed = path_info
|
||||
|
||||
@@ -546,11 +527,12 @@ class CloudCache:
|
||||
elif self.changed_cache(
|
||||
hash_info, path_info=path_info, filter_info=filter_info
|
||||
):
|
||||
logger.warning(
|
||||
"Cache '%s' not found. File '%s' won't be created.",
|
||||
hash_info,
|
||||
path_info,
|
||||
)
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"Cache '%s' not found. File '%s' won't be created.",
|
||||
hash_info,
|
||||
path_info,
|
||||
)
|
||||
self.safe_remove(path_info, force=force)
|
||||
failed = path_info
|
||||
|
||||
@@ -607,76 +589,21 @@ class CloudCache:
|
||||
return 1
|
||||
|
||||
if not filter_info:
|
||||
return len(self.get_dir_cache(hash_info))
|
||||
return self.get_dir_cache(hash_info).nfiles
|
||||
|
||||
return ilen(
|
||||
filter_info.isin_or_eq(path_info / entry[self.tree.PARAM_CHECKSUM])
|
||||
for entry in self.get_dir_cache(hash_info)
|
||||
filter_info.isin_or_eq(path_info / relpath)
|
||||
for relpath, _ in self.get_dir_cache(hash_info).items()
|
||||
)
|
||||
|
||||
def _to_dict(self, dir_info):
|
||||
info = {}
|
||||
for entry in dir_info:
|
||||
relpath = None
|
||||
hash_info = None
|
||||
for key, value in entry.items():
|
||||
if key == self.tree.PARAM_RELPATH:
|
||||
relpath = value
|
||||
else:
|
||||
hash_info = HashInfo(key, value)
|
||||
info[relpath] = hash_info
|
||||
return info
|
||||
|
||||
def _from_dict(self, dir_dict):
|
||||
return [
|
||||
{
|
||||
self.tree.PARAM_RELPATH: relpath,
|
||||
hash_info.name: hash_info.value,
|
||||
}
|
||||
for relpath, hash_info in dir_dict.items()
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _diff(ancestor, other, allow_removed=False):
|
||||
from dictdiffer import diff
|
||||
|
||||
allowed = ["add"]
|
||||
if allow_removed:
|
||||
allowed.append("remove")
|
||||
|
||||
result = list(diff(ancestor, other))
|
||||
for typ, _, _ in result:
|
||||
if typ not in allowed:
|
||||
raise MergeError(
|
||||
"unable to auto-merge directories with diff that contains "
|
||||
f"'{typ}'ed files"
|
||||
)
|
||||
return result
|
||||
|
||||
def _merge_dirs(self, ancestor_info, our_info, their_info):
|
||||
from dictdiffer import patch
|
||||
|
||||
ancestor = self._to_dict(ancestor_info)
|
||||
our = self._to_dict(our_info)
|
||||
their = self._to_dict(their_info)
|
||||
|
||||
our_diff = self._diff(ancestor, our)
|
||||
if not our_diff:
|
||||
return self._from_dict(their)
|
||||
|
||||
their_diff = self._diff(ancestor, their)
|
||||
if not their_diff:
|
||||
return self._from_dict(our)
|
||||
|
||||
# make sure there are no conflicting files
|
||||
self._diff(our, their, allow_removed=True)
|
||||
|
||||
merged = patch(our_diff + their_diff, ancestor, in_place=True)
|
||||
|
||||
# Sorting the list by path to ensure reproducibility
|
||||
return sorted(
|
||||
self._from_dict(merged), key=itemgetter(self.tree.PARAM_RELPATH)
|
||||
)
|
||||
def _get_dir_size(self, dir_info):
|
||||
try:
|
||||
return sum(
|
||||
self.tree.getsize(self.tree.hash_to_path_info(hi.value))
|
||||
for _, hi in dir_info.items()
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
def merge(self, ancestor_info, our_info, their_info):
|
||||
assert our_info
|
||||
@@ -685,23 +612,326 @@ class CloudCache:
|
||||
if ancestor_info:
|
||||
ancestor = self.get_dir_cache(ancestor_info)
|
||||
else:
|
||||
ancestor = []
|
||||
ancestor = DirInfo()
|
||||
|
||||
our = self.get_dir_cache(our_info)
|
||||
their = self.get_dir_cache(their_info)
|
||||
|
||||
merged = self._merge_dirs(ancestor, our, their)
|
||||
return self.save_dir_info(merged)
|
||||
merged = our.merge(ancestor, their)
|
||||
hash_info = self.save_dir_info(merged)
|
||||
hash_info.size = self._get_dir_size(merged)
|
||||
return hash_info
|
||||
|
||||
@use_state
|
||||
def get_hash(self, tree, path_info):
|
||||
hash_info = tree.get_hash(path_info)
|
||||
if not hash_info.isdir:
|
||||
assert hash_info.name == self.tree.PARAM_CHECKSUM
|
||||
return hash_info
|
||||
|
||||
return self.save_dir_info(hash_info.dir_info, hash_info)
|
||||
hi = self.save_dir_info(hash_info.dir_info, hash_info)
|
||||
hi.size = hash_info.size
|
||||
return hi
|
||||
|
||||
def set_dir_info(self, hash_info):
|
||||
assert hash_info.isdir
|
||||
|
||||
hash_info.dir_info = self._to_dict(self.get_dir_cache(hash_info))
|
||||
hash_info.dir_info = self.get_dir_cache(hash_info)
|
||||
hash_info.nfiles = hash_info.dir_info.nfiles
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
if len(prefix) > 2:
|
||||
path_info = self.tree.path_info / prefix[:2] / prefix[2:]
|
||||
else:
|
||||
path_info = self.tree.path_info / prefix[:2]
|
||||
prefix = True
|
||||
else:
|
||||
path_info = self.tree.path_info
|
||||
prefix = False
|
||||
if progress_callback:
|
||||
for file_info in self.tree.walk_files(path_info, prefix=prefix):
|
||||
progress_callback()
|
||||
yield file_info.path
|
||||
else:
|
||||
yield from self.tree.walk_files(path_info, prefix=prefix)
|
||||
|
||||
def _path_to_hash(self, path):
|
||||
parts = self.tree.PATH_CLS(path).parts[-2:]
|
||||
|
||||
if not (len(parts) == 2 and parts[0] and len(parts[0]) == 2):
|
||||
raise ValueError(f"Bad cache file path '{path}'")
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
def list_hashes(self, prefix=None, progress_callback=None):
|
||||
"""Iterate over hashes in this tree.
|
||||
|
||||
If `prefix` is specified, only hashes which begin with `prefix`
|
||||
will be returned.
|
||||
"""
|
||||
for path in self._list_paths(prefix, progress_callback):
|
||||
try:
|
||||
yield self._path_to_hash(path)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
"'%s' doesn't look like a cache file, skipping", path
|
||||
)
|
||||
|
||||
def _hashes_with_limit(self, limit, prefix=None, progress_callback=None):
|
||||
count = 0
|
||||
for hash_ in self.list_hashes(prefix, progress_callback):
|
||||
yield hash_
|
||||
count += 1
|
||||
if count > limit:
|
||||
logger.debug(
|
||||
"`list_hashes()` returned max '{}' hashes, "
|
||||
"skipping remaining results".format(limit)
|
||||
)
|
||||
return
|
||||
|
||||
def _max_estimation_size(self, hashes):
|
||||
# Max remote size allowed for us to use traverse method
|
||||
return max(
|
||||
self.tree.TRAVERSE_THRESHOLD_SIZE,
|
||||
len(hashes)
|
||||
/ self.tree.TRAVERSE_WEIGHT_MULTIPLIER
|
||||
* self.tree.LIST_OBJECT_PAGE_SIZE,
|
||||
)
|
||||
|
||||
def _estimate_remote_size(self, hashes=None, name=None):
|
||||
"""Estimate tree size based on number of entries beginning with
|
||||
"00..." prefix.
|
||||
"""
|
||||
prefix = "0" * self.tree.TRAVERSE_PREFIX_LEN
|
||||
total_prefixes = pow(16, self.tree.TRAVERSE_PREFIX_LEN)
|
||||
if hashes:
|
||||
max_hashes = self._max_estimation_size(hashes)
|
||||
else:
|
||||
max_hashes = None
|
||||
|
||||
with Tqdm(
|
||||
desc="Estimating size of "
|
||||
+ (f"cache in '{name}'" if name else "remote cache"),
|
||||
unit="file",
|
||||
) as pbar:
|
||||
|
||||
def update(n=1):
|
||||
pbar.update(n * total_prefixes)
|
||||
|
||||
if max_hashes:
|
||||
hashes = self._hashes_with_limit(
|
||||
max_hashes / total_prefixes, prefix, update
|
||||
)
|
||||
else:
|
||||
hashes = self.list_hashes(prefix, update)
|
||||
|
||||
remote_hashes = set(hashes)
|
||||
if remote_hashes:
|
||||
remote_size = total_prefixes * len(remote_hashes)
|
||||
else:
|
||||
remote_size = total_prefixes
|
||||
logger.debug(f"Estimated remote size: {remote_size} files")
|
||||
return remote_size, remote_hashes
|
||||
|
||||
def list_hashes_traverse(
|
||||
self, remote_size, remote_hashes, jobs=None, name=None
|
||||
):
|
||||
"""Iterate over all hashes found in this tree.
|
||||
Hashes are fetched in parallel according to prefix, except in
|
||||
cases where the remote size is very small.
|
||||
|
||||
All hashes from the remote (including any from the size
|
||||
estimation step passed via the `remote_hashes` argument) will be
|
||||
returned.
|
||||
|
||||
NOTE: For large remotes the list of hashes will be very
|
||||
big(e.g. 100M entries, md5 for each is 32 bytes, so ~3200Mb list)
|
||||
and we don't really need all of it at the same time, so it makes
|
||||
sense to use a generator to gradually iterate over it, without
|
||||
keeping all of it in memory.
|
||||
"""
|
||||
num_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
|
||||
if num_pages < 256 / self.tree.JOBS:
|
||||
# Fetching prefixes in parallel requires at least 255 more
|
||||
# requests, for small enough remotes it will be faster to fetch
|
||||
# entire cache without splitting it into prefixes.
|
||||
#
|
||||
# NOTE: this ends up re-fetching hashes that were already
|
||||
# fetched during remote size estimation
|
||||
traverse_prefixes = [None]
|
||||
initial = 0
|
||||
else:
|
||||
yield from remote_hashes
|
||||
initial = len(remote_hashes)
|
||||
traverse_prefixes = [f"{i:02x}" for i in range(1, 256)]
|
||||
if self.tree.TRAVERSE_PREFIX_LEN > 2:
|
||||
traverse_prefixes += [
|
||||
"{0:0{1}x}".format(i, self.tree.TRAVERSE_PREFIX_LEN)
|
||||
for i in range(
|
||||
1, pow(16, self.tree.TRAVERSE_PREFIX_LEN - 2)
|
||||
)
|
||||
]
|
||||
with Tqdm(
|
||||
desc="Querying "
|
||||
+ (f"cache in '{name}'" if name else "remote cache"),
|
||||
total=remote_size,
|
||||
initial=initial,
|
||||
unit="file",
|
||||
) as pbar:
|
||||
|
||||
def list_with_update(prefix):
|
||||
return list(
|
||||
self.list_hashes(
|
||||
prefix=prefix, progress_callback=pbar.update
|
||||
)
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=jobs or self.tree.JOBS
|
||||
) as executor:
|
||||
in_remote = executor.map(list_with_update, traverse_prefixes,)
|
||||
yield from itertools.chain.from_iterable(in_remote)
|
||||
|
||||
def all(self, jobs=None, name=None):
|
||||
"""Iterate over all hashes in this tree.
|
||||
|
||||
Hashes will be fetched in parallel threads according to prefix
|
||||
(except for small remotes) and a progress bar will be displayed.
|
||||
"""
|
||||
logger.debug(
|
||||
"Fetching all hashes from '{}'".format(
|
||||
name if name else "remote cache"
|
||||
)
|
||||
)
|
||||
|
||||
if not self.tree.CAN_TRAVERSE:
|
||||
return self.list_hashes()
|
||||
|
||||
remote_size, remote_hashes = self._estimate_remote_size(name=name)
|
||||
return self.list_hashes_traverse(
|
||||
remote_size, remote_hashes, jobs, name
|
||||
)
|
||||
|
||||
def _remove_unpacked_dir(self, hash_):
|
||||
pass
|
||||
|
||||
def gc(self, used, jobs=None):
|
||||
removed = False
|
||||
# hashes must be sorted to ensure we always remove .dir files first
|
||||
for hash_ in sorted(
|
||||
self.all(jobs, str(self.tree.path_info)),
|
||||
key=self.tree.is_dir_hash,
|
||||
reverse=True,
|
||||
):
|
||||
if hash_ in used:
|
||||
continue
|
||||
path_info = self.tree.hash_to_path_info(hash_)
|
||||
if self.tree.is_dir_hash(hash_):
|
||||
# backward compatibility
|
||||
# pylint: disable=protected-access
|
||||
self._remove_unpacked_dir(hash_)
|
||||
self.tree.remove(path_info)
|
||||
removed = True
|
||||
|
||||
return removed
|
||||
|
||||
def list_hashes_exists(self, hashes, jobs=None, name=None):
|
||||
"""Return list of the specified hashes which exist in this tree.
|
||||
Hashes will be queried individually.
|
||||
"""
|
||||
logger.debug(
|
||||
"Querying {} hashes via object_exists".format(len(hashes))
|
||||
)
|
||||
with Tqdm(
|
||||
desc="Querying "
|
||||
+ ("cache in " + name if name else "remote cache"),
|
||||
total=len(hashes),
|
||||
unit="file",
|
||||
) as pbar:
|
||||
|
||||
def exists_with_progress(path_info):
|
||||
ret = self.tree.exists(path_info)
|
||||
pbar.update_msg(str(path_info))
|
||||
return ret
|
||||
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=jobs or self.tree.JOBS
|
||||
) as executor:
|
||||
path_infos = map(self.tree.hash_to_path_info, hashes)
|
||||
in_remote = executor.map(exists_with_progress, path_infos)
|
||||
ret = list(itertools.compress(hashes, in_remote))
|
||||
return ret
|
||||
|
||||
def hashes_exist(self, hashes, jobs=None, name=None):
|
||||
"""Check if the given hashes are stored in the remote.
|
||||
|
||||
There are two ways of performing this check:
|
||||
|
||||
- Traverse method: Get a list of all the files in the remote
|
||||
(traversing the cache directory) and compare it with
|
||||
the given hashes. Cache entries will be retrieved in parallel
|
||||
threads according to prefix (i.e. entries starting with, "00...",
|
||||
"01...", and so on) and a progress bar will be displayed.
|
||||
|
||||
- Exists method: For each given hash, run the `exists`
|
||||
method and filter the hashes that aren't on the remote.
|
||||
This is done in parallel threads.
|
||||
It also shows a progress bar when performing the check.
|
||||
|
||||
The reason for such an odd logic is that most of the remotes
|
||||
take much shorter time to just retrieve everything they have under
|
||||
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
|
||||
check if particular file exists much quicker, use their own
|
||||
implementation of hashes_exist (see ssh, local).
|
||||
|
||||
Which method to use will be automatically determined after estimating
|
||||
the size of the remote cache, and comparing the estimated size with
|
||||
len(hashes). To estimate the size of the remote cache, we fetch
|
||||
a small subset of cache entries (i.e. entries starting with "00...").
|
||||
Based on the number of entries in that subset, the size of the full
|
||||
cache can be estimated, since the cache is evenly distributed according
|
||||
to hash.
|
||||
|
||||
Returns:
|
||||
A list with hashes that were found in the remote
|
||||
"""
|
||||
# Remotes which do not use traverse prefix should override
|
||||
# hashes_exist() (see ssh, local)
|
||||
assert self.tree.TRAVERSE_PREFIX_LEN >= 2
|
||||
|
||||
hashes = set(hashes)
|
||||
if len(hashes) == 1 or not self.tree.CAN_TRAVERSE:
|
||||
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
|
||||
return remote_hashes
|
||||
|
||||
# Max remote size allowed for us to use traverse method
|
||||
remote_size, remote_hashes = self._estimate_remote_size(hashes, name)
|
||||
|
||||
traverse_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
|
||||
# For sufficiently large remotes, traverse must be weighted to account
|
||||
# for performance overhead from large lists/sets.
|
||||
# From testing with S3, for remotes with 1M+ files, object_exists is
|
||||
# faster until len(hashes) is at least 10k~100k
|
||||
if remote_size > self.tree.TRAVERSE_THRESHOLD_SIZE:
|
||||
traverse_weight = (
|
||||
traverse_pages * self.tree.TRAVERSE_WEIGHT_MULTIPLIER
|
||||
)
|
||||
else:
|
||||
traverse_weight = traverse_pages
|
||||
if len(hashes) < traverse_weight:
|
||||
logger.debug(
|
||||
"Large remote ('{}' hashes < '{}' traverse weight), "
|
||||
"using object_exists for remaining hashes".format(
|
||||
len(hashes), traverse_weight
|
||||
)
|
||||
)
|
||||
return list(hashes & remote_hashes) + self.list_hashes_exists(
|
||||
hashes - remote_hashes, jobs, name
|
||||
)
|
||||
|
||||
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
|
||||
remote_hashes = set(
|
||||
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
|
||||
)
|
||||
return list(hashes & set(remote_hashes))
|
||||
|
||||
Vendored
+22
-403
@@ -1,53 +1,23 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partial, wraps
|
||||
|
||||
from funcy import cached_property, concat
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.exceptions import DownloadError, UploadError
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
from ..remote.base import index_locked
|
||||
from ..tree.local import LocalTree
|
||||
from .base import (
|
||||
STATUS_DELETED,
|
||||
STATUS_MAP,
|
||||
STATUS_MISSING,
|
||||
STATUS_NEW,
|
||||
CloudCache,
|
||||
)
|
||||
from ..utils.fs import walk_files
|
||||
from .base import CloudCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_exceptions(func, operation):
|
||||
@wraps(func)
|
||||
def wrapper(from_info, to_info, *args, **kwargs):
|
||||
try:
|
||||
func(from_info, to_info, *args, **kwargs)
|
||||
return 0
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# NOTE: this means we ran out of file descriptors and there is no
|
||||
# reason to try to proceed, as we will hit this error anyways.
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
raise
|
||||
|
||||
logger.exception(
|
||||
"failed to %s '%s' to '%s'", operation, from_info, to_info
|
||||
)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class LocalCache(CloudCache):
|
||||
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
|
||||
CACHE_MODE = LocalTree.CACHE_MODE
|
||||
UNPACKED_DIR_SUFFIX = ".unpacked"
|
||||
|
||||
def __init__(self, tree):
|
||||
super().__init__(tree)
|
||||
@@ -61,10 +31,6 @@ class LocalCache(CloudCache):
|
||||
def cache_dir(self, value):
|
||||
self.tree.path_info = PathInfo(value) if value else None
|
||||
|
||||
@classmethod
|
||||
def supported(cls, config): # pylint: disable=unused-argument
|
||||
return True
|
||||
|
||||
@cached_property
|
||||
def cache_path(self):
|
||||
return os.path.abspath(self.cache_dir)
|
||||
@@ -102,371 +68,24 @@ class LocalCache(CloudCache):
|
||||
|
||||
super()._verify_link(path_info, link_type)
|
||||
|
||||
@index_locked
|
||||
def status(
|
||||
self,
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
# Return flattened dict containing all status info
|
||||
dir_status, file_status, _ = self._status(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
return dict(dir_status, **file_status)
|
||||
|
||||
def _status(
|
||||
self,
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
|
||||
|
||||
dir_status_info contains status for .dir files, file_status_info
|
||||
contains status for all other files, and dir_contents is a dict of
|
||||
{dir_hash: set(file_hash, ...)} which can be used to map
|
||||
a .dir file to its file contents.
|
||||
"""
|
||||
logger.debug(
|
||||
f"Preparing to collect status from {remote.tree.path_info}"
|
||||
)
|
||||
md5s = set(named_cache.scheme_keys(self.tree.scheme))
|
||||
|
||||
logger.debug("Collecting information from local cache...")
|
||||
local_exists = frozenset(
|
||||
self.hashes_exist(md5s, jobs=jobs, name=self.cache_dir)
|
||||
)
|
||||
|
||||
# This is a performance optimization. We can safely assume that,
|
||||
# if the resources that we want to fetch are already cached,
|
||||
# there's no need to check the remote storage for the existence of
|
||||
# those files.
|
||||
if download and local_exists == md5s:
|
||||
remote_exists = local_exists
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
assert self.tree.path_info is not None
|
||||
if prefix:
|
||||
path_info = self.tree.path_info / prefix[:2]
|
||||
if not self.tree.exists(path_info):
|
||||
return
|
||||
else:
|
||||
logger.debug("Collecting information from remote cache...")
|
||||
remote_exists = set()
|
||||
dir_md5s = set(named_cache.dir_keys(self.tree.scheme))
|
||||
if dir_md5s:
|
||||
remote_exists.update(
|
||||
self._indexed_dir_hashes(named_cache, remote, dir_md5s)
|
||||
)
|
||||
md5s.difference_update(remote_exists)
|
||||
if md5s:
|
||||
remote_exists.update(
|
||||
remote.hashes_exist(
|
||||
md5s, jobs=jobs, name=str(remote.tree.path_info)
|
||||
)
|
||||
)
|
||||
return self._make_status(
|
||||
named_cache,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
)
|
||||
|
||||
def _make_status(
|
||||
self,
|
||||
named_cache,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
):
|
||||
def make_names(hash_, names):
|
||||
return {"name": hash_ if show_checksums else " ".join(names)}
|
||||
|
||||
dir_status = {}
|
||||
file_status = {}
|
||||
dir_contents = {}
|
||||
for hash_, item in named_cache[self.tree.scheme].items():
|
||||
if item.children:
|
||||
dir_status[hash_] = make_names(hash_, item.names)
|
||||
dir_contents[hash_] = set()
|
||||
for child_hash, child in item.children.items():
|
||||
file_status[child_hash] = make_names(
|
||||
child_hash, child.names
|
||||
)
|
||||
dir_contents[hash_].add(child_hash)
|
||||
else:
|
||||
file_status[hash_] = make_names(hash_, item.names)
|
||||
|
||||
self._fill_statuses(dir_status, local_exists, remote_exists)
|
||||
self._fill_statuses(file_status, local_exists, remote_exists)
|
||||
|
||||
if log_missing:
|
||||
self._log_missing_caches(dict(dir_status, **file_status))
|
||||
|
||||
return dir_status, file_status, dir_contents
|
||||
|
||||
def _indexed_dir_hashes(self, named_cache, remote, dir_md5s):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
indexed_dirs = set(remote.index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
indexed_dir_exists.update(
|
||||
remote.tree.list_hashes_exists(indexed_dirs)
|
||||
)
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '{}', "
|
||||
"clearing remote index".format(", ".join(missing_dirs))
|
||||
)
|
||||
remote.index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_md5s.intersection(indexed_dir_exists)
|
||||
dir_exists.update(
|
||||
remote.tree.list_hashes_exists(dir_md5s - dir_exists)
|
||||
)
|
||||
|
||||
# If .dir hash exists on the remote, assume directory contents
|
||||
# still exists on the remote
|
||||
for dir_hash in dir_exists:
|
||||
file_hashes = list(
|
||||
named_cache.child_keys(self.tree.scheme, dir_hash)
|
||||
)
|
||||
if dir_hash not in remote.index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '{}' with '{}' nested files".format(
|
||||
dir_hash, len(file_hashes)
|
||||
)
|
||||
)
|
||||
remote.index.update([dir_hash], file_hashes)
|
||||
yield dir_hash
|
||||
yield from file_hashes
|
||||
|
||||
@staticmethod
|
||||
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
|
||||
# Using sets because they are way faster for lookups
|
||||
local = set(local_exists)
|
||||
remote = set(remote_exists)
|
||||
|
||||
for md5, info in hash_info_dir.items():
|
||||
status = STATUS_MAP[(md5 in local, md5 in remote)]
|
||||
info["status"] = status
|
||||
|
||||
def _get_plans(self, download, remote, status_info, status):
|
||||
cache = []
|
||||
path_infos = []
|
||||
names = []
|
||||
hashes = []
|
||||
missing = []
|
||||
for md5, info in Tqdm(
|
||||
status_info.items(), desc="Analysing status", unit="file"
|
||||
):
|
||||
if info["status"] == status:
|
||||
cache.append(self.tree.hash_to_path_info(md5))
|
||||
path_infos.append(remote.tree.hash_to_path_info(md5))
|
||||
names.append(info["name"])
|
||||
hashes.append(md5)
|
||||
elif info["status"] == STATUS_MISSING:
|
||||
missing.append(md5)
|
||||
|
||||
if download:
|
||||
to_infos = cache
|
||||
from_infos = path_infos
|
||||
path_info = self.tree.path_info
|
||||
# NOTE: use utils.fs walk_files since tree.walk_files will not follow
|
||||
# symlinks
|
||||
if progress_callback:
|
||||
for path in walk_files(path_info):
|
||||
progress_callback()
|
||||
yield path
|
||||
else:
|
||||
to_infos = path_infos
|
||||
from_infos = cache
|
||||
yield from walk_files(path_info)
|
||||
|
||||
return (from_infos, to_infos, names, hashes), missing
|
||||
|
||||
def _process(
|
||||
self,
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
):
|
||||
logger.debug(
|
||||
"Preparing to {} '{}'".format(
|
||||
"download data from" if download else "upload data to",
|
||||
remote.tree.path_info,
|
||||
)
|
||||
)
|
||||
|
||||
if download:
|
||||
func = partial(
|
||||
_log_exceptions(remote.tree.download, "download"),
|
||||
dir_mode=self.tree.dir_mode,
|
||||
file_mode=self.tree.file_mode,
|
||||
)
|
||||
status = STATUS_DELETED
|
||||
desc = "Downloading"
|
||||
else:
|
||||
func = _log_exceptions(remote.tree.upload, "upload")
|
||||
status = STATUS_NEW
|
||||
desc = "Uploading"
|
||||
|
||||
if jobs is None:
|
||||
jobs = remote.tree.JOBS
|
||||
|
||||
dir_status, file_status, dir_contents = self._status(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
)
|
||||
|
||||
dir_plans, _ = self._get_plans(download, remote, dir_status, status)
|
||||
file_plans, missing_files = self._get_plans(
|
||||
download, remote, file_status, status
|
||||
)
|
||||
|
||||
total = len(dir_plans[0]) + len(file_plans[0])
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
with Tqdm(total=total, unit="file", desc=desc) as pbar:
|
||||
func = pbar.wrap_fn(func)
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
if download:
|
||||
from_infos, to_infos, names, _ = (
|
||||
d + f for d, f in zip(dir_plans, file_plans)
|
||||
)
|
||||
fails = sum(
|
||||
executor.map(func, from_infos, to_infos, names)
|
||||
)
|
||||
else:
|
||||
# for uploads, push files first, and any .dir files last
|
||||
|
||||
file_futures = {}
|
||||
for from_info, to_info, name, hash_ in zip(*file_plans):
|
||||
file_futures[hash_] = executor.submit(
|
||||
func, from_info, to_info, name
|
||||
)
|
||||
dir_futures = {}
|
||||
for from_info, to_info, name, dir_hash in zip(*dir_plans):
|
||||
# if for some reason a file contained in this dir is
|
||||
# missing both locally and in the remote, we want to
|
||||
# push whatever file content we have, but should not
|
||||
# push .dir file
|
||||
for file_hash in missing_files:
|
||||
if file_hash in dir_contents[dir_hash]:
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
name,
|
||||
)
|
||||
break
|
||||
else:
|
||||
wait_futures = {
|
||||
future
|
||||
for file_hash, future in file_futures.items()
|
||||
if file_hash in dir_contents[dir_hash]
|
||||
}
|
||||
dir_futures[dir_hash] = executor.submit(
|
||||
self._dir_upload,
|
||||
func,
|
||||
wait_futures,
|
||||
from_info,
|
||||
to_info,
|
||||
name,
|
||||
)
|
||||
fails = sum(
|
||||
future.result()
|
||||
for future in concat(
|
||||
file_futures.values(), dir_futures.values()
|
||||
)
|
||||
)
|
||||
|
||||
if fails:
|
||||
if download:
|
||||
remote.index.clear()
|
||||
raise DownloadError(fails)
|
||||
raise UploadError(fails)
|
||||
|
||||
if not download:
|
||||
# index successfully pushed dirs
|
||||
for dir_hash, future in dir_futures.items():
|
||||
if future.result() == 0:
|
||||
file_hashes = dir_contents[dir_hash]
|
||||
logger.debug(
|
||||
"Indexing pushed dir '{}' with "
|
||||
"'{}' nested files".format(dir_hash, len(file_hashes))
|
||||
)
|
||||
remote.index.update([dir_hash], file_hashes)
|
||||
|
||||
return len(dir_plans[0]) + len(file_plans[0])
|
||||
|
||||
@staticmethod
|
||||
def _dir_upload(func, futures, from_info, to_info, name):
|
||||
for future in as_completed(futures):
|
||||
if future.result():
|
||||
# do not upload this .dir file if any file in this
|
||||
# directory failed to upload
|
||||
logger.debug(
|
||||
"failed to upload full contents of '{}', "
|
||||
"aborting .dir file upload".format(name)
|
||||
)
|
||||
logger.error(f"failed to upload '{from_info}' to '{to_info}'")
|
||||
return 1
|
||||
return func(from_info, to_info, name)
|
||||
|
||||
@index_locked
|
||||
def push(self, named_cache, remote, jobs=None, show_checksums=False):
|
||||
return self._process(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=False,
|
||||
)
|
||||
|
||||
@index_locked
|
||||
def pull(self, named_cache, remote, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=True,
|
||||
)
|
||||
|
||||
if not remote.tree.verify:
|
||||
for checksum in named_cache.scheme_keys("local"):
|
||||
cache_file = self.tree.hash_to_path_info(checksum)
|
||||
if self.tree.exists(cache_file):
|
||||
# We can safely save here, as existing corrupted files will
|
||||
# be removed upon status, while files corrupted during
|
||||
# download will not be moved from tmp_file
|
||||
# (see `BaseTree.download()`)
|
||||
self.tree.state.save(cache_file, checksum)
|
||||
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def _log_missing_caches(hash_info_dict):
|
||||
missing_caches = [
|
||||
(md5, info)
|
||||
for md5, info in hash_info_dict.items()
|
||||
if info["status"] == STATUS_MISSING
|
||||
]
|
||||
if missing_caches:
|
||||
missing_desc = "\n".join(
|
||||
"name: {}, md5: {}".format(info["name"], md5)
|
||||
for md5, info in missing_caches
|
||||
)
|
||||
msg = (
|
||||
"Some of the cache files do not exist neither locally "
|
||||
"nor on remote. Missing cache files:\n{}".format(missing_desc)
|
||||
)
|
||||
logger.warning(msg)
|
||||
def _remove_unpacked_dir(self, hash_):
|
||||
info = self.tree.hash_to_path_info(hash_)
|
||||
path_info = info.with_name(info.name + self.UNPACKED_DIR_SUFFIX)
|
||||
self.tree.remove(path_info)
|
||||
|
||||
Vendored
+20
-3
@@ -1,17 +1,18 @@
|
||||
import errno
|
||||
import itertools
|
||||
import logging
|
||||
import posixpath
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import to_chunks
|
||||
|
||||
from .base import Remote
|
||||
from .base import CloudCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SSHRemote(Remote):
|
||||
class SSHCache(CloudCache):
|
||||
def batch_exists(self, path_infos, callback):
|
||||
def _exists(chunk_and_channel):
|
||||
chunk, channel = chunk_and_channel
|
||||
@@ -47,7 +48,7 @@ class SSHRemote(Remote):
|
||||
remote/base.
|
||||
"""
|
||||
if not self.tree.CAN_TRAVERSE:
|
||||
return list(set(hashes) & set(self.tree.all()))
|
||||
return list(set(hashes) & set(self.all()))
|
||||
|
||||
# possibly prompt for credentials before "Querying" progress output
|
||||
self.tree.ensure_credentials()
|
||||
@@ -71,3 +72,19 @@ class SSHRemote(Remote):
|
||||
in_remote = itertools.chain.from_iterable(results)
|
||||
ret = list(itertools.compress(hashes, in_remote))
|
||||
return ret
|
||||
|
||||
def _list_paths(self, prefix=None, progress_callback=None):
|
||||
if prefix:
|
||||
root = posixpath.join(self.tree.path_info.path, prefix[:2])
|
||||
else:
|
||||
root = self.tree.path_info.path
|
||||
with self.tree.ssh(self.tree.path_info) as ssh:
|
||||
if prefix and not ssh.exists(root):
|
||||
return
|
||||
# If we simply return an iterator then with above closes instantly
|
||||
if progress_callback:
|
||||
for path in ssh.walk_files(root):
|
||||
progress_callback()
|
||||
yield path
|
||||
else:
|
||||
yield from ssh.walk_files(root)
|
||||
@@ -146,6 +146,10 @@ def get_parent_parser():
|
||||
)
|
||||
parent_parser.add_argument("--cprofile-dump", help=argparse.SUPPRESS)
|
||||
|
||||
parent_parser.add_argument(
|
||||
"--pdb", action="store_true", default=False, help=argparse.SUPPRESS,
|
||||
)
|
||||
|
||||
log_level_group = parent_parser.add_mutually_exclusive_group()
|
||||
log_level_group.add_argument(
|
||||
"-q", "--quiet", action="count", default=0, help="Be quiet."
|
||||
|
||||
@@ -20,6 +20,8 @@ class CmdAdd(CmdBase):
|
||||
no_commit=self.args.no_commit,
|
||||
fname=self.args.file,
|
||||
external=self.args.external,
|
||||
glob=self.args.glob,
|
||||
desc=self.args.desc,
|
||||
)
|
||||
|
||||
except DvcException:
|
||||
@@ -57,11 +59,26 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Allow targets that are outside of the DVC repository.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--glob",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
help="Specify name of the DVC-file this command will generate.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
metavar="<text>",
|
||||
help=(
|
||||
"User description of the data (optional). "
|
||||
"This doesn't affect any DVC operations."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"targets", nargs="+", help="Input files/directories to add.",
|
||||
).complete = completion.FILE
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import append_doc_link, fix_subparsers
|
||||
from dvc.command.config import CmdConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdCacheDir(CmdConfig):
|
||||
def run(self):
|
||||
if self.args.value is None and not self.args.unset:
|
||||
logger.info(self.config["cache"]["dir"])
|
||||
return 0
|
||||
with self.config.edit(level=self.args.level) as edit:
|
||||
edit["cache"]["dir"] = self.args.value
|
||||
return 0
|
||||
@@ -55,6 +61,8 @@ def add_parser(subparsers, parent_parser):
|
||||
"value",
|
||||
help="Path to cache directory. Relative paths are resolved relative "
|
||||
"to the current directory and saved to config relative to the "
|
||||
"config file location.",
|
||||
"config file location. If no path is provided, it returns the "
|
||||
"current cache directory.",
|
||||
nargs="?",
|
||||
).complete = completion.DIR
|
||||
cache_dir_parser.set_defaults(func=CmdCacheDir)
|
||||
|
||||
+72
-40
@@ -2,7 +2,7 @@ import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.stage import Stage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,58 +30,83 @@ def _show_dot(G):
|
||||
return dot_file.getvalue()
|
||||
|
||||
|
||||
def _build(G, target=None, full=False):
|
||||
def _collect_targets(repo, target, outs):
|
||||
if not target:
|
||||
return []
|
||||
|
||||
pairs = repo.stage.collect_granular(target)
|
||||
if not outs:
|
||||
return [stage.addressing for stage, _ in pairs]
|
||||
|
||||
targets = []
|
||||
for stage, info in pairs:
|
||||
if not info:
|
||||
targets.extend([str(out) for out in stage.outs])
|
||||
continue
|
||||
|
||||
for out in repo.outs_trie.itervalues(prefix=info.parts): # noqa: B301
|
||||
targets.extend(str(out))
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _transform(repo, outs):
|
||||
import networkx as nx
|
||||
|
||||
from dvc.repo.graph import get_pipeline, get_pipelines
|
||||
def _relabel(node) -> str:
|
||||
return node.addressing if isinstance(node, Stage) else str(node)
|
||||
|
||||
if target:
|
||||
H = get_pipeline(get_pipelines(G), target)
|
||||
if not full:
|
||||
descendants = nx.descendants(G, target)
|
||||
G = repo.outs_graph if outs else repo.graph
|
||||
return nx.relabel_nodes(G, _relabel, copy=True)
|
||||
|
||||
|
||||
def _filter(G, targets, full):
|
||||
import networkx as nx
|
||||
|
||||
if not targets:
|
||||
return G
|
||||
|
||||
H = G.copy()
|
||||
if not full:
|
||||
descendants = set()
|
||||
for target in targets:
|
||||
descendants.update(nx.descendants(G, target))
|
||||
descendants.add(target)
|
||||
H.remove_nodes_from(set(G.nodes()) - descendants)
|
||||
else:
|
||||
H = G
|
||||
H.remove_nodes_from(set(G.nodes()) - descendants)
|
||||
|
||||
def _relabel(stage):
|
||||
return stage.addressing
|
||||
undirected = H.to_undirected()
|
||||
connected = set()
|
||||
for target in targets:
|
||||
connected.update(nx.node_connected_component(undirected, target))
|
||||
|
||||
return nx.relabel_nodes(H, _relabel, copy=False)
|
||||
H.remove_nodes_from(set(H.nodes()) - connected)
|
||||
|
||||
return H
|
||||
|
||||
|
||||
def _build(repo, target=None, full=False, outs=False):
|
||||
targets = _collect_targets(repo, target, outs)
|
||||
G = _transform(repo, outs)
|
||||
return _filter(G, targets, full)
|
||||
|
||||
|
||||
class CmdDAG(CmdBase):
|
||||
def run(self):
|
||||
try:
|
||||
target = None
|
||||
if self.args.target:
|
||||
stages = self.repo.collect(self.args.target)
|
||||
if len(stages) > 1:
|
||||
logger.error(
|
||||
f"'{self.args.target}' contains more than one stage "
|
||||
"{stages}, please specify one stage"
|
||||
)
|
||||
return 1
|
||||
target = stages[0]
|
||||
G = _build(
|
||||
self.repo,
|
||||
target=self.args.target,
|
||||
full=self.args.full,
|
||||
outs=self.args.outs,
|
||||
)
|
||||
|
||||
G = _build(self.repo.graph, target=target, full=self.args.full,)
|
||||
if self.args.dot:
|
||||
logger.info(_show_dot(G))
|
||||
else:
|
||||
from dvc.utils.pager import pager
|
||||
|
||||
if self.args.dot:
|
||||
logger.info(_show_dot(G))
|
||||
else:
|
||||
from dvc.utils.pager import pager
|
||||
pager(_show_ascii(G))
|
||||
|
||||
pager(_show_ascii(G))
|
||||
|
||||
return 0
|
||||
except DvcException:
|
||||
msg = "failed to show "
|
||||
if self.args.target:
|
||||
msg += f"a pipeline for '{target}'"
|
||||
else:
|
||||
msg += "pipelines"
|
||||
logger.exception(msg)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
@@ -108,6 +133,13 @@ def add_parser(subparsers, parent_parser):
|
||||
"showing DAG consisting only of ancestors."
|
||||
),
|
||||
)
|
||||
dag_parser.add_argument(
|
||||
"-o",
|
||||
"--outs",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print output files instead of stages.",
|
||||
)
|
||||
dag_parser.add_argument(
|
||||
"target",
|
||||
nargs="?",
|
||||
|
||||
@@ -237,10 +237,7 @@ def add_parser(subparsers, _parent_parser):
|
||||
push_parser.set_defaults(func=CmdDataPush)
|
||||
|
||||
# Fetch
|
||||
FETCH_HELP = (
|
||||
"Get tracked files or directories from remote storage"
|
||||
" into the cache."
|
||||
)
|
||||
FETCH_HELP = "Download cached files or directories from remote storage."
|
||||
|
||||
fetch_parser = subparsers.add_parser(
|
||||
"fetch",
|
||||
|
||||
+316
-193
@@ -3,16 +3,19 @@ import io
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
from collections.abc import Mapping
|
||||
from datetime import date, datetime
|
||||
from itertools import groupby
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from dvc.command import completion
|
||||
import dvc.prompt as prompt
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.command.metrics import DEFAULT_PRECISION, _show_metrics
|
||||
from dvc.command.status import CmdDataStatus
|
||||
from dvc.dvcfile import PIPELINE_FILE
|
||||
from dvc.command.metrics import DEFAULT_PRECISION
|
||||
from dvc.command.repro import CmdRepro
|
||||
from dvc.command.repro import add_arguments as add_repro_arguments
|
||||
from dvc.exceptions import DvcException, InvalidArgumentError
|
||||
from dvc.repo.experiments import Experiments
|
||||
from dvc.scm.git import Git
|
||||
from dvc.utils.flatten import flatten
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,13 +51,13 @@ def _filter_names(
|
||||
update_func({match: None for match in matches})
|
||||
|
||||
if include:
|
||||
ret = OrderedDict()
|
||||
ret: OrderedDict = OrderedDict()
|
||||
_filter(include, ret.update)
|
||||
else:
|
||||
ret = OrderedDict({name: None for name in names})
|
||||
|
||||
if exclude:
|
||||
_filter(exclude, ret.difference_update)
|
||||
_filter(exclude, ret.difference_update) # type: ignore[attr-defined]
|
||||
|
||||
return [".".join(name) for name in ret]
|
||||
|
||||
@@ -113,30 +116,48 @@ def _collect_rows(
|
||||
reverse = sort_order == "desc"
|
||||
experiments = _sort_exp(experiments, sort_by, sort_type, reverse)
|
||||
|
||||
last_tip = None
|
||||
new_checkpoint = True
|
||||
for i, (rev, exp) in enumerate(experiments.items()):
|
||||
row = []
|
||||
style = None
|
||||
queued = "*" if exp.get("queued", False) else ""
|
||||
|
||||
tip = exp.get("checkpoint_tip")
|
||||
parent = ""
|
||||
if rev == "baseline":
|
||||
name = exp.get("name", base_rev)
|
||||
if Git.is_sha(base_rev):
|
||||
name_rev = base_rev[:7]
|
||||
else:
|
||||
name_rev = base_rev
|
||||
name = exp.get("name", name_rev)
|
||||
row.append(f"{name}")
|
||||
style = "bold"
|
||||
else:
|
||||
if tip and tip == last_tip:
|
||||
tree = "│ ╟"
|
||||
if tip:
|
||||
parent_rev = exp.get("checkpoint_parent", "")
|
||||
parent_exp = experiments.get(parent_rev, {})
|
||||
parent_tip = parent_exp.get("checkpoint_tip")
|
||||
if tip == parent_tip:
|
||||
if new_checkpoint:
|
||||
tree = "│ ╓"
|
||||
else:
|
||||
tree = "│ ╟"
|
||||
new_checkpoint = False
|
||||
else:
|
||||
if parent_rev == base_rev:
|
||||
tree = "├─╨"
|
||||
else:
|
||||
tree = "│ ╟"
|
||||
parent = f" ({parent_rev[:7]})"
|
||||
new_checkpoint = True
|
||||
else:
|
||||
if i < len(experiments) - 1:
|
||||
if tip:
|
||||
tree = "├─╥"
|
||||
else:
|
||||
tree = "├──"
|
||||
tree = "├──"
|
||||
else:
|
||||
tree = "└──"
|
||||
row.append(f"{tree} {queued}{rev[:7]}")
|
||||
last_tip = tip
|
||||
new_checkpoint = True
|
||||
name = exp.get("name", rev[:7])
|
||||
row.append(f"{tree} {queued}{name}{parent}")
|
||||
|
||||
if not no_timestamp:
|
||||
row.append(_format_time(exp.get("timestamp")))
|
||||
@@ -150,13 +171,12 @@ def _collect_rows(
|
||||
|
||||
|
||||
def _sort_exp(experiments, sort_by, typ, reverse):
|
||||
if "baseline" in experiments:
|
||||
ret = OrderedDict({"baseline": experiments.pop("baseline")})
|
||||
else:
|
||||
ret = OrderedDict()
|
||||
|
||||
def _sort(item):
|
||||
_, exp = item
|
||||
rev, exp = item
|
||||
tip = exp.get("checkpoint_tip")
|
||||
if tip and tip != rev:
|
||||
# Sort checkpoint experiments by tip commit
|
||||
return _sort((tip, experiments[tip]))
|
||||
for fname, item in exp.get(typ, {}).items():
|
||||
if isinstance(item, dict):
|
||||
item = flatten(item)
|
||||
@@ -167,6 +187,10 @@ def _sort_exp(experiments, sort_by, typ, reverse):
|
||||
return (val is None, val)
|
||||
return (True, None)
|
||||
|
||||
ret = OrderedDict()
|
||||
if "baseline" in experiments:
|
||||
ret["baseline"] = experiments.pop("baseline")
|
||||
|
||||
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
|
||||
return ret
|
||||
|
||||
@@ -181,12 +205,19 @@ def _format_time(timestamp):
|
||||
return timestamp.strftime(fmt)
|
||||
|
||||
|
||||
def _extend_row(row, names, items, precision):
|
||||
def _round(val):
|
||||
if isinstance(val, float):
|
||||
return round(val, precision)
|
||||
def _format_field(val, precision=DEFAULT_PRECISION):
|
||||
if isinstance(val, float):
|
||||
fmt = f"{{:.{precision}g}}"
|
||||
return fmt.format(val)
|
||||
elif isinstance(val, Mapping):
|
||||
return {k: _format_field(v) for k, v in val.items()}
|
||||
elif isinstance(val, list):
|
||||
return [_format_field(x) for x in val]
|
||||
return str(val)
|
||||
|
||||
return val
|
||||
|
||||
def _extend_row(row, names, items, precision):
|
||||
from rich.text import Text
|
||||
|
||||
if not items:
|
||||
row.extend(["-"] * len(names))
|
||||
@@ -200,7 +231,13 @@ def _extend_row(row, names, items, precision):
|
||||
for name in names:
|
||||
if name in item:
|
||||
value = item[name]
|
||||
text = str(_round(value)) if value is not None else "-"
|
||||
if value is None:
|
||||
text = "-"
|
||||
else:
|
||||
# wrap field data in rich.Text, otherwise rich may
|
||||
# interpret unescaped braces from list/dict types as rich
|
||||
# markup tags
|
||||
text = Text(str(_format_field(value, precision)))
|
||||
row.append(text)
|
||||
else:
|
||||
row.append("-")
|
||||
@@ -220,8 +257,6 @@ def _parse_list(param_list):
|
||||
def _show_experiments(all_experiments, console, **kwargs):
|
||||
from rich.table import Table
|
||||
|
||||
from dvc.scm.git import Git
|
||||
|
||||
include_metrics = _parse_list(kwargs.pop("include_metrics", []))
|
||||
exclude_metrics = _parse_list(kwargs.pop("exclude_metrics", []))
|
||||
include_params = _parse_list(kwargs.pop("include_params", []))
|
||||
@@ -245,9 +280,6 @@ def _show_experiments(all_experiments, console, **kwargs):
|
||||
table.add_column(name, justify="left")
|
||||
|
||||
for base_rev, experiments in all_experiments.items():
|
||||
if Git.is_sha(base_rev):
|
||||
base_rev = base_rev[:7]
|
||||
|
||||
for row, _, in _collect_rows(
|
||||
base_rev, experiments, metric_names, param_names, **kwargs,
|
||||
):
|
||||
@@ -256,6 +288,12 @@ def _show_experiments(all_experiments, console, **kwargs):
|
||||
console.print(table)
|
||||
|
||||
|
||||
def _format_json(item):
|
||||
if isinstance(item, (date, datetime)):
|
||||
return item.isoformat()
|
||||
raise TypeError
|
||||
|
||||
|
||||
class CmdExperimentsShow(CmdBase):
|
||||
def run(self):
|
||||
from rich.console import Console
|
||||
@@ -273,6 +311,12 @@ class CmdExperimentsShow(CmdBase):
|
||||
sha_only=self.args.sha,
|
||||
)
|
||||
|
||||
if self.args.show_json:
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(all_experiments, default=_format_json))
|
||||
return 0
|
||||
|
||||
if self.args.no_pager:
|
||||
console = Console()
|
||||
else:
|
||||
@@ -282,6 +326,11 @@ class CmdExperimentsShow(CmdBase):
|
||||
file=io.StringIO(), force_terminal=True, width=9999
|
||||
)
|
||||
|
||||
if self.args.precision is None:
|
||||
precision = DEFAULT_PRECISION
|
||||
else:
|
||||
precision = self.args.precision
|
||||
|
||||
_show_experiments(
|
||||
all_experiments,
|
||||
console,
|
||||
@@ -292,6 +341,7 @@ class CmdExperimentsShow(CmdBase):
|
||||
no_timestamp=self.args.no_timestamp,
|
||||
sort_by=self.args.sort_by,
|
||||
sort_order=self.args.sort_order,
|
||||
precision=precision,
|
||||
)
|
||||
|
||||
if not self.args.no_pager:
|
||||
@@ -303,30 +353,26 @@ class CmdExperimentsShow(CmdBase):
|
||||
return 0
|
||||
|
||||
|
||||
class CmdExperimentsCheckout(CmdBase):
|
||||
class CmdExperimentsApply(CmdBase):
|
||||
def run(self):
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
|
||||
self.repo.experiments.checkout(self.args.experiment)
|
||||
self.repo.experiments.apply(self.args.experiment)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _show_diff(
|
||||
diff, title="", markdown=False, no_path=False, old=False, precision=None
|
||||
diff,
|
||||
title="",
|
||||
markdown=False,
|
||||
no_path=False,
|
||||
old=False,
|
||||
precision=DEFAULT_PRECISION,
|
||||
):
|
||||
from dvc.utils.diff import table
|
||||
|
||||
if precision is None:
|
||||
precision = DEFAULT_PRECISION
|
||||
|
||||
def _round(val):
|
||||
if isinstance(val, float):
|
||||
return round(val, precision)
|
||||
|
||||
return val
|
||||
|
||||
rows = []
|
||||
for fname, diff_ in diff.items():
|
||||
sorted_diff = OrderedDict(sorted(diff_.items()))
|
||||
@@ -334,9 +380,13 @@ def _show_diff(
|
||||
row = [] if no_path else [fname]
|
||||
row.append(item)
|
||||
if old:
|
||||
row.append(_round(change.get("old")))
|
||||
row.append(_round(change["new"]))
|
||||
row.append(_round(change.get("diff", "diff not supported")))
|
||||
row.append(_format_field(change.get("old"), precision))
|
||||
row.append(_format_field(change["new"], precision))
|
||||
row.append(
|
||||
_format_field(
|
||||
change.get("diff", "diff not supported"), precision
|
||||
)
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
header = [] if no_path else ["Path"]
|
||||
@@ -367,6 +417,11 @@ class CmdExperimentsDiff(CmdBase):
|
||||
|
||||
logger.info(json.dumps(diff))
|
||||
else:
|
||||
if self.args.precision is None:
|
||||
precision = DEFAULT_PRECISION
|
||||
else:
|
||||
precision = self.args.precision
|
||||
|
||||
diffs = [("metrics", "Metric"), ("params", "Param")]
|
||||
for key, title in diffs:
|
||||
table = _show_diff(
|
||||
@@ -375,7 +430,7 @@ class CmdExperimentsDiff(CmdBase):
|
||||
markdown=self.args.show_md,
|
||||
no_path=self.args.no_path,
|
||||
old=self.args.old,
|
||||
precision=self.args.precision,
|
||||
precision=precision,
|
||||
)
|
||||
if table:
|
||||
logger.info(table)
|
||||
@@ -388,7 +443,7 @@ class CmdExperimentsDiff(CmdBase):
|
||||
return 0
|
||||
|
||||
|
||||
class CmdExperimentsRun(CmdBase):
|
||||
class CmdExperimentsRun(CmdRepro):
|
||||
def run(self):
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
@@ -405,38 +460,16 @@ class CmdExperimentsRun(CmdBase):
|
||||
ret = 0
|
||||
for target in self.args.targets:
|
||||
try:
|
||||
stages = self.repo.reproduce(
|
||||
self.repo.experiments.run(
|
||||
target,
|
||||
single_item=self.args.single_item,
|
||||
force=self.args.force,
|
||||
dry=self.args.dry,
|
||||
interactive=self.args.interactive,
|
||||
pipeline=self.args.pipeline,
|
||||
all_pipelines=self.args.all_pipelines,
|
||||
run_cache=not self.args.no_run_cache,
|
||||
no_commit=self.args.no_commit,
|
||||
downstream=self.args.downstream,
|
||||
recursive=self.args.recursive,
|
||||
force_downstream=self.args.force_downstream,
|
||||
experiment=True,
|
||||
name=self.args.name,
|
||||
queue=self.args.queue,
|
||||
run_all=self.args.run_all,
|
||||
jobs=self.args.jobs,
|
||||
params=self.args.params,
|
||||
checkpoint=(
|
||||
self.args.checkpoint
|
||||
or self.args.checkpoint_continue is not None
|
||||
),
|
||||
checkpoint_continue=self.args.checkpoint_continue,
|
||||
checkpoint_resume=self.args.checkpoint_resume,
|
||||
**self._repro_kwargs,
|
||||
)
|
||||
|
||||
if len(stages) == 0:
|
||||
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
|
||||
if self.args.metrics:
|
||||
metrics = self.repo.metrics.show()
|
||||
logger.info(_show_metrics(metrics))
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
ret = 1
|
||||
@@ -446,6 +479,71 @@ class CmdExperimentsRun(CmdBase):
|
||||
return ret
|
||||
|
||||
|
||||
class CmdExperimentsGC(CmdRepro):
|
||||
def run(self):
|
||||
from dvc.repo.gc import _raise_error_if_all_disabled
|
||||
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
|
||||
_raise_error_if_all_disabled(
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
workspace=self.args.workspace,
|
||||
)
|
||||
|
||||
msg = "This will remove all experiments except those derived from "
|
||||
|
||||
msg += "the workspace"
|
||||
if self.args.all_commits:
|
||||
msg += " and all git commits"
|
||||
elif self.args.all_branches and self.args.all_tags:
|
||||
msg += " and all git branches and tags"
|
||||
elif self.args.all_branches:
|
||||
msg += " and all git branches"
|
||||
elif self.args.all_tags:
|
||||
msg += " and all git tags"
|
||||
msg += " of the current repo."
|
||||
if self.args.queued:
|
||||
msg += " Run queued experiments will be preserved."
|
||||
if self.args.queued:
|
||||
msg += " Run queued experiments will be removed."
|
||||
|
||||
logger.warning(msg)
|
||||
|
||||
msg = "Are you sure you want to proceed?"
|
||||
if not self.args.force and not prompt.confirm(msg):
|
||||
return 1
|
||||
|
||||
removed = self.repo.experiments.gc(
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
workspace=self.args.workspace,
|
||||
queued=self.args.queued,
|
||||
)
|
||||
|
||||
if removed:
|
||||
logger.info(
|
||||
f"Removed {removed} experiments. To remove unused cache files "
|
||||
"use 'dvc gc'."
|
||||
)
|
||||
else:
|
||||
logger.info("No experiments to remove.")
|
||||
return 0
|
||||
|
||||
|
||||
class CmdExperimentsBranch(CmdBase):
|
||||
def run(self):
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
|
||||
self.repo.experiments.branch(self.args.experiment, self.args.branch)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
EXPERIMENTS_HELP = "Commands to display and compare experiments."
|
||||
|
||||
@@ -550,22 +648,39 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Always show git commit SHAs instead of branch/tag names.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Print output in JSON format instead of a human-readable table.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--precision",
|
||||
type=int,
|
||||
help=(
|
||||
"Round metrics/params to `n` digits precision after the decimal "
|
||||
f"point. Rounds to {DEFAULT_PRECISION} digits by default."
|
||||
),
|
||||
metavar="<n>",
|
||||
)
|
||||
experiments_show_parser.set_defaults(func=CmdExperimentsShow)
|
||||
|
||||
EXPERIMENTS_CHECKOUT_HELP = "Checkout experiments."
|
||||
experiments_checkout_parser = experiments_subparsers.add_parser(
|
||||
"checkout",
|
||||
EXPERIMENTS_APPLY_HELP = (
|
||||
"Apply the changes from an experiment to your workspace."
|
||||
)
|
||||
experiments_apply_parser = experiments_subparsers.add_parser(
|
||||
"apply",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(
|
||||
EXPERIMENTS_CHECKOUT_HELP, "experiments/checkout"
|
||||
EXPERIMENTS_APPLY_HELP, "experiments/apply"
|
||||
),
|
||||
help=EXPERIMENTS_CHECKOUT_HELP,
|
||||
help=EXPERIMENTS_APPLY_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_checkout_parser.add_argument(
|
||||
"experiment", help="Checkout this experiment.",
|
||||
experiments_apply_parser.add_argument(
|
||||
"experiment", help="Experiment to be applied.",
|
||||
)
|
||||
experiments_checkout_parser.set_defaults(func=CmdExperimentsCheckout)
|
||||
experiments_apply_parser.set_defaults(func=CmdExperimentsApply)
|
||||
|
||||
EXPERIMENTS_DIFF_HELP = (
|
||||
"Show changes between experiments in the DVC repository."
|
||||
@@ -636,145 +751,153 @@ def add_parser(subparsers, parent_parser):
|
||||
help=EXPERIMENTS_RUN_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
_add_run_common(experiments_run_parser)
|
||||
experiments_run_parser.add_argument(
|
||||
"targets",
|
||||
nargs="*",
|
||||
help=f"Stages to reproduce. '{PIPELINE_FILE}' by default.",
|
||||
).complete = completion.DVC_FILE
|
||||
experiments_run_parser.add_argument(
|
||||
"--checkpoint-resume", type=str, default=None, help=argparse.SUPPRESS,
|
||||
)
|
||||
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
|
||||
|
||||
EXPERIMENTS_RESUME_HELP = "Resume checkpoint experiments."
|
||||
experiments_resume_parser = experiments_subparsers.add_parser(
|
||||
"resume",
|
||||
parents=[parent_parser],
|
||||
aliases=["res"],
|
||||
description=append_doc_link(
|
||||
EXPERIMENTS_RESUME_HELP, "experiments/resume"
|
||||
),
|
||||
help=EXPERIMENTS_RESUME_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
_add_run_common(experiments_resume_parser)
|
||||
experiments_resume_parser.add_argument(
|
||||
"-r",
|
||||
"--rev",
|
||||
type=str,
|
||||
default=Experiments.LAST_CHECKPOINT,
|
||||
dest="checkpoint_resume",
|
||||
help=(
|
||||
"Continue the specified checkpoint experiment. "
|
||||
"If no experiment revision is provided, "
|
||||
"the most recently run checkpoint experiment will be used."
|
||||
),
|
||||
metavar="<experiment_rev>",
|
||||
)
|
||||
experiments_resume_parser.set_defaults(func=CmdExperimentsRun)
|
||||
|
||||
EXPERIMENTS_GC_HELP = "Garbage collect unneeded experiments."
|
||||
EXPERIMENTS_GC_DESCRIPTION = (
|
||||
"Removes all experiments which are not derived from the specified"
|
||||
"Git revisions."
|
||||
)
|
||||
experiments_gc_parser = experiments_subparsers.add_parser(
|
||||
"gc",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(
|
||||
EXPERIMENTS_GC_DESCRIPTION, "experiments/gc"
|
||||
),
|
||||
help=EXPERIMENTS_GC_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"-w",
|
||||
"--workspace",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep experiments derived from the current workspace.",
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"-a",
|
||||
"--all-branches",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep experiments derived from the tips of all Git branches.",
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"-T",
|
||||
"--all-tags",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep experiments derived from all Git tags.",
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"--all-commits",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Keep experiments derived from all Git commits.",
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"--queued",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Keep queued experiments (experiments run queue will be cleared "
|
||||
"by default)."
|
||||
),
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce even if dependencies were not changed.",
|
||||
help="Force garbage collection - automatically agree to all prompts.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-s",
|
||||
"--single-item",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce only single data item without recursive dependencies "
|
||||
"check.",
|
||||
experiments_gc_parser.set_defaults(func=CmdExperimentsGC)
|
||||
|
||||
EXPERIMENTS_BRANCH_HELP = "Promote an experiment to a Git branch."
|
||||
experiments_branch_parser = experiments_subparsers.add_parser(
|
||||
"branch",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(
|
||||
EXPERIMENTS_BRANCH_HELP, "experiments/branch"
|
||||
),
|
||||
help=EXPERIMENTS_BRANCH_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-c",
|
||||
"--cwd",
|
||||
default=os.path.curdir,
|
||||
help="Directory within your repo to reproduce from. Note: deprecated "
|
||||
"by `dvc --cd <path>`.",
|
||||
metavar="<path>",
|
||||
experiments_branch_parser.add_argument(
|
||||
"experiment", help="Experiment to be promoted.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show metrics after reproduction.",
|
||||
experiments_branch_parser.add_argument(
|
||||
"branch", help="Git branch name to use.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only print the commands that would be executed without "
|
||||
"actually executing.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ask for confirmation before reproducing each stage.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-p",
|
||||
"--pipeline",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce the whole pipeline that the specified stage file "
|
||||
"belongs to.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-P",
|
||||
"--all-pipelines",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce all pipelines in the repo.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"-R",
|
||||
"--recursive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce all stages in the specified directory.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
action="store_true",
|
||||
default=False,
|
||||
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
|
||||
|
||||
|
||||
def _add_run_common(parser):
|
||||
"""Add common args for 'exp run' and 'exp resume'."""
|
||||
# inherit arguments from `dvc repro`
|
||||
add_repro_arguments(parser)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--name",
|
||||
default=None,
|
||||
help=(
|
||||
"Execute stage commands even if they have already been run with "
|
||||
"the same command/dependencies/outputs/etc before."
|
||||
"Human-readable experiment name. If not specified, a name will "
|
||||
"be auto-generated."
|
||||
),
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--force-downstream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce all descendants of a changed stage even if their "
|
||||
"direct dependencies didn't change.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--downstream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Use the specified param values when reproducing pipelines.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--queue",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Stage this experiment in the run queue for future execution.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--run-all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Execute all experiments in the run queue.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
help="Run the specified number of experiments at a time in parallel.",
|
||||
metavar="<number>",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--checkpoint",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce pipelines as a checkpoint experiment.",
|
||||
)
|
||||
experiments_run_parser.add_argument(
|
||||
"--continue",
|
||||
nargs=1,
|
||||
default=None,
|
||||
dest="checkpoint_continue",
|
||||
help=(
|
||||
"Continue from the specified checkpoint experiment"
|
||||
"(implies --checkpoint)."
|
||||
),
|
||||
)
|
||||
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
|
||||
|
||||
@@ -17,6 +17,8 @@ class CmdImport(CmdBase):
|
||||
out=self.args.out,
|
||||
fname=self.args.file,
|
||||
rev=self.args.rev,
|
||||
no_exec=self.args.no_exec,
|
||||
desc=self.args.desc,
|
||||
)
|
||||
except DvcException:
|
||||
logger.exception(
|
||||
@@ -65,4 +67,19 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Specify name of the DVC-file this command will generate.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--no-exec",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only create DVC-file without actually downloading it.",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
metavar="<text>",
|
||||
help=(
|
||||
"User description of the data (optional). "
|
||||
"This doesn't affect any DVC operations."
|
||||
),
|
||||
)
|
||||
import_parser.set_defaults(func=CmdImport)
|
||||
|
||||
@@ -16,6 +16,7 @@ class CmdImportUrl(CmdBase):
|
||||
out=self.args.out,
|
||||
fname=self.args.file,
|
||||
no_exec=self.args.no_exec,
|
||||
desc=self.args.desc,
|
||||
)
|
||||
except DvcException:
|
||||
logger.exception(
|
||||
@@ -67,4 +68,13 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Only create DVC-file without actually downloading it.",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
metavar="<text>",
|
||||
help=(
|
||||
"User description of the data (optional). "
|
||||
"This doesn't affect any DVC operations."
|
||||
),
|
||||
)
|
||||
import_parser.set_defaults(func=CmdImportUrl)
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import colorama
|
||||
|
||||
from dvc import analytics
|
||||
from dvc.command.base import CmdBaseNoRepo, append_doc_link
|
||||
from dvc.utils import boxify
|
||||
from dvc.utils import format_link as fmt_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _welcome_message():
|
||||
if analytics.is_enabled():
|
||||
logger.info(
|
||||
boxify(
|
||||
"DVC has enabled anonymous aggregate usage analytics.\n"
|
||||
"Read the analytics documentation (and how to opt-out) here:\n"
|
||||
+ fmt_link("https://dvc.org/doc/user-guide/analytics"),
|
||||
border_color="red",
|
||||
)
|
||||
)
|
||||
|
||||
msg = (
|
||||
"{yellow}What's next?{nc}\n"
|
||||
"{yellow}------------{nc}\n"
|
||||
f"- Check out the documentation: {fmt_link('https://dvc.org/doc')}\n"
|
||||
f"- Get help and share ideas: {fmt_link('https://dvc.org/chat')}\n"
|
||||
f"- Star us on GitHub: {fmt_link('https://github.com/iterative/dvc')}"
|
||||
).format(yellow=colorama.Fore.YELLOW, nc=colorama.Fore.RESET)
|
||||
|
||||
logger.info(msg)
|
||||
|
||||
|
||||
class CmdInit(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.exceptions import InitError
|
||||
@@ -19,6 +46,7 @@ class CmdInit(CmdBaseNoRepo):
|
||||
subdir=self.args.subdir,
|
||||
)
|
||||
self.config = self.repo.config
|
||||
_welcome_message()
|
||||
except InitError:
|
||||
logger.exception("failed to initiate DVC")
|
||||
return 1
|
||||
|
||||
@@ -70,7 +70,7 @@ class CmdMetricsShow(CmdMetricsBase):
|
||||
if table:
|
||||
logger.info(table)
|
||||
except DvcException:
|
||||
logger.exception("failed to show metrics")
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
@@ -171,9 +171,8 @@ def add_parser(subparsers, parent_parser):
|
||||
"targets",
|
||||
nargs="*",
|
||||
help=(
|
||||
"Limit command scope to these metric files (supports any file, "
|
||||
"even when not found as `metrics` in `dvc.yaml`). Using -R, "
|
||||
"directories to search metric files in can also be given."
|
||||
"Limit command scope to these metrics files. Using -R, "
|
||||
"directories to search metrics files in can also be given."
|
||||
),
|
||||
).complete = completion.FILE
|
||||
metrics_show_parser.add_argument(
|
||||
@@ -209,7 +208,7 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help=(
|
||||
"If any target is a directory, recursively search and process "
|
||||
"metric files."
|
||||
"metrics files."
|
||||
),
|
||||
)
|
||||
metrics_show_parser.set_defaults(func=CmdMetricsShow)
|
||||
@@ -237,8 +236,8 @@ def add_parser(subparsers, parent_parser):
|
||||
"--targets",
|
||||
nargs="*",
|
||||
help=(
|
||||
"Limit command scope to these metric files. Using -R, "
|
||||
"directories to search metric files in can also be given."
|
||||
"Limit command scope to these metrics files. Using -R, "
|
||||
"directories to search metrics files in can also be given."
|
||||
),
|
||||
metavar="<paths>",
|
||||
).complete = completion.FILE
|
||||
@@ -249,7 +248,7 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help=(
|
||||
"If any target is a directory, recursively search and process "
|
||||
"metric files."
|
||||
"metrics files."
|
||||
),
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
|
||||
@@ -31,8 +31,6 @@ DIV_HTML = """<div id = "{id}"></div>
|
||||
|
||||
|
||||
class CmdPlots(CmdBase):
|
||||
UNINITIALIZED = True
|
||||
|
||||
def _func(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -67,12 +65,11 @@ class CmdPlots(CmdBase):
|
||||
html = PAGE_HTML.format(divs="\n".join(divs))
|
||||
path = self.args.out or "plots.html"
|
||||
|
||||
path = os.path.join(os.getcwd(), path)
|
||||
with open(path, "w") as fobj:
|
||||
fobj.write(html)
|
||||
|
||||
logger.info(
|
||||
"file://{}".format(os.path.join(self.repo.root_dir, path))
|
||||
)
|
||||
logger.info(f"file://{path}")
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
@@ -82,11 +79,15 @@ class CmdPlots(CmdBase):
|
||||
|
||||
|
||||
class CmdPlotsShow(CmdPlots):
|
||||
UNINITIALIZED = True
|
||||
|
||||
def _func(self, *args, **kwargs):
|
||||
return self.repo.plots.show(*args, **kwargs)
|
||||
|
||||
|
||||
class CmdPlotsDiff(CmdPlots):
|
||||
UNINITIALIZED = True
|
||||
|
||||
def _func(self, *args, **kwargs):
|
||||
return self.repo.plots.diff(
|
||||
*args,
|
||||
@@ -124,7 +125,7 @@ def add_parser(subparsers, parent_parser):
|
||||
|
||||
fix_subparsers(plots_subparsers)
|
||||
|
||||
SHOW_HELP = "Generate plots from metric files."
|
||||
SHOW_HELP = "Generate plots from metrics files."
|
||||
plots_show_parser = plots_subparsers.add_parser(
|
||||
"show",
|
||||
parents=[parent_parser],
|
||||
@@ -176,7 +177,7 @@ def add_parser(subparsers, parent_parser):
|
||||
_add_output_arguments(plots_diff_parser)
|
||||
plots_diff_parser.set_defaults(func=CmdPlotsDiff)
|
||||
|
||||
PLOTS_MODIFY_HELP = "Modify display properties of plot metric files."
|
||||
PLOTS_MODIFY_HELP = "Modify display properties of plot metrics files."
|
||||
plots_modify_parser = plots_subparsers.add_parser(
|
||||
"modify",
|
||||
parents=[parent_parser],
|
||||
|
||||
@@ -21,7 +21,8 @@ class CmdRemove(CmdBase):
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
REMOVE_HELP = (
|
||||
"Remove stage entry, remove .gitignore entry and unprotect outputs"
|
||||
"Remove stages or .dvc files, unprotect their outputs, and"
|
||||
" erase .gitignore entries."
|
||||
)
|
||||
remove_parser = subparsers.add_parser(
|
||||
"remove",
|
||||
|
||||
+45
-57
@@ -26,29 +26,15 @@ class CmdRepro(CmdBase):
|
||||
ret = 0
|
||||
for target in self.args.targets:
|
||||
try:
|
||||
stages = self.repo.reproduce(
|
||||
target,
|
||||
single_item=self.args.single_item,
|
||||
force=self.args.force,
|
||||
dry=self.args.dry,
|
||||
interactive=self.args.interactive,
|
||||
pipeline=self.args.pipeline,
|
||||
all_pipelines=self.args.all_pipelines,
|
||||
run_cache=not self.args.no_run_cache,
|
||||
no_commit=self.args.no_commit,
|
||||
downstream=self.args.downstream,
|
||||
recursive=self.args.recursive,
|
||||
force_downstream=self.args.force_downstream,
|
||||
experiment=self.args.experiment,
|
||||
queue=self.args.queue,
|
||||
run_all=self.args.run_all,
|
||||
jobs=self.args.jobs,
|
||||
params=self.args.params,
|
||||
pull=self.args.pull,
|
||||
)
|
||||
stages = self.repo.reproduce(target, **self._repro_kwargs)
|
||||
|
||||
if len(stages) == 0:
|
||||
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
else:
|
||||
logger.info(
|
||||
"Use `dvc push` to send your updates to "
|
||||
"remote storage."
|
||||
)
|
||||
|
||||
if self.args.metrics:
|
||||
metrics = self.repo.metrics.show()
|
||||
@@ -62,18 +48,26 @@ class CmdRepro(CmdBase):
|
||||
os.chdir(saved_dir)
|
||||
return ret
|
||||
|
||||
@property
|
||||
def _repro_kwargs(self):
|
||||
return {
|
||||
"single_item": self.args.single_item,
|
||||
"force": self.args.force,
|
||||
"dry": self.args.dry,
|
||||
"interactive": self.args.interactive,
|
||||
"pipeline": self.args.pipeline,
|
||||
"all_pipelines": self.args.all_pipelines,
|
||||
"run_cache": not self.args.no_run_cache,
|
||||
"no_commit": self.args.no_commit,
|
||||
"downstream": self.args.downstream,
|
||||
"recursive": self.args.recursive,
|
||||
"force_downstream": self.args.force_downstream,
|
||||
"pull": self.args.pull,
|
||||
"glob": self.args.glob,
|
||||
}
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
REPRO_HELP = (
|
||||
"Reproduce complete or partial pipelines by executing their stages."
|
||||
)
|
||||
repro_parser = subparsers.add_parser(
|
||||
"repro",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(REPRO_HELP, "repro"),
|
||||
help=REPRO_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
def add_arguments(repro_parser):
|
||||
repro_parser.add_argument(
|
||||
"targets",
|
||||
nargs="*",
|
||||
@@ -173,32 +167,6 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-e",
|
||||
"--experiment",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--params",
|
||||
action="append",
|
||||
default=[],
|
||||
help=argparse.SUPPRESS,
|
||||
metavar="[<filename>:]<params_list>",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--queue", action="store_true", default=False, help=argparse.SUPPRESS
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--run-all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-j", "--jobs", type=int, help=argparse.SUPPRESS, metavar="<number>"
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--pull",
|
||||
action="store_true",
|
||||
@@ -208,4 +176,24 @@ def add_parser(subparsers, parent_parser):
|
||||
"from the run-cache."
|
||||
),
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--glob",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
)
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
REPRO_HELP = (
|
||||
"Reproduce complete or partial pipelines by executing their stages."
|
||||
)
|
||||
repro_parser = subparsers.add_parser(
|
||||
"repro",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(REPRO_HELP, "repro"),
|
||||
help=REPRO_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
add_arguments(repro_parser)
|
||||
repro_parser.set_defaults(func=CmdRepro)
|
||||
|
||||
+23
-3
@@ -21,6 +21,7 @@ class CmdRun(CmdBase):
|
||||
self.args.plots_no_cache,
|
||||
self.args.outs_persist,
|
||||
self.args.outs_persist_no_cache,
|
||||
self.args.checkpoints,
|
||||
self.args.params,
|
||||
self.args.command,
|
||||
]
|
||||
@@ -45,16 +46,18 @@ class CmdRun(CmdBase):
|
||||
params=self.args.params,
|
||||
fname=self.args.file,
|
||||
wdir=self.args.wdir,
|
||||
no_exec=self.args.no_exec,
|
||||
no_exec=(self.args.no_exec or bool(self.args.checkpoints)),
|
||||
force=self.args.force,
|
||||
run_cache=not self.args.no_run_cache,
|
||||
no_commit=self.args.no_commit,
|
||||
outs_persist=self.args.outs_persist,
|
||||
outs_persist_no_cache=self.args.outs_persist_no_cache,
|
||||
checkpoints=self.args.checkpoints,
|
||||
always_changed=self.args.always_changed,
|
||||
name=self.args.name,
|
||||
single_stage=self.args.single_stage,
|
||||
external=self.args.external,
|
||||
desc=self.args.desc,
|
||||
)
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
@@ -134,7 +137,7 @@ def add_parser(subparsers, parent_parser):
|
||||
"--metrics",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output metric file.",
|
||||
help="Declare output metrics file.",
|
||||
metavar="<path>",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
@@ -142,7 +145,7 @@ def add_parser(subparsers, parent_parser):
|
||||
"--metrics-no-cache",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output metric file (do not put into DVC cache).",
|
||||
help="Declare output metrics file (do not put into DVC cache).",
|
||||
metavar="<path>",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
@@ -212,6 +215,14 @@ def add_parser(subparsers, parent_parser):
|
||||
"removed upon repro (do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"-c",
|
||||
"--checkpoints",
|
||||
action="append",
|
||||
default=[],
|
||||
help=argparse.SUPPRESS,
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
run_parser.add_argument(
|
||||
"--always-changed",
|
||||
action="store_true",
|
||||
@@ -230,6 +241,15 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Allow outputs that are outside of the DVC repository.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
metavar="<text>",
|
||||
help=(
|
||||
"User description of the stage (optional). "
|
||||
"This doesn't affect any DVC operations."
|
||||
),
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"command", nargs=argparse.REMAINDER, help="Command to execute."
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ class CmdDataStatus(CmdDataBase):
|
||||
STATUS_LEN = 20
|
||||
STATUS_INDENT = "\t"
|
||||
UP_TO_DATE_MSG = "Data and pipelines are up to date."
|
||||
IN_SYNC_MSG = "Cache and remote '{remote}' are in sync."
|
||||
EMPTY_PROJECT_MSG = (
|
||||
"There are no data or pipelines tracked in this project yet.\n"
|
||||
"See {link} to get started!"
|
||||
@@ -68,6 +69,11 @@ class CmdDataStatus(CmdDataBase):
|
||||
self._show(st, indent)
|
||||
elif not self.repo.stages:
|
||||
logger.info(self.EMPTY_PROJECT_MSG)
|
||||
elif self.args.cloud or self.args.remote:
|
||||
remote = self.args.remote or self.repo.config["core"].get(
|
||||
"remote"
|
||||
)
|
||||
logger.info(self.IN_SYNC_MSG.format(remote=remote))
|
||||
else:
|
||||
logger.info(self.UP_TO_DATE_MSG)
|
||||
|
||||
|
||||
@@ -24,5 +24,6 @@ def add_parser(subparsers, parent_parser):
|
||||
description=append_doc_link(VERSION_HELP, "version"),
|
||||
help=VERSION_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
aliases=["doctor"],
|
||||
)
|
||||
version_parser.set_defaults(func=CmdVersion)
|
||||
|
||||
+26
-1
@@ -103,6 +103,7 @@ class RelPath(str):
|
||||
REMOTE_COMMON = {
|
||||
"url": str,
|
||||
"checksum_jobs": All(Coerce(int), Range(1)),
|
||||
"jobs": All(Coerce(int), Range(1)),
|
||||
Optional("no_traverse"): Bool, # obsoleted
|
||||
"verify": Bool,
|
||||
}
|
||||
@@ -148,6 +149,7 @@ SCHEMA = {
|
||||
"s3": str,
|
||||
"gs": str,
|
||||
"hdfs": str,
|
||||
"webhdfs": str,
|
||||
"ssh": str,
|
||||
"azure": str,
|
||||
# This is for default local cache
|
||||
@@ -194,6 +196,13 @@ SCHEMA = {
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"hdfs": {"user": str, **REMOTE_COMMON},
|
||||
"webhdfs": {
|
||||
"hdfscli_config": str,
|
||||
"webhdfs_token": str,
|
||||
"user": str,
|
||||
"webhdfs_alias": str,
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"azure": {"connection_string": str, **REMOTE_COMMON},
|
||||
"oss": {
|
||||
"oss_key_id": str,
|
||||
@@ -224,6 +233,8 @@ SCHEMA = {
|
||||
"row_limit": All(Coerce(int), Range(1)),
|
||||
"row_cleanup_quota": All(Coerce(int), Range(0, 100)),
|
||||
},
|
||||
# section for experimental features
|
||||
"feature": {Optional("parametrization", default=False): Bool},
|
||||
}
|
||||
COMPILED_SCHEMA = Schema(SCHEMA)
|
||||
|
||||
@@ -375,6 +386,12 @@ class Config(dict):
|
||||
def resolve(path):
|
||||
if os.path.isabs(path) or re.match(r"\w+://", path):
|
||||
return path
|
||||
|
||||
# on windows convert slashes to backslashes
|
||||
# to have path compatible with abs_conf_dir
|
||||
if os.path.sep == "\\" and "/" in path:
|
||||
path = path.replace("/", "\\")
|
||||
|
||||
return RelPath(os.path.join(abs_conf_dir, path))
|
||||
|
||||
return Config._map_dirs(conf, resolve)
|
||||
@@ -401,7 +418,15 @@ class Config(dict):
|
||||
dirs_schema = {
|
||||
"cache": {"dir": func},
|
||||
"remote": {
|
||||
str: {"url": func, "gdrive_user_credentials_file": func}
|
||||
str: {
|
||||
"url": func,
|
||||
"gdrive_user_credentials_file": func,
|
||||
"gdrive_service_account_p12_file_path": func,
|
||||
"credentialpath": func,
|
||||
"keyfile": func,
|
||||
"cert_path": func,
|
||||
"key_path": func,
|
||||
}
|
||||
},
|
||||
}
|
||||
return Schema(dirs_schema, extra=ALLOW_EXTRA)(conf)
|
||||
|
||||
+12
-6
@@ -62,8 +62,11 @@ class DataCloud:
|
||||
"""
|
||||
remote = self.get_remote(remote, "push")
|
||||
|
||||
return self.repo.cache.local.push(
|
||||
cache, jobs=jobs, remote=remote, show_checksums=show_checksums,
|
||||
return remote.push(
|
||||
self.repo.cache.local,
|
||||
cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
|
||||
def pull(
|
||||
@@ -81,8 +84,11 @@ class DataCloud:
|
||||
"""
|
||||
remote = self.get_remote(remote, "pull")
|
||||
|
||||
return self.repo.cache.local.pull(
|
||||
cache, jobs=jobs, remote=remote, show_checksums=show_checksums
|
||||
return remote.pull(
|
||||
self.repo.cache.local,
|
||||
cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
)
|
||||
|
||||
def status(
|
||||
@@ -107,10 +113,10 @@ class DataCloud:
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
remote = self.get_remote(remote, "status")
|
||||
return self.repo.cache.local.status(
|
||||
return remote.status(
|
||||
self.repo.cache.local,
|
||||
cache,
|
||||
jobs=jobs,
|
||||
remote=remote,
|
||||
show_checksums=show_checksums,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,9 @@ from dvc.dependency.local import LocalDependency
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.dependency.s3 import S3Dependency
|
||||
from dvc.dependency.ssh import SSHDependency
|
||||
from dvc.dependency.webdav import WebDAVDependency
|
||||
from dvc.dependency.webdavs import WebDAVSDependency
|
||||
from dvc.dependency.webhdfs import WebHDFSDependency
|
||||
from dvc.output.base import BaseOutput
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
@@ -25,6 +28,9 @@ DEPS = [
|
||||
HTTPSDependency,
|
||||
S3Dependency,
|
||||
SSHDependency,
|
||||
WebDAVDependency,
|
||||
WebDAVSDependency,
|
||||
WebHDFSDependency,
|
||||
# NOTE: LocalDependency is the default choice
|
||||
]
|
||||
|
||||
@@ -37,16 +43,20 @@ DEP_MAP = {
|
||||
Schemes.HDFS: HDFSDependency,
|
||||
Schemes.HTTP: HTTPDependency,
|
||||
Schemes.HTTPS: HTTPSDependency,
|
||||
Schemes.WEBDAV: WebDAVDependency,
|
||||
Schemes.WEBDAVS: WebDAVSDependency,
|
||||
Schemes.WEBHDFS: WebHDFSDependency,
|
||||
}
|
||||
|
||||
|
||||
# NOTE: schema for dependencies is basically the same as for outputs, but
|
||||
# without output-specific entries like 'cache' (whether or not output is
|
||||
# cached, see -o and -O flags for `dvc run`) and 'metric' (whether or not
|
||||
# output is a metric file and how to parse it, see `-M` flag for `dvc run`).
|
||||
# output is a metrics file and how to parse it, see `-M` flag for `dvc run`).
|
||||
SCHEMA = output.SCHEMA.copy()
|
||||
del SCHEMA[BaseOutput.PARAM_CACHE]
|
||||
del SCHEMA[BaseOutput.PARAM_METRIC]
|
||||
del SCHEMA[BaseOutput.PARAM_DESC]
|
||||
SCHEMA.update(RepoDependency.REPO_SCHEMA)
|
||||
SCHEMA.update(ParamsDependency.PARAM_SCHEMA)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Type
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
|
||||
@@ -21,9 +23,11 @@ class DependencyIsStageFileError(DvcException):
|
||||
class BaseDependency:
|
||||
IS_DEPENDENCY = True
|
||||
|
||||
DoesNotExistError = DependencyDoesNotExistError
|
||||
IsNotFileOrDirError = DependencyIsNotFileOrDirError
|
||||
IsStageFileError = DependencyIsStageFileError
|
||||
DoesNotExistError = DependencyDoesNotExistError # type: Type[DvcException]
|
||||
IsNotFileOrDirError = (
|
||||
DependencyIsNotFileOrDirError
|
||||
) # type: Type[DvcException]
|
||||
IsStageFileError = DependencyIsStageFileError # type: Type[DvcException]
|
||||
|
||||
def update(self, rev=None):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from dvc.dependency.base import BaseDependency
|
||||
from dvc.output.base import BaseOutput
|
||||
|
||||
from ..tree.webdav import WebDAVTree
|
||||
|
||||
|
||||
class WebDAVDependency(BaseDependency, BaseOutput):
|
||||
TREE_CLS = WebDAVTree
|
||||
@@ -0,0 +1,6 @@
|
||||
from ..tree.webdavs import WebDAVSTree
|
||||
from .webdav import WebDAVDependency
|
||||
|
||||
|
||||
class WebDAVSDependency(WebDAVDependency):
|
||||
TREE_CLS = WebDAVSTree
|
||||
@@ -0,0 +1,6 @@
|
||||
from dvc.dependency.base import BaseDependency
|
||||
from dvc.output.webhdfs import WebHDFSOutput
|
||||
|
||||
|
||||
class WebHDFSDependency(BaseDependency, WebHDFSOutput):
|
||||
pass
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import posixpath
|
||||
from operator import itemgetter
|
||||
|
||||
from pygtrie import Trie
|
||||
|
||||
from .hash_info import HashInfo
|
||||
|
||||
|
||||
def _diff(ancestor, other, allow_removed=False):
|
||||
from dictdiffer import diff
|
||||
|
||||
from dvc.exceptions import MergeError
|
||||
|
||||
allowed = ["add"]
|
||||
if allow_removed:
|
||||
allowed.append("remove")
|
||||
|
||||
result = list(diff(ancestor, other))
|
||||
for typ, _, _ in result:
|
||||
if typ not in allowed:
|
||||
raise MergeError(
|
||||
"unable to auto-merge directories with diff that contains "
|
||||
f"'{typ}'ed files"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _merge(ancestor, our, their):
|
||||
import copy
|
||||
|
||||
from dictdiffer import patch
|
||||
|
||||
our_diff = _diff(ancestor, our)
|
||||
if not our_diff:
|
||||
return copy.deepcopy(their)
|
||||
|
||||
their_diff = _diff(ancestor, their)
|
||||
if not their_diff:
|
||||
return copy.deepcopy(our)
|
||||
|
||||
# make sure there are no conflicting files
|
||||
_diff(our, their, allow_removed=True)
|
||||
|
||||
return patch(our_diff + their_diff, ancestor)
|
||||
|
||||
|
||||
class DirInfo:
|
||||
PARAM_RELPATH = "relpath"
|
||||
|
||||
def __init__(self):
|
||||
self.trie = Trie()
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
try:
|
||||
return sum(
|
||||
hash_info.size
|
||||
for _, hash_info in self.trie.iteritems() # noqa: B301
|
||||
)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def nfiles(self):
|
||||
return len(self.trie)
|
||||
|
||||
def items(self, path_info=None):
|
||||
for key, hash_info in self.trie.iteritems(): # noqa: B301
|
||||
path = posixpath.sep.join(key)
|
||||
if path_info is not None:
|
||||
path = path_info / path
|
||||
yield path, hash_info
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, lst):
|
||||
ret = DirInfo()
|
||||
for _entry in lst:
|
||||
entry = _entry.copy()
|
||||
relpath = entry.pop(cls.PARAM_RELPATH)
|
||||
parts = tuple(relpath.split(posixpath.sep))
|
||||
ret.trie[parts] = HashInfo.from_dict(entry)
|
||||
return ret
|
||||
|
||||
def to_list(self):
|
||||
# Sorting the list by path to ensure reproducibility
|
||||
return sorted(
|
||||
(
|
||||
{
|
||||
# NOTE: not using hash_info.to_dict() because we don't want
|
||||
# size/nfiles fields at this point.
|
||||
hash_info.name: hash_info.value,
|
||||
self.PARAM_RELPATH: posixpath.sep.join(parts),
|
||||
}
|
||||
for parts, hash_info in self.trie.iteritems() # noqa: B301
|
||||
),
|
||||
key=itemgetter(self.PARAM_RELPATH),
|
||||
)
|
||||
|
||||
def merge(self, ancestor, their):
|
||||
merged = DirInfo()
|
||||
merged.trie = _merge(ancestor.trie, self.trie, their.trie)
|
||||
return merged
|
||||
+16
-4
@@ -36,6 +36,10 @@ class LockfileCorruptedError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class ParametrizedDumpError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def is_valid_filename(path):
|
||||
return path.endswith(DVC_FILE_SUFFIX) or os.path.basename(path) in [
|
||||
DVC_FILE,
|
||||
@@ -45,10 +49,14 @@ def is_valid_filename(path):
|
||||
|
||||
def is_dvc_file(path):
|
||||
return os.path.isfile(path) and (
|
||||
is_valid_filename(path) or os.path.basename(path) == PIPELINE_LOCK
|
||||
is_valid_filename(path) or is_lock_file(path)
|
||||
)
|
||||
|
||||
|
||||
def is_lock_file(path):
|
||||
return os.path.basename(path) == PIPELINE_LOCK
|
||||
|
||||
|
||||
def check_dvc_filename(path):
|
||||
if not is_valid_filename(path):
|
||||
raise StageFileBadNameError(
|
||||
@@ -193,7 +201,13 @@ class PipelineFile(FileMixin):
|
||||
def _dump_lockfile(self, stage):
|
||||
self._lockfile.dump(stage)
|
||||
|
||||
@staticmethod
|
||||
def _check_if_parametrized(stage):
|
||||
if stage.raw_data.parametrized:
|
||||
raise ParametrizedDumpError(f"cannot dump a parametrized {stage}")
|
||||
|
||||
def _dump_pipeline_file(self, stage):
|
||||
self._check_if_parametrized(stage)
|
||||
stage_data = serialize.to_pipeline_file(stage)
|
||||
|
||||
with modify_yaml(self.path, tree=self.repo.tree) as data:
|
||||
@@ -209,8 +223,6 @@ class PipelineFile(FileMixin):
|
||||
|
||||
if existing_entry:
|
||||
orig_stage_data = data["stages"][stage.name]
|
||||
if "meta" in orig_stage_data:
|
||||
stage_data[stage.name]["meta"] = orig_stage_data["meta"]
|
||||
apply_diff(stage_data[stage.name], orig_stage_data)
|
||||
else:
|
||||
data["stages"].update(stage_data)
|
||||
@@ -227,7 +239,7 @@ class PipelineFile(FileMixin):
|
||||
def stages(self):
|
||||
data, _ = self._load()
|
||||
lockfile_data = self._lockfile.load()
|
||||
return StageLoader(self, data.get("stages", {}), lockfile_data)
|
||||
return StageLoader(self, data, lockfile_data)
|
||||
|
||||
def remove(self, force=False):
|
||||
if not force:
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
DVC_CHECKPOINT = "DVC_CHECKPOINT"
|
||||
DVC_DAEMON = "DVC_DAEMON"
|
||||
DVC_PAGER = "DVC_PAGER"
|
||||
DVC_ROOT = "DVC_ROOT"
|
||||
|
||||
+36
-7
@@ -42,7 +42,7 @@ class OutputDuplicationError(DvcException):
|
||||
|
||||
|
||||
class OutputNotFoundError(DvcException):
|
||||
"""Thrown if a file/directory not found in repository pipelines.
|
||||
"""Thrown if a file/directory is not found as an output in any pipeline.
|
||||
|
||||
Args:
|
||||
output (unicode): path to the file/directory.
|
||||
@@ -105,7 +105,7 @@ class ArgumentDuplicationError(DvcException):
|
||||
|
||||
|
||||
class MoveNotDataSourceError(DvcException):
|
||||
"""Thrown if attempted to move a file/directory that is not an output
|
||||
"""Thrown when trying to move a file/directory that is not an output
|
||||
in a data source stage.
|
||||
|
||||
Args:
|
||||
@@ -164,7 +164,7 @@ class BadMetricError(DvcException):
|
||||
def __init__(self, paths):
|
||||
super().__init__(
|
||||
"the following metrics do not exist, "
|
||||
"are not metric files or are malformed: {paths}".format(
|
||||
"are not metrics files or are malformed: {paths}".format(
|
||||
paths=", ".join(f"'{path}'" for path in paths)
|
||||
)
|
||||
)
|
||||
@@ -174,11 +174,20 @@ class NoMetricsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class NoPlotsError(DvcException):
|
||||
def __init__(self):
|
||||
class NoMetricsParsedError(NoMetricsError):
|
||||
def __init__(self, command):
|
||||
super().__init__(
|
||||
"no plots in this repository. Use `--plots/--plots-no-cache` "
|
||||
"options for `dvc run` to mark stage outputs as plots."
|
||||
f"Could not parse {command} files. Use `-v` option to see more "
|
||||
"details."
|
||||
)
|
||||
|
||||
|
||||
class NoMetricsFoundError(NoMetricsError):
|
||||
def __init__(self, command, run_options):
|
||||
super().__init__(
|
||||
f"No {command} files in this repository. "
|
||||
f"Use `{run_options}` options for "
|
||||
f"`dvc run` to mark stage outputs as {command}."
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +227,11 @@ class FileMissingError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class FileOwnershipError(DvcException):
|
||||
def __init__(self, path):
|
||||
super().__init__(f"file '{path}' not owned by user! ")
|
||||
|
||||
|
||||
class DvcIgnoreInCollectedDirError(DvcException):
|
||||
def __init__(self, ignore_dirname):
|
||||
super().__init__(
|
||||
@@ -338,3 +352,18 @@ class NoOutputOrStageError(DvcException):
|
||||
|
||||
class MergeError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class CacheLinkError(DvcException):
|
||||
SUPPORT_LINK = "See {} for more information.".format(
|
||||
format_link(
|
||||
"https://dvc.org/doc/user-guide/troubleshooting#cache-types"
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, path_infos):
|
||||
msg = "No possible cache link types for '{}'. {}".format(
|
||||
", ".join([str(path) for path in path_infos]), self.SUPPORT_LINK,
|
||||
)
|
||||
super().__init__(msg)
|
||||
self.path_infos = path_infos
|
||||
|
||||
+40
-93
@@ -4,7 +4,7 @@ import tempfile
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from distutils.dir_util import copy_tree
|
||||
from typing import Iterable
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from funcy import cached_property, reraise, retry, wrap_with
|
||||
|
||||
@@ -22,7 +22,6 @@ from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.scm.base import CloneError
|
||||
from dvc.scm.git import Git
|
||||
from dvc.state import StateNoop
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.utils import relpath
|
||||
@@ -51,12 +50,10 @@ def external_repo(url, rev=None, for_write=False, **kwargs):
|
||||
scm=None if for_write else Git(root_dir),
|
||||
rev=None if for_write else rev,
|
||||
for_write=for_write,
|
||||
uninitialized=True,
|
||||
**kwargs,
|
||||
)
|
||||
try:
|
||||
repo = ExternalRepo(**conf)
|
||||
except NotDvcRepoError:
|
||||
repo = ExternalGitRepo(**conf)
|
||||
repo = ExternalRepo(**conf)
|
||||
|
||||
try:
|
||||
yield repo
|
||||
@@ -76,8 +73,8 @@ def external_repo(url, rev=None, for_write=False, **kwargs):
|
||||
_remove(path)
|
||||
|
||||
|
||||
CLONES = {}
|
||||
CACHE_DIRS = {}
|
||||
CLONES: Dict[str, str] = {}
|
||||
CACHE_DIRS: Dict[str, str] = {}
|
||||
|
||||
|
||||
def clean_repos():
|
||||
@@ -90,9 +87,34 @@ def clean_repos():
|
||||
_remove(path)
|
||||
|
||||
|
||||
class BaseExternalRepo:
|
||||
class ExternalRepo(Repo):
|
||||
# pylint: disable=no-member
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_dir,
|
||||
url,
|
||||
scm=None,
|
||||
rev=None,
|
||||
for_write=False,
|
||||
cache_dir=None,
|
||||
cache_types=None,
|
||||
uninitialized=False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
root_dir, scm=scm, rev=rev, uninitialized=uninitialized
|
||||
)
|
||||
|
||||
self.url = url
|
||||
self.for_write = for_write
|
||||
self.cache_dir = cache_dir or self._get_cache_dir()
|
||||
self.cache_types = cache_types
|
||||
|
||||
self._setup_cache(self)
|
||||
self._fix_upstream(self)
|
||||
self.tree_confs = kwargs
|
||||
|
||||
def __str__(self):
|
||||
return self.url
|
||||
|
||||
@@ -108,13 +130,15 @@ class BaseExternalRepo:
|
||||
return self.scm.get_rev()
|
||||
return self.tree.rev
|
||||
|
||||
def _fetch_to_cache(self, path_info, repo, callback):
|
||||
def _fetch_to_cache(self, path_info, repo, callback, **kwargs):
|
||||
# don't support subrepo traversal as it might fail due to difference
|
||||
# in remotes
|
||||
tree = self._get_tree_for(repo)
|
||||
cache = repo.cache.local
|
||||
|
||||
hash_info = tree.get_hash(path_info, download_callback=callback)
|
||||
hash_info = tree.get_hash(
|
||||
path_info, download_callback=callback, **kwargs
|
||||
)
|
||||
cache.save(
|
||||
path_info,
|
||||
tree,
|
||||
@@ -148,7 +172,9 @@ class BaseExternalRepo:
|
||||
|
||||
self._check_repo(path, metadata.repo)
|
||||
repo = metadata.repo
|
||||
hash_info = self._fetch_to_cache(path, repo, download_update)
|
||||
hash_info = self._fetch_to_cache(
|
||||
path, repo, download_update, **kwargs
|
||||
)
|
||||
hash_infos.append(hash_info)
|
||||
|
||||
return sum(download_results), failed, hash_infos
|
||||
@@ -230,7 +256,8 @@ class BaseExternalRepo:
|
||||
return repo
|
||||
|
||||
def _setup_cache(self, repo):
|
||||
repo.cache.local.cache_dir = self.cache_dir
|
||||
repo.config["cache"]["dir"] = self.cache_dir
|
||||
repo.cache = Cache(repo)
|
||||
if self.cache_types:
|
||||
repo.cache.local.cache_types = self.cache_types
|
||||
|
||||
@@ -262,86 +289,6 @@ class BaseExternalRepo:
|
||||
return cache_dir
|
||||
|
||||
|
||||
class ExternalRepo(BaseExternalRepo, Repo):
|
||||
def __init__(
|
||||
self,
|
||||
root_dir,
|
||||
url,
|
||||
scm=None,
|
||||
rev=None,
|
||||
for_write=False,
|
||||
cache_dir=None,
|
||||
cache_types=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(root_dir, scm=scm, rev=rev)
|
||||
|
||||
self.url = url
|
||||
self.for_write = for_write
|
||||
self.cache_dir = cache_dir or self._get_cache_dir()
|
||||
self.cache_types = cache_types
|
||||
|
||||
self._setup_cache(self)
|
||||
self._fix_upstream(self)
|
||||
self.tree_confs = kwargs
|
||||
|
||||
|
||||
class ExternalGitRepo(BaseExternalRepo):
|
||||
def __init__(
|
||||
self,
|
||||
root_dir,
|
||||
url,
|
||||
scm=None,
|
||||
rev=None,
|
||||
for_write=False,
|
||||
cache_dir=None,
|
||||
cache_types=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.root_dir = os.path.realpath(root_dir)
|
||||
self.scm = scm
|
||||
|
||||
self.url = url
|
||||
self.for_write = for_write
|
||||
self.cache_dir = cache_dir or self._get_cache_dir()
|
||||
self.cache_types = cache_types
|
||||
|
||||
self.rev = rev
|
||||
self.tree_confs = kwargs
|
||||
|
||||
self.config = {"cache": {"dir": self.cache_dir}}
|
||||
self.cache = Cache(self)
|
||||
if cache_types:
|
||||
self.cache.local.cache_types = cache_types
|
||||
|
||||
self.state = StateNoop()
|
||||
|
||||
@cached_property
|
||||
def tree(self):
|
||||
if self.scm:
|
||||
return self.scm.get_tree(self.rev)
|
||||
return LocalTree(self, {"url": self.root_dir})
|
||||
|
||||
def close(self):
|
||||
if self.scm:
|
||||
self.scm.close()
|
||||
|
||||
def find_out_by_relpath(self, path):
|
||||
raise OutputNotFoundError(path, self)
|
||||
|
||||
@contextmanager
|
||||
def open_by_relpath(self, path, mode="r", encoding=None, **kwargs):
|
||||
"""Opens a specified resource as a file object."""
|
||||
path_info = PathInfo(self.root_dir) / path
|
||||
try:
|
||||
with self.repo_tree.open(
|
||||
path_info, mode=mode, encoding=encoding, **kwargs
|
||||
) as fobj:
|
||||
yield fobj
|
||||
except FileNotFoundError as exc:
|
||||
raise PathMissingError(path, self.url) from exc
|
||||
|
||||
|
||||
def _cached_clone(url, rev, for_write=False):
|
||||
"""Clone an external git repo to a temporary directory.
|
||||
|
||||
|
||||
+30
-7
@@ -1,26 +1,49 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
|
||||
HASH_DIR_SUFFIX = ".dir"
|
||||
|
||||
DirInfo = Dict[str, str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashInfo:
|
||||
name: str
|
||||
value: str
|
||||
dir_info: dict = field(default=None, compare=False)
|
||||
PARAM_SIZE = "size"
|
||||
PARAM_NFILES = "nfiles"
|
||||
|
||||
name: Optional[str]
|
||||
value: Optional[str]
|
||||
dir_info: Optional[DirInfo] = field(default=None, compare=False)
|
||||
size: Optional[int] = field(default=None, compare=False)
|
||||
nfiles: Optional[int] = field(default=None, compare=False)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
if not d:
|
||||
_d = d.copy() if d else {}
|
||||
size = _d.pop(cls.PARAM_SIZE, None)
|
||||
nfiles = _d.pop(cls.PARAM_NFILES, None)
|
||||
|
||||
if not _d:
|
||||
return cls(None, None)
|
||||
((name, value),) = d.items()
|
||||
return cls(name, value)
|
||||
|
||||
((name, value),) = _d.items()
|
||||
return cls(name, value, size=size, nfiles=nfiles)
|
||||
|
||||
def to_dict(self):
|
||||
return {self.name: self.value} if self else {}
|
||||
ret = OrderedDict()
|
||||
if not self:
|
||||
return ret
|
||||
|
||||
ret[self.name] = self.value
|
||||
if self.size is not None:
|
||||
ret[self.PARAM_SIZE] = self.size
|
||||
if self.nfiles is not None:
|
||||
ret[self.PARAM_NFILES] = self.nfiles
|
||||
return ret
|
||||
|
||||
@property
|
||||
def isdir(self):
|
||||
|
||||
+25
-1
@@ -8,7 +8,7 @@ from dvc.exceptions import DvcException, NotDvcRepoError
|
||||
from dvc.repo import Repo
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.system import System
|
||||
from dvc.tree import TREES
|
||||
from dvc.tree import TREES, get_tree_cls, get_tree_config
|
||||
from dvc.utils import error_link
|
||||
from dvc.utils.pkg import PKG
|
||||
from dvc.version import __version__
|
||||
@@ -50,6 +50,10 @@ def get_dvc_info():
|
||||
else:
|
||||
info.append("Cache types: " + error_link("no-dvc-cache"))
|
||||
|
||||
info.append(f"Caches: {_get_caches(repo.cache)}")
|
||||
|
||||
info.append(f"Remotes: {_get_remotes(repo.config)}")
|
||||
|
||||
except NotDvcRepoError:
|
||||
pass
|
||||
except SCMError:
|
||||
@@ -63,6 +67,26 @@ def get_dvc_info():
|
||||
return "\n".join(info)
|
||||
|
||||
|
||||
def _get_caches(cache):
|
||||
caches = (
|
||||
cache_type
|
||||
for cache_type, cache_instance in cache.by_scheme()
|
||||
if cache_instance
|
||||
)
|
||||
|
||||
# Caches will be always non-empty including the local cache
|
||||
return ", ".join(caches)
|
||||
|
||||
|
||||
def _get_remotes(config):
|
||||
schemes = (
|
||||
get_tree_cls(get_tree_config(config, name=remote)).scheme
|
||||
for remote in config["remote"]
|
||||
)
|
||||
|
||||
return ", ".join(schemes) or "None"
|
||||
|
||||
|
||||
def _get_linktype_support_info(repo):
|
||||
|
||||
links = {
|
||||
|
||||
+6
-3
@@ -15,10 +15,13 @@ from dvc.utils import format_link
|
||||
|
||||
DEFAULT_TIMEOUT = 3
|
||||
|
||||
|
||||
FAILED_TO_LOCK_MESSAGE = (
|
||||
"cannot perform the command because another DVC process seems to be "
|
||||
"running on this project. If that is not the case, manually remove "
|
||||
"`.dvc/tmp/lock` and try again."
|
||||
"Unable to acquire lock. Most likely another DVC process is running or "
|
||||
"was terminated abruptly. Check the page {} for other possible reasons "
|
||||
"and to learn how to resolve this."
|
||||
).format(
|
||||
format_link("https://dvc.org/doc/user-guide/troubleshooting#lock-issue")
|
||||
)
|
||||
|
||||
|
||||
|
||||
+16
-2
@@ -42,6 +42,19 @@ def profile(enable, dump):
|
||||
prof.dump_stats(dump)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def debug(enable):
|
||||
try:
|
||||
yield
|
||||
return
|
||||
except Exception:
|
||||
if enable:
|
||||
import pdb # noqa: T100
|
||||
|
||||
pdb.post_mortem()
|
||||
raise
|
||||
|
||||
|
||||
def main(argv=None): # noqa: C901
|
||||
"""Run dvc CLI command.
|
||||
|
||||
@@ -72,8 +85,9 @@ def main(argv=None): # noqa: C901
|
||||
logger.trace(args)
|
||||
|
||||
with profile(enable=args.cprofile, dump=args.cprofile_dump):
|
||||
cmd = args.func(args)
|
||||
ret = cmd.run()
|
||||
with debug(args.pdb):
|
||||
cmd = args.func(args)
|
||||
ret = cmd.run()
|
||||
except ConfigError:
|
||||
logger.exception("configuration error")
|
||||
ret = 251
|
||||
|
||||
+45
-3
@@ -4,24 +4,28 @@ from urllib.parse import urlparse
|
||||
from funcy import collecting, project
|
||||
from voluptuous import And, Any, Coerce, Length, Lower, Required, SetTo
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.output.base import BaseOutput
|
||||
from dvc.output.gs import GSOutput
|
||||
from dvc.output.hdfs import HDFSOutput
|
||||
from dvc.output.local import LocalOutput
|
||||
from dvc.output.s3 import S3Output
|
||||
from dvc.output.ssh import SSHOutput
|
||||
from dvc.output.webhdfs import WebHDFSOutput
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..tree import get_cloud_tree
|
||||
from ..tree.hdfs import HDFSTree
|
||||
from ..tree.local import LocalTree
|
||||
from ..tree.s3 import S3Tree
|
||||
from ..tree.webhdfs import WebHDFSTree
|
||||
|
||||
OUTS = [
|
||||
HDFSOutput,
|
||||
S3Output,
|
||||
GSOutput,
|
||||
SSHOutput,
|
||||
WebHDFSOutput,
|
||||
# NOTE: LocalOutput is the default choice
|
||||
]
|
||||
|
||||
@@ -31,6 +35,7 @@ OUTS_MAP = {
|
||||
Schemes.GS: GSOutput,
|
||||
Schemes.SSH: SSHOutput,
|
||||
Schemes.LOCAL: LocalOutput,
|
||||
Schemes.WEBHDFS: WebHDFSOutput,
|
||||
}
|
||||
|
||||
CHECKSUM_SCHEMA = Any(
|
||||
@@ -51,6 +56,7 @@ CHECKSUMS_SCHEMA = {
|
||||
LocalTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
S3Tree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
HDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
WebHDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
|
||||
}
|
||||
|
||||
SCHEMA = CHECKSUMS_SCHEMA.copy()
|
||||
@@ -59,10 +65,22 @@ SCHEMA[BaseOutput.PARAM_CACHE] = bool
|
||||
SCHEMA[BaseOutput.PARAM_METRIC] = BaseOutput.METRIC_SCHEMA
|
||||
SCHEMA[BaseOutput.PARAM_PLOT] = bool
|
||||
SCHEMA[BaseOutput.PARAM_PERSIST] = bool
|
||||
SCHEMA[BaseOutput.PARAM_CHECKPOINT] = bool
|
||||
SCHEMA[HashInfo.PARAM_SIZE] = int
|
||||
SCHEMA[HashInfo.PARAM_NFILES] = int
|
||||
SCHEMA[BaseOutput.PARAM_DESC] = str
|
||||
|
||||
|
||||
def _get(
|
||||
stage, p, info=None, cache=True, metric=False, plot=False, persist=False
|
||||
stage,
|
||||
p,
|
||||
info=None,
|
||||
cache=True,
|
||||
metric=False,
|
||||
plot=False,
|
||||
persist=False,
|
||||
checkpoint=False,
|
||||
desc=None,
|
||||
):
|
||||
parsed = urlparse(p)
|
||||
|
||||
@@ -77,6 +95,8 @@ def _get(
|
||||
metric=metric,
|
||||
plot=plot,
|
||||
persist=persist,
|
||||
checkpoint=checkpoint,
|
||||
desc=desc,
|
||||
)
|
||||
|
||||
for o in OUTS:
|
||||
@@ -90,6 +110,8 @@ def _get(
|
||||
metric=metric,
|
||||
plot=plot,
|
||||
persist=persist,
|
||||
checkpoint=checkpoint,
|
||||
desc=desc,
|
||||
)
|
||||
return LocalOutput(
|
||||
stage,
|
||||
@@ -100,6 +122,8 @@ def _get(
|
||||
metric=metric,
|
||||
plot=plot,
|
||||
persist=persist,
|
||||
checkpoint=checkpoint,
|
||||
desc=desc,
|
||||
)
|
||||
|
||||
|
||||
@@ -111,6 +135,8 @@ def loadd_from(stage, d_list):
|
||||
metric = d.pop(BaseOutput.PARAM_METRIC, False)
|
||||
plot = d.pop(BaseOutput.PARAM_PLOT, False)
|
||||
persist = d.pop(BaseOutput.PARAM_PERSIST, False)
|
||||
checkpoint = d.pop(BaseOutput.PARAM_CHECKPOINT, False)
|
||||
desc = d.pop(BaseOutput.PARAM_DESC, False)
|
||||
ret.append(
|
||||
_get(
|
||||
stage,
|
||||
@@ -120,13 +146,21 @@ def loadd_from(stage, d_list):
|
||||
metric=metric,
|
||||
plot=plot,
|
||||
persist=persist,
|
||||
checkpoint=checkpoint,
|
||||
desc=desc,
|
||||
)
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
def loads_from(
|
||||
stage, s_list, use_cache=True, metric=False, plot=False, persist=False
|
||||
stage,
|
||||
s_list,
|
||||
use_cache=True,
|
||||
metric=False,
|
||||
plot=False,
|
||||
persist=False,
|
||||
checkpoint=False,
|
||||
):
|
||||
return [
|
||||
_get(
|
||||
@@ -137,6 +171,7 @@ def loads_from(
|
||||
metric=metric,
|
||||
plot=plot,
|
||||
persist=persist,
|
||||
checkpoint=checkpoint,
|
||||
)
|
||||
for s in s_list
|
||||
]
|
||||
@@ -180,5 +215,12 @@ def load_from_pipeline(stage, s_list, typ="outs"):
|
||||
from dvc.schema import PLOT_PROPS
|
||||
|
||||
plt_d, flags = _split_dict(flags, keys=PLOT_PROPS.keys())
|
||||
extra = project(flags, ["cache", "persist"])
|
||||
extra = project(
|
||||
flags,
|
||||
[
|
||||
BaseOutput.PARAM_CACHE,
|
||||
BaseOutput.PARAM_PERSIST,
|
||||
BaseOutput.PARAM_CHECKPOINT,
|
||||
],
|
||||
)
|
||||
yield _get(stage, path, {}, plot=plt_d or plot, metric=metric, **extra)
|
||||
|
||||
+35
-12
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from copy import copy
|
||||
from typing import Type
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from voluptuous import Any
|
||||
@@ -61,6 +62,7 @@ class BaseOutput:
|
||||
|
||||
PARAM_PATH = "path"
|
||||
PARAM_CACHE = "cache"
|
||||
PARAM_CHECKPOINT = "checkpoint"
|
||||
PARAM_METRIC = "metric"
|
||||
PARAM_METRIC_TYPE = "type"
|
||||
PARAM_METRIC_XPATH = "xpath"
|
||||
@@ -73,6 +75,7 @@ class BaseOutput:
|
||||
PARAM_PLOT_TITLE = "title"
|
||||
PARAM_PLOT_HEADER = "header"
|
||||
PARAM_PERSIST = "persist"
|
||||
PARAM_DESC = "desc"
|
||||
|
||||
METRIC_SCHEMA = Any(
|
||||
None,
|
||||
@@ -83,10 +86,10 @@ class BaseOutput:
|
||||
},
|
||||
)
|
||||
|
||||
DoesNotExistError = OutputDoesNotExistError
|
||||
IsNotFileOrDirError = OutputIsNotFileOrDirError
|
||||
IsStageFileError = OutputIsStageFileError
|
||||
IsIgnoredError = OutputIsIgnoredError
|
||||
DoesNotExistError = OutputDoesNotExistError # type: Type[DvcException]
|
||||
IsNotFileOrDirError = OutputIsNotFileOrDirError # type: Type[DvcException]
|
||||
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
|
||||
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
|
||||
|
||||
sep = "/"
|
||||
|
||||
@@ -100,6 +103,8 @@ class BaseOutput:
|
||||
metric=False,
|
||||
plot=False,
|
||||
persist=False,
|
||||
checkpoint=False,
|
||||
desc=None,
|
||||
):
|
||||
self._validate_output_path(path, stage)
|
||||
# This output (and dependency) objects have too many paths/urls
|
||||
@@ -124,6 +129,8 @@ class BaseOutput:
|
||||
self.metric = False if self.IS_DEPENDENCY else metric
|
||||
self.plot = False if self.IS_DEPENDENCY else plot
|
||||
self.persist = persist
|
||||
self.checkpoint = checkpoint
|
||||
self.desc = desc
|
||||
|
||||
self.path_info = self._parse_path(tree, path)
|
||||
if self.use_cache and self.cache is None:
|
||||
@@ -282,6 +289,9 @@ class BaseOutput:
|
||||
self.hash_info = self.get_hash()
|
||||
|
||||
def commit(self):
|
||||
if not self.exists:
|
||||
raise self.DoesNotExistError(self)
|
||||
|
||||
assert self.hash_info
|
||||
if self.use_cache:
|
||||
self.cache.save(self.path_info, self.cache.tree, self.hash_info)
|
||||
@@ -293,6 +303,9 @@ class BaseOutput:
|
||||
if self.IS_DEPENDENCY:
|
||||
return ret
|
||||
|
||||
if self.desc:
|
||||
ret[self.PARAM_DESC] = self.desc
|
||||
|
||||
if not self.use_cache:
|
||||
ret[self.PARAM_CACHE] = self.use_cache
|
||||
|
||||
@@ -312,6 +325,9 @@ class BaseOutput:
|
||||
if self.persist:
|
||||
ret[self.PARAM_PERSIST] = self.persist
|
||||
|
||||
if self.checkpoint:
|
||||
ret[self.PARAM_CHECKPOINT] = self.checkpoint
|
||||
|
||||
return ret
|
||||
|
||||
def verify_metric(self):
|
||||
@@ -326,7 +342,8 @@ class BaseOutput:
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
allow_persist_missing=False,
|
||||
allow_missing=False,
|
||||
**kwargs,
|
||||
):
|
||||
if not self.use_cache:
|
||||
if progress_callback:
|
||||
@@ -343,9 +360,10 @@ class BaseOutput:
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
**kwargs,
|
||||
)
|
||||
except CheckoutError:
|
||||
if self.persist and allow_persist_missing:
|
||||
if allow_missing or self.checkpoint:
|
||||
return None
|
||||
raise
|
||||
|
||||
@@ -443,9 +461,7 @@ class BaseOutput:
|
||||
path = str(self.path_info)
|
||||
filter_path = str(filter_info) if filter_info else None
|
||||
is_win = os.name == "nt"
|
||||
for entry in self.dir_cache:
|
||||
checksum = entry[self.tree.PARAM_CHECKSUM]
|
||||
entry_relpath = entry[self.tree.PARAM_RELPATH]
|
||||
for entry_relpath, entry_hash_info in self.dir_cache.items():
|
||||
if is_win:
|
||||
entry_relpath = entry_relpath.replace("/", os.sep)
|
||||
entry_path = os.path.join(path, entry_relpath)
|
||||
@@ -454,7 +470,7 @@ class BaseOutput:
|
||||
or entry_path == filter_path
|
||||
or entry_path.startswith(filter_path + os.sep)
|
||||
):
|
||||
cache.add(self.scheme, checksum, entry_path)
|
||||
cache.add(self.scheme, entry_hash_info.value, entry_path)
|
||||
|
||||
return cache
|
||||
|
||||
@@ -525,8 +541,15 @@ class BaseOutput:
|
||||
my = self.dumpd()
|
||||
other = out.dumpd()
|
||||
|
||||
my.pop(self.tree.PARAM_CHECKSUM)
|
||||
other.pop(self.tree.PARAM_CHECKSUM)
|
||||
ignored = [
|
||||
self.tree.PARAM_CHECKSUM,
|
||||
HashInfo.PARAM_SIZE,
|
||||
HashInfo.PARAM_NFILES,
|
||||
]
|
||||
|
||||
for opt in ignored:
|
||||
my.pop(opt, None)
|
||||
other.pop(opt, None)
|
||||
|
||||
if my != other:
|
||||
raise MergeError(
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
from dvc.output.s3 import S3Output
|
||||
from dvc.output.base import BaseOutput
|
||||
|
||||
from ..tree.gs import GSTree
|
||||
|
||||
|
||||
class GSOutput(S3Output):
|
||||
class GSOutput(BaseOutput):
|
||||
TREE_CLS = GSTree
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from dvc.output.base import BaseOutput
|
||||
|
||||
from ..tree.webhdfs import WebHDFSTree
|
||||
|
||||
|
||||
class WebHDFSOutput(BaseOutput):
|
||||
TREE_CLS = WebHDFSTree
|
||||
@@ -0,0 +1,321 @@
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from copy import deepcopy
|
||||
from itertools import starmap
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from funcy import join, lfilter
|
||||
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.parsing.interpolate import ParseError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import relpath
|
||||
|
||||
from .context import (
|
||||
Context,
|
||||
ContextError,
|
||||
KeyNotInContext,
|
||||
MergeError,
|
||||
Meta,
|
||||
Node,
|
||||
SetError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STAGES_KWD = "stages"
|
||||
VARS_KWD = "vars"
|
||||
WDIR_KWD = "wdir"
|
||||
DEFAULT_PARAMS_FILE = ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
PARAMS_KWD = "params"
|
||||
FOREACH_KWD = "foreach"
|
||||
DO_KWD = "do"
|
||||
SET_KWD = "set"
|
||||
|
||||
DEFAULT_SENTINEL = object()
|
||||
|
||||
JOIN = "@"
|
||||
|
||||
|
||||
class VarsAlreadyLoaded(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class ResolveError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def _format_preamble(msg, path, spacing=" "):
|
||||
return f"failed to parse {msg} in '{path}':{spacing}"
|
||||
|
||||
|
||||
def format_and_raise(exc, msg, path):
|
||||
spacing = (
|
||||
"\n"
|
||||
if isinstance(exc, (ParseError, MergeError, VarsAlreadyLoaded))
|
||||
else " "
|
||||
)
|
||||
message = _format_preamble(msg, path, spacing) + str(exc)
|
||||
|
||||
# FIXME: cannot reraise because of how we log "cause" of the exception
|
||||
# the error message is verbose, hence need control over the spacing
|
||||
_reraise_err(ResolveError, message, from_exc=exc)
|
||||
|
||||
|
||||
def _reraise_err(exc_cls, *args, from_exc=None):
|
||||
err = exc_cls(*args)
|
||||
if from_exc and logger.isEnabledFor(logging.DEBUG):
|
||||
raise err from from_exc
|
||||
raise err
|
||||
|
||||
|
||||
class DataResolver:
|
||||
def __init__(self, repo: "Repo", wdir: PathInfo, d: dict):
|
||||
self.data: dict = d
|
||||
self.wdir = wdir
|
||||
self.repo = repo
|
||||
self.tree = self.repo.tree
|
||||
self.imported_files: Dict[str, Optional[List[str]]] = {}
|
||||
self.relpath = relpath(self.wdir / "dvc.yaml")
|
||||
|
||||
to_import: PathInfo = wdir / DEFAULT_PARAMS_FILE
|
||||
if self.tree.exists(to_import):
|
||||
self.imported_files = {os.path.abspath(to_import): None}
|
||||
self.global_ctx = Context.load_from(self.tree, to_import)
|
||||
else:
|
||||
self.global_ctx = Context()
|
||||
logger.debug(
|
||||
"%s does not exist, it won't be used in parametrization",
|
||||
to_import,
|
||||
)
|
||||
|
||||
vars_ = d.get(VARS_KWD, [])
|
||||
try:
|
||||
self.load_from_vars(
|
||||
self.global_ctx, vars_, wdir, skip_imports=self.imported_files
|
||||
)
|
||||
except (ContextError, VarsAlreadyLoaded) as exc:
|
||||
format_and_raise(exc, "'vars'", self.relpath)
|
||||
|
||||
self.tracked_vars = {}
|
||||
|
||||
@staticmethod
|
||||
def check_loaded(path, item, keys, skip_imports):
|
||||
if not keys and isinstance(skip_imports[path], list):
|
||||
raise VarsAlreadyLoaded(
|
||||
f"cannot load '{item}' as it's partially loaded already"
|
||||
)
|
||||
elif keys and skip_imports[path] is None:
|
||||
raise VarsAlreadyLoaded(
|
||||
f"cannot partially load '{item}' as it's already loaded."
|
||||
)
|
||||
elif keys and isinstance(skip_imports[path], list):
|
||||
if not set(keys).isdisjoint(set(skip_imports[path])):
|
||||
raise VarsAlreadyLoaded(
|
||||
f"cannot load '{item}' as it's partially loaded already"
|
||||
)
|
||||
|
||||
def load_from_vars(
|
||||
self,
|
||||
context: "Context",
|
||||
vars_: List,
|
||||
wdir: PathInfo,
|
||||
skip_imports: Dict[str, Optional[List[str]]],
|
||||
stage_name: str = None,
|
||||
):
|
||||
stage_name = stage_name or ""
|
||||
for index, item in enumerate(vars_):
|
||||
assert isinstance(item, (str, dict))
|
||||
if isinstance(item, str):
|
||||
path, _, keys_str = item.partition(":")
|
||||
keys = lfilter(bool, keys_str.split(","))
|
||||
|
||||
path_info = wdir / path
|
||||
path = os.path.abspath(path_info)
|
||||
|
||||
if path in skip_imports:
|
||||
if not keys and skip_imports[path] is None:
|
||||
# allow specifying complete filepath multiple times
|
||||
continue
|
||||
self.check_loaded(path, item, keys, skip_imports)
|
||||
|
||||
context.merge_from(self.tree, path_info, select_keys=keys)
|
||||
skip_imports[path] = keys if keys else None
|
||||
else:
|
||||
joiner = "." if stage_name else ""
|
||||
meta = Meta(source=f"{stage_name}{joiner}vars[{index}]")
|
||||
context.merge_update(Context(item, meta=meta))
|
||||
|
||||
def _resolve_entry(self, name: str, definition):
|
||||
context = Context.clone(self.global_ctx)
|
||||
if FOREACH_KWD in definition:
|
||||
assert DO_KWD in definition
|
||||
self.set_context_from(
|
||||
context, definition.get(SET_KWD, {}), source=[name, "set"]
|
||||
)
|
||||
return self._foreach(
|
||||
context, name, definition[FOREACH_KWD], definition[DO_KWD]
|
||||
)
|
||||
|
||||
try:
|
||||
return self._resolve_stage(context, name, definition)
|
||||
except ContextError as exc:
|
||||
format_and_raise(exc, f"stage '{name}'", self.relpath)
|
||||
|
||||
def resolve(self):
|
||||
stages = self.data.get(STAGES_KWD, {})
|
||||
data = join(starmap(self._resolve_entry, stages.items())) or {}
|
||||
logger.trace("Resolved dvc.yaml:\n%s", data)
|
||||
return {STAGES_KWD: data}
|
||||
|
||||
def _resolve_stage(self, context: Context, name: str, definition) -> dict:
|
||||
definition = deepcopy(definition)
|
||||
self.set_context_from(
|
||||
context, definition.pop(SET_KWD, {}), source=[name, "set"]
|
||||
)
|
||||
wdir = self._resolve_wdir(context, name, definition.get(WDIR_KWD))
|
||||
if self.wdir != wdir:
|
||||
logger.debug(
|
||||
"Stage %s has different wdir than dvc.yaml file", name
|
||||
)
|
||||
|
||||
vars_ = definition.pop(VARS_KWD, [])
|
||||
# FIXME: Should `vars` be templatized?
|
||||
try:
|
||||
self.load_from_vars(
|
||||
context,
|
||||
vars_,
|
||||
wdir,
|
||||
skip_imports=deepcopy(self.imported_files),
|
||||
stage_name=name,
|
||||
)
|
||||
except VarsAlreadyLoaded as exc:
|
||||
format_and_raise(exc, f"'stages.{name}.vars'", self.relpath)
|
||||
|
||||
logger.trace( # type: ignore[attr-defined]
|
||||
"Context during resolution of stage %s:\n%s", name, context
|
||||
)
|
||||
|
||||
with context.track():
|
||||
resolved = {}
|
||||
for key, value in definition.items():
|
||||
# NOTE: we do not pop "wdir", and resolve it again
|
||||
# this does not affect anything and is done to try to
|
||||
# track the source of `wdir` interpolation.
|
||||
# This works because of the side-effect that we do not
|
||||
# allow overwriting and/or str interpolating complex objects.
|
||||
# Fix if/when those assumptions are no longer valid.
|
||||
try:
|
||||
resolved[key] = context.resolve(value)
|
||||
except (ParseError, KeyNotInContext) as exc:
|
||||
format_and_raise(
|
||||
exc, f"'stages.{name}.{key}'", self.relpath
|
||||
)
|
||||
|
||||
self.tracked_vars[name] = self._get_vars(context)
|
||||
return {name: resolved}
|
||||
|
||||
def _get_vars(self, context: Context):
|
||||
tracked = defaultdict(dict)
|
||||
for path, vars_ in context.tracked.items():
|
||||
for var in vars_:
|
||||
tracked[path][var] = context.select(var, unwrap=True)
|
||||
return tracked
|
||||
|
||||
def _resolve_wdir(
|
||||
self, context: Context, name: str, wdir: str = None
|
||||
) -> PathInfo:
|
||||
if not wdir:
|
||||
return self.wdir
|
||||
|
||||
try:
|
||||
wdir = str(context.resolve_str(wdir, unwrap=True))
|
||||
except (ContextError, ParseError) as exc:
|
||||
format_and_raise(exc, f"'stages.{name}.wdir'", self.relpath)
|
||||
return self.wdir / str(wdir)
|
||||
|
||||
def _foreach(self, context: Context, name: str, foreach_data, do_data):
|
||||
iterable = self._resolve_foreach_data(context, name, foreach_data)
|
||||
args = (context, name, do_data, iterable)
|
||||
it = (
|
||||
range(len(iterable))
|
||||
if not isinstance(iterable, Mapping)
|
||||
else iterable
|
||||
)
|
||||
gen = (self._each_iter(*args, i) for i in it)
|
||||
return join(gen)
|
||||
|
||||
def _each_iter(self, context: Context, name: str, do_data, iterable, key):
|
||||
value = iterable[key]
|
||||
c = Context.clone(context)
|
||||
suffix = c["item"] = value
|
||||
if isinstance(iterable, Mapping):
|
||||
suffix = c["key"] = key
|
||||
|
||||
generated = f"{name}{JOIN}{suffix}"
|
||||
try:
|
||||
return self._resolve_stage(c, generated, do_data)
|
||||
except ContextError as exc:
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, MergeError) and exc.key in self._inserted_keys(
|
||||
iterable
|
||||
):
|
||||
raise ResolveError(
|
||||
f"attempted to redefine '{exc.key}' in stage '{generated}'"
|
||||
" generated through 'foreach'"
|
||||
)
|
||||
format_and_raise(
|
||||
exc, f"stage '{generated}' (gen. from '{name}')", self.relpath
|
||||
)
|
||||
|
||||
def _resolve_foreach_data(
|
||||
self, context: "Context", name: str, foreach_data
|
||||
):
|
||||
try:
|
||||
iterable = context.resolve(foreach_data, unwrap=False)
|
||||
except (ContextError, ParseError) as exc:
|
||||
format_and_raise(exc, f"'stages.{name}.foreach'", self.relpath)
|
||||
if isinstance(iterable, str) or not isinstance(
|
||||
iterable, (Sequence, Mapping)
|
||||
):
|
||||
raise ResolveError(
|
||||
f"failed to resolve 'stages.{name}.foreach'"
|
||||
f" in '{self.relpath}': expected list/dictionary, got "
|
||||
+ type(
|
||||
iterable.value if isinstance(iterable, Node) else iterable
|
||||
).__name__
|
||||
)
|
||||
|
||||
warn_for = [k for k in self._inserted_keys(iterable) if k in context]
|
||||
if warn_for:
|
||||
logger.warning(
|
||||
"%s %s already specified, "
|
||||
"will be overwritten for stages generated from '%s'",
|
||||
" and ".join(warn_for),
|
||||
"is" if len(warn_for) == 1 else "are",
|
||||
name,
|
||||
)
|
||||
|
||||
return iterable
|
||||
|
||||
@staticmethod
|
||||
def _inserted_keys(iterable):
|
||||
keys = ["item"]
|
||||
if isinstance(iterable, Mapping):
|
||||
keys.append("key")
|
||||
return keys
|
||||
|
||||
@classmethod
|
||||
def set_context_from(cls, context: Context, to_set, source=None):
|
||||
try:
|
||||
for key, value in to_set.items():
|
||||
src_set = [*(source or []), key]
|
||||
context.set(key, value, source=".".join(src_set))
|
||||
except SetError as exc:
|
||||
_reraise_err(ResolveError, str(exc), from_exc=exc)
|
||||
@@ -0,0 +1,451 @@
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from funcy import identity, rpartial
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.parsing.interpolate import (
|
||||
ParseError,
|
||||
get_expression,
|
||||
get_matches,
|
||||
is_exact_string,
|
||||
is_interpolated_string,
|
||||
str_interpolate,
|
||||
)
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.serialize import LOADERS
|
||||
|
||||
SeqOrMap = Union[Sequence, Mapping]
|
||||
|
||||
|
||||
class ContextError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class SetError(ContextError):
|
||||
pass
|
||||
|
||||
|
||||
class MergeError(ContextError):
|
||||
def __init__(self, key, new, into):
|
||||
self.key = key
|
||||
if not isinstance(into[key], Node) or not isinstance(new, Node):
|
||||
super().__init__(
|
||||
f"cannot merge '{key}' as it already exists in {into}"
|
||||
)
|
||||
return
|
||||
|
||||
preexisting = into[key].meta.source
|
||||
new_src = new.meta.source
|
||||
path = new.meta.path()
|
||||
super().__init__(
|
||||
f"cannot redefine '{path}' from '{new_src}'"
|
||||
f" as it already exists in '{preexisting}'"
|
||||
)
|
||||
|
||||
|
||||
class ParamsFileNotFound(ContextError):
|
||||
pass
|
||||
|
||||
|
||||
class KeyNotInContext(ContextError):
|
||||
def __init__(self, key) -> None:
|
||||
self.key = key
|
||||
super().__init__(f"Could not find '{key}'")
|
||||
|
||||
|
||||
def _merge(into, update, overwrite):
|
||||
for key, val in update.items():
|
||||
if isinstance(into.get(key), Mapping) and isinstance(val, Mapping):
|
||||
_merge(into[key], val, overwrite)
|
||||
else:
|
||||
if key in into and not overwrite:
|
||||
raise MergeError(key, val, into)
|
||||
into[key] = val
|
||||
assert isinstance(into[key], Node)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meta:
|
||||
source: Optional[str] = None
|
||||
dpaths: List[str] = field(default_factory=list)
|
||||
local: bool = True
|
||||
|
||||
@staticmethod
|
||||
def update_path(meta: "Meta", path: Union[str, int]):
|
||||
dpaths = meta.dpaths[:] + [str(path)]
|
||||
return replace(meta, dpaths=dpaths)
|
||||
|
||||
def __str__(self):
|
||||
string = self.source or "<local>"
|
||||
string += ":" + self.path()
|
||||
return string
|
||||
|
||||
def path(self):
|
||||
return ".".join(self.dpaths)
|
||||
|
||||
|
||||
def _default_meta():
|
||||
return Meta(source=None)
|
||||
|
||||
|
||||
class Node:
|
||||
def get_sources(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def value(self):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Value(Node):
|
||||
_value: Any
|
||||
meta: Meta = field(
|
||||
compare=False, default_factory=_default_meta, repr=False
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self._value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self._value)
|
||||
|
||||
def get_sources(self):
|
||||
return {self.meta.source: self.meta.path()}
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
|
||||
PRIMITIVES = (int, float, str, bytes, bool)
|
||||
|
||||
|
||||
class Container(Node, ABC):
|
||||
meta: Meta
|
||||
data: Union[list, dict]
|
||||
_key_transform = staticmethod(identity)
|
||||
|
||||
def __init__(self, meta=None) -> None:
|
||||
self.meta = meta or _default_meta()
|
||||
|
||||
def _convert(self, key, value):
|
||||
meta = Meta.update_path(self.meta, key)
|
||||
return self._convert_with_meta(value, meta)
|
||||
|
||||
@staticmethod
|
||||
def _convert_with_meta(value, meta: Meta = None):
|
||||
if value is None or isinstance(value, PRIMITIVES):
|
||||
assert meta
|
||||
return Value(value, meta=meta)
|
||||
elif isinstance(value, Node):
|
||||
return value
|
||||
elif isinstance(value, (list, dict)):
|
||||
assert meta
|
||||
container = CtxDict if isinstance(value, dict) else CtxList
|
||||
return container(value, meta=meta)
|
||||
else:
|
||||
msg = (
|
||||
"Unsupported value of type "
|
||||
f"'{type(value).__name__}' in '{meta}'"
|
||||
)
|
||||
raise TypeError(msg)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
self.data[key] = self._convert(key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self.data[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.data)
|
||||
|
||||
def __eq__(self, o):
|
||||
container = type(self)
|
||||
if isinstance(o, container):
|
||||
return o.data == self.data
|
||||
return container(o) == self
|
||||
|
||||
def select(self, key: str):
|
||||
index, *rems = key.split(sep=".", maxsplit=1)
|
||||
index = index.strip()
|
||||
index = self._key_transform(index)
|
||||
try:
|
||||
d = self[index]
|
||||
except LookupError as exc:
|
||||
raise ValueError(
|
||||
f"Could not find '{index}' in {self.data}"
|
||||
) from exc
|
||||
|
||||
if not rems:
|
||||
return d
|
||||
|
||||
rem = rems[0]
|
||||
if not isinstance(d, Container):
|
||||
raise ValueError(
|
||||
f"{index} is a primitive value, cannot get '{rem}'"
|
||||
)
|
||||
return d.select(rem)
|
||||
|
||||
def get_sources(self):
|
||||
return {}
|
||||
|
||||
|
||||
class CtxList(Container, MutableSequence):
|
||||
_key_transform = staticmethod(int)
|
||||
|
||||
def __init__(self, values: Sequence, meta: Meta = None):
|
||||
super().__init__(meta=meta)
|
||||
self.data: list = []
|
||||
self.extend(values)
|
||||
|
||||
def insert(self, index: int, value):
|
||||
self.data.insert(index, self._convert(index, value))
|
||||
|
||||
def get_sources(self):
|
||||
return {self.meta.source: self.meta.path()}
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return [node.value for node in self]
|
||||
|
||||
|
||||
class CtxDict(Container, MutableMapping):
|
||||
def __init__(self, mapping: Mapping = None, meta: Meta = None, **kwargs):
|
||||
super().__init__(meta=meta)
|
||||
|
||||
self.data: dict = {}
|
||||
if mapping:
|
||||
self.update(mapping)
|
||||
self.update(kwargs)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if not isinstance(key, str):
|
||||
# limitation for the interpolation
|
||||
# ignore other kinds of keys
|
||||
return
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
def merge_update(self, *args, overwrite=False):
|
||||
for d in args:
|
||||
_merge(self, d, overwrite=overwrite)
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return {key: node.value for key, node in self.items()}
|
||||
|
||||
|
||||
class Context(CtxDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Top level mutable dict, with some helpers to create context and track
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._track = False
|
||||
self._tracked_data = defaultdict(set)
|
||||
|
||||
@contextmanager
|
||||
def track(self):
|
||||
self._track = True
|
||||
yield
|
||||
self._track = False
|
||||
|
||||
def _track_data(self, node):
|
||||
if not self._track:
|
||||
return
|
||||
|
||||
if node.meta and node.meta.local:
|
||||
return
|
||||
|
||||
for source, keys in node.get_sources().items():
|
||||
if not source:
|
||||
continue
|
||||
params_file = self._tracked_data[source]
|
||||
keys = [keys] if isinstance(keys, str) else keys
|
||||
params_file.update(keys)
|
||||
|
||||
@property
|
||||
def tracked(self):
|
||||
return self._tracked_data
|
||||
|
||||
def select(
|
||||
self, key: str, unwrap=False
|
||||
): # pylint: disable=arguments-differ
|
||||
"""Select the item using key, similar to `__getitem__`
|
||||
but can track the usage of the data on interpolation
|
||||
as well and can get from nested data structure by using
|
||||
"." separated key (eg: "key1.key2.key3")
|
||||
|
||||
Args:
|
||||
key: key to select value from
|
||||
unwrap: Convert CtxList/CtxDict/Value items to it's original data
|
||||
Defaults to False. Note that the default is different from
|
||||
`resolve`.
|
||||
"""
|
||||
try:
|
||||
node = super().select(key)
|
||||
except ValueError as exc:
|
||||
raise KeyNotInContext(key) from exc
|
||||
|
||||
self._track_data(node)
|
||||
assert isinstance(node, Node)
|
||||
return node.value if unwrap else node
|
||||
|
||||
@classmethod
|
||||
def load_from(cls, tree, path: PathInfo, select_keys=None) -> "Context":
|
||||
file = relpath(path)
|
||||
if not tree.exists(path):
|
||||
raise ParamsFileNotFound(f"'{file}' does not exist")
|
||||
|
||||
_, ext = os.path.splitext(file)
|
||||
loader = LOADERS[ext]
|
||||
|
||||
data = loader(path, tree=tree)
|
||||
select_keys = select_keys or []
|
||||
if select_keys:
|
||||
try:
|
||||
data = {key: data[key] for key in select_keys}
|
||||
except KeyError as exc:
|
||||
key, *_ = exc.args
|
||||
raise ContextError(
|
||||
f"could not find '{key}' in '{file}'"
|
||||
) from exc
|
||||
|
||||
meta = Meta(source=file, local=False)
|
||||
return cls(data, meta=meta)
|
||||
|
||||
def merge_from(
|
||||
self, tree, path: PathInfo, overwrite=False, select_keys=None,
|
||||
):
|
||||
self.merge_update(
|
||||
Context.load_from(tree, path, select_keys), overwrite=overwrite
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def clone(cls, ctx: "Context") -> "Context":
|
||||
"""Clones given context."""
|
||||
return cls(deepcopy(ctx.data))
|
||||
|
||||
def set(self, key, value, source=None):
|
||||
"""
|
||||
Sets a value, either non-interpolated values to a key,
|
||||
or an interpolated string after resolving it.
|
||||
|
||||
>>> c = Context({"foo": "foo", "bar": [1, 2], "lorem": {"a": "z"}})
|
||||
>>> c.set("foobar", "${bar}")
|
||||
>>> c
|
||||
{'foo': 'foo', 'bar': [1, 2], 'lorem': {'a': 'z'}, 'foobar': [1, 2]}
|
||||
"""
|
||||
try:
|
||||
self._set(key, value, source)
|
||||
except (ParseError, ValueError, ContextError) as exc:
|
||||
sp = "\n" if isinstance(exc, ParseError) else " "
|
||||
raise SetError(f"Failed to set '{source}':{sp}{str(exc)}") from exc
|
||||
|
||||
def _set(self, key, value, source):
|
||||
if key in self:
|
||||
raise ValueError(f"Cannot set '{key}', key already exists")
|
||||
if isinstance(value, str):
|
||||
self._check_joined_with_interpolation(key, value)
|
||||
value = self.resolve_str(value, unwrap=False)
|
||||
elif isinstance(value, (Sequence, Mapping)):
|
||||
self._check_not_nested_collection(key, value)
|
||||
self._check_interpolation_collection(key, value)
|
||||
self[key] = self._convert_with_meta(value, Meta(source=source))
|
||||
|
||||
def resolve(self, src, unwrap=True):
|
||||
"""Recursively resolves interpolation and returns resolved data.
|
||||
|
||||
Args:
|
||||
src: Data (str/list/dict etc.) to resolve
|
||||
unwrap: Unwrap CtxDict/CtxList/Value to it's original data if
|
||||
inside `src`. Defaults to True.
|
||||
|
||||
>>> c = Context({"three": 3})
|
||||
>>> c.resolve({"lst": [1, 2, "${three}"]})
|
||||
{'lst': [1, 2, 3]}
|
||||
"""
|
||||
Seq = (list, tuple, set)
|
||||
|
||||
resolve = rpartial(self.resolve, unwrap)
|
||||
if isinstance(src, Mapping):
|
||||
return dict(map(resolve, kv) for kv in src.items())
|
||||
elif isinstance(src, Seq):
|
||||
return type(src)(map(resolve, src))
|
||||
elif isinstance(src, str):
|
||||
return self.resolve_str(src, unwrap=unwrap)
|
||||
return src
|
||||
|
||||
def resolve_str(self, src: str, unwrap=True):
|
||||
"""Resolves interpolated string to it's original value,
|
||||
or in case of multiple interpolations, a combined string.
|
||||
|
||||
>>> c = Context({"enabled": True})
|
||||
>>> c.resolve_str("${enabled}")
|
||||
True
|
||||
>>> c.resolve_str("enabled? ${enabled}")
|
||||
'enabled? True'
|
||||
"""
|
||||
matches = get_matches(src)
|
||||
if is_exact_string(src, matches):
|
||||
# replace "${enabled}", if `enabled` is a boolean, with it's actual
|
||||
# value rather than it's string counterparts.
|
||||
expr = get_expression(matches[0])
|
||||
return self.select(expr, unwrap=unwrap)
|
||||
# but not "${num} days"
|
||||
return str_interpolate(src, matches, self)
|
||||
|
||||
@staticmethod
|
||||
def _check_not_nested_collection(key: str, value: SeqOrMap):
|
||||
values = value.values() if isinstance(value, Mapping) else value
|
||||
has_nested = any(
|
||||
not isinstance(item, str) and isinstance(item, (Mapping, Sequence))
|
||||
for item in values
|
||||
)
|
||||
if has_nested:
|
||||
raise ValueError(f"Cannot set '{key}', has nested dict/list")
|
||||
|
||||
@staticmethod
|
||||
def _check_interpolation_collection(key: str, value: SeqOrMap):
|
||||
values = value.values() if isinstance(value, Mapping) else value
|
||||
interpolated = any(is_interpolated_string(item) for item in values)
|
||||
if interpolated:
|
||||
raise ValueError(
|
||||
f"Cannot set '{key}', "
|
||||
"having interpolation inside "
|
||||
f"'{type(value).__name__}' is not supported."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_joined_with_interpolation(key: str, value: str):
|
||||
matches = get_matches(value)
|
||||
if matches and not is_exact_string(value, matches):
|
||||
raise ValueError(
|
||||
f"Cannot set '{key}', "
|
||||
"joining string with interpolated string "
|
||||
"is not supported"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
|
||||
doctest.testmod()
|
||||
@@ -0,0 +1,126 @@
|
||||
import re
|
||||
import typing
|
||||
from functools import singledispatch
|
||||
|
||||
from pyparsing import (
|
||||
CharsNotIn,
|
||||
ParseException,
|
||||
ParserElement,
|
||||
Suppress,
|
||||
ZeroOrMore,
|
||||
)
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils import colorize
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import List, Match
|
||||
|
||||
from .context import Context
|
||||
|
||||
LBRACK = "["
|
||||
RBRACK = "]"
|
||||
PERIOD = "."
|
||||
KEYCRE = re.compile(
|
||||
r"""
|
||||
(?<!\\) # escape \${} or ${{}}
|
||||
\$ # starts with $
|
||||
(?:({{)|({)) # either starts with double braces or single
|
||||
(.*?) # match every char inside
|
||||
(?(1)}})(?(2)}) # end with same kinds of braces it opened with
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
ParserElement.enablePackrat()
|
||||
|
||||
|
||||
word = CharsNotIn(f"{PERIOD}{LBRACK}{RBRACK}")
|
||||
idx = Suppress(LBRACK) + word + Suppress(RBRACK)
|
||||
attr = Suppress(PERIOD) + word
|
||||
parser = word + ZeroOrMore(attr ^ idx)
|
||||
parser.setParseAction(PERIOD.join)
|
||||
|
||||
|
||||
class ParseError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def get_matches(template: str):
|
||||
return list(KEYCRE.finditer(template))
|
||||
|
||||
|
||||
def is_interpolated_string(val):
|
||||
return bool(get_matches(val)) if isinstance(val, str) else False
|
||||
|
||||
|
||||
def format_and_raise_parse_error(exc):
|
||||
raise ParseError(_format_exc_msg(exc))
|
||||
|
||||
|
||||
@singledispatch
|
||||
def to_str(obj):
|
||||
return str(obj)
|
||||
|
||||
|
||||
@to_str.register(bool)
|
||||
def _(obj: bool):
|
||||
return "true" if obj else "false"
|
||||
|
||||
|
||||
def _format_exc_msg(exc: ParseException):
|
||||
exc.loc += 2 # 2 because we append `${` at the start of expr below
|
||||
|
||||
expr = exc.pstr
|
||||
exc.pstr = "${" + exc.pstr + "}"
|
||||
error = ParseException.explain(exc, depth=0)
|
||||
|
||||
_, pointer, *explains = error.splitlines()
|
||||
pstr = "{brace_open}{expr}{brace_close}".format(
|
||||
brace_open=colorize("${", color="blue"),
|
||||
expr=colorize(expr, color="magenta"),
|
||||
brace_close=colorize("}", color="blue"),
|
||||
)
|
||||
msg = "\n".join(explains)
|
||||
pointer = colorize(pointer, color="red")
|
||||
return "\n".join([pstr, pointer, colorize(msg, color="red", style="bold")])
|
||||
|
||||
|
||||
def parse_expr(s: str):
|
||||
try:
|
||||
result = parser.parseString(s, parseAll=True)
|
||||
except ParseException as exc:
|
||||
format_and_raise_parse_error(exc)
|
||||
|
||||
joined = result.asList()
|
||||
assert len(joined) == 1
|
||||
return joined[0]
|
||||
|
||||
|
||||
def get_expression(match: "Match"):
|
||||
_, _, inner = match.groups()
|
||||
return parse_expr(inner)
|
||||
|
||||
|
||||
def str_interpolate(template: str, matches: "List[Match]", context: "Context"):
|
||||
from .context import PRIMITIVES
|
||||
|
||||
index, buf = 0, ""
|
||||
for match in matches:
|
||||
start, end = match.span(0)
|
||||
expr = get_expression(match)
|
||||
value = context.select(expr, unwrap=True)
|
||||
if value is not None and not isinstance(value, PRIMITIVES):
|
||||
raise ParseError(
|
||||
f"Cannot interpolate data of type '{type(value).__name__}'"
|
||||
)
|
||||
buf += template[index:start] + to_str(value)
|
||||
index = end
|
||||
buf += template[index:]
|
||||
# regex already backtracks and avoids any `${` starting with
|
||||
# backslashes(`\`). We just need to replace those by `${`.
|
||||
return buf.replace(r"\${", "${")
|
||||
|
||||
|
||||
def is_exact_string(src: str, matches: "List[Match]"):
|
||||
return len(matches) == 1 and src == matches[0].group(0)
|
||||
+13
-2
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import pathlib
|
||||
import posixpath
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from funcy import cached_property
|
||||
@@ -75,6 +76,17 @@ class PathInfo(pathlib.PurePath, _BasePath):
|
||||
n = len(other._cparts)
|
||||
return len(self._cparts) > n and self._cparts[:n] == other._cparts
|
||||
|
||||
def relative_to(self, other): # pylint: disable=arguments-differ
|
||||
# pathlib relative_to raises exception when one path is not a direct
|
||||
# descendant of the other when os.path.relpath would return abspath.
|
||||
# For DVC PathInfo we only need the relpath behavior.
|
||||
# See: https://bugs.python.org/issue40358
|
||||
try:
|
||||
path = super().relative_to(other)
|
||||
except ValueError:
|
||||
path = relpath(self, other)
|
||||
return self.__class__(path)
|
||||
|
||||
|
||||
class WindowsPathInfo(PathInfo, pathlib.PureWindowsPath):
|
||||
pass
|
||||
@@ -113,7 +125,6 @@ class URLInfo(_BasePath):
|
||||
p = urlparse(url)
|
||||
assert not p.query and not p.params and not p.fragment
|
||||
assert p.password is None
|
||||
|
||||
self._fill_parts(p.scheme, p.hostname, p.username, p.port, p.path)
|
||||
|
||||
@classmethod
|
||||
@@ -243,7 +254,7 @@ class CloudURLInfo(URLInfo):
|
||||
|
||||
|
||||
class HTTPURLInfo(URLInfo):
|
||||
__hash__ = URLInfo.__hash__
|
||||
__hash__: Callable[["HTTPURLInfo"], int] = URLInfo.__hash__
|
||||
|
||||
def __init__(self, url):
|
||||
p = urlparse(url)
|
||||
|
||||
+6
-3
@@ -31,9 +31,12 @@ class Tqdm(tqdm):
|
||||
BAR_FMT_NOTOTAL = (
|
||||
"{desc}{bar:b}|{postfix[info]}{n_fmt} [{elapsed}, {rate_fmt:>11}]"
|
||||
)
|
||||
BYTES_DEFAULTS = dict(
|
||||
unit="B", unit_scale=True, unit_divisor=1024, miniters=1
|
||||
)
|
||||
BYTES_DEFAULTS = {
|
||||
"unit": "B",
|
||||
"unit_scale": True,
|
||||
"unit_divisor": 1024,
|
||||
"miniters": 1,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from ..tree import get_cloud_tree
|
||||
from .base import Remote
|
||||
from .local import LocalRemote
|
||||
from .ssh import SSHRemote
|
||||
|
||||
|
||||
def get_remote(repo, **kwargs):
|
||||
tree = get_cloud_tree(repo, **kwargs)
|
||||
if tree.scheme == "local":
|
||||
return LocalRemote(tree)
|
||||
if tree.scheme == "ssh":
|
||||
return SSHRemote(tree)
|
||||
return Remote(tree)
|
||||
|
||||
+414
-101
@@ -1,19 +1,59 @@
|
||||
import errno
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import wraps
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from functools import partial, wraps
|
||||
|
||||
from funcy import concat
|
||||
|
||||
from dvc.exceptions import DownloadError, UploadError
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
from ..progress import Tqdm
|
||||
from .index import RemoteIndex, RemoteIndexNoop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_OK = 1
|
||||
STATUS_MISSING = 2
|
||||
STATUS_NEW = 3
|
||||
STATUS_DELETED = 4
|
||||
|
||||
STATUS_MAP = {
|
||||
# (local_exists, remote_exists)
|
||||
(True, True): STATUS_OK,
|
||||
(False, False): STATUS_MISSING,
|
||||
(True, False): STATUS_NEW,
|
||||
(False, True): STATUS_DELETED,
|
||||
}
|
||||
|
||||
|
||||
def _log_exceptions(func, operation):
|
||||
@wraps(func)
|
||||
def wrapper(from_info, to_info, *args, **kwargs):
|
||||
try:
|
||||
func(from_info, to_info, *args, **kwargs)
|
||||
return 0
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# NOTE: this means we ran out of file descriptors and there is no
|
||||
# reason to try to proceed, as we will hit this error anyways.
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
raise
|
||||
|
||||
logger.exception(
|
||||
"failed to %s '%s' to '%s'", operation, from_info, to_info
|
||||
)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def index_locked(f):
|
||||
@wraps(f)
|
||||
def wrapper(obj, named_cache, remote, *args, **kwargs):
|
||||
if hasattr(remote, "index"):
|
||||
with remote.index:
|
||||
return f(obj, named_cache, remote, *args, **kwargs)
|
||||
return f(obj, named_cache, remote, *args, **kwargs)
|
||||
def wrapper(obj, *args, **kwargs):
|
||||
with obj.index:
|
||||
return f(obj, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -28,8 +68,11 @@ class Remote:
|
||||
INDEX_CLS = RemoteIndex
|
||||
|
||||
def __init__(self, tree):
|
||||
from dvc.cache import get_cloud_cache
|
||||
|
||||
self.tree = tree
|
||||
self.repo = tree.repo
|
||||
self.cache = get_cloud_cache(self.tree)
|
||||
|
||||
config = tree.config
|
||||
url = config.get("url")
|
||||
@@ -47,123 +90,393 @@ class Remote:
|
||||
path_info=self.tree.path_info or "No path",
|
||||
)
|
||||
|
||||
@property
|
||||
def cache(self):
|
||||
return getattr(self.repo.cache, self.tree.scheme)
|
||||
@index_locked
|
||||
def gc(self, *args, **kwargs):
|
||||
removed = self.cache.gc(*args, **kwargs)
|
||||
|
||||
def hashes_exist(self, hashes, jobs=None, name=None):
|
||||
"""Check if the given hashes are stored in the remote.
|
||||
if removed:
|
||||
self.index.clear()
|
||||
|
||||
There are two ways of performing this check:
|
||||
return removed
|
||||
|
||||
- Traverse method: Get a list of all the files in the remote
|
||||
(traversing the cache directory) and compare it with
|
||||
the given hashes. Cache entries will be retrieved in parallel
|
||||
threads according to prefix (i.e. entries starting with, "00...",
|
||||
"01...", and so on) and a progress bar will be displayed.
|
||||
|
||||
- Exists method: For each given hash, run the `exists`
|
||||
method and filter the hashes that aren't on the remote.
|
||||
This is done in parallel threads.
|
||||
It also shows a progress bar when performing the check.
|
||||
|
||||
The reason for such an odd logic is that most of the remotes
|
||||
take much shorter time to just retrieve everything they have under
|
||||
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
|
||||
check if particular file exists much quicker, use their own
|
||||
implementation of hashes_exist (see ssh, local).
|
||||
|
||||
Which method to use will be automatically determined after estimating
|
||||
the size of the remote cache, and comparing the estimated size with
|
||||
len(hashes). To estimate the size of the remote cache, we fetch
|
||||
a small subset of cache entries (i.e. entries starting with "00...").
|
||||
Based on the number of entries in that subset, the size of the full
|
||||
cache can be estimated, since the cache is evenly distributed according
|
||||
to hash.
|
||||
|
||||
Returns:
|
||||
A list with hashes that were found in the remote
|
||||
"""
|
||||
# Remotes which do not use traverse prefix should override
|
||||
# hashes_exist() (see ssh, local)
|
||||
assert self.tree.TRAVERSE_PREFIX_LEN >= 2
|
||||
@index_locked
|
||||
def status(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
# Return flattened dict containing all status info
|
||||
dir_status, file_status, _ = self._status(
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
return dict(dir_status, **file_status)
|
||||
|
||||
def hashes_exist(self, hashes, **kwargs):
|
||||
hashes = set(hashes)
|
||||
indexed_hashes = set(self.index.intersection(hashes))
|
||||
hashes -= indexed_hashes
|
||||
indexed_hashes = list(indexed_hashes)
|
||||
logger.debug("Matched '{}' indexed hashes".format(len(indexed_hashes)))
|
||||
if not hashes:
|
||||
return indexed_hashes
|
||||
|
||||
if len(hashes) == 1 or not self.tree.CAN_TRAVERSE:
|
||||
remote_hashes = self.tree.list_hashes_exists(hashes, jobs, name)
|
||||
return list(indexed_hashes) + remote_hashes
|
||||
return indexed_hashes + self.cache.hashes_exist(list(hashes), **kwargs)
|
||||
|
||||
# Max remote size allowed for us to use traverse method
|
||||
remote_size, remote_hashes = self.tree.estimate_remote_size(
|
||||
hashes, name
|
||||
def _status(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
log_missing=True,
|
||||
):
|
||||
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
|
||||
|
||||
dir_status_info contains status for .dir files, file_status_info
|
||||
contains status for all other files, and dir_contents is a dict of
|
||||
{dir_hash: set(file_hash, ...)} which can be used to map
|
||||
a .dir file to its file contents.
|
||||
"""
|
||||
logger.debug(f"Preparing to collect status from {self.tree.path_info}")
|
||||
md5s = set(named_cache.scheme_keys(cache.tree.scheme))
|
||||
|
||||
logger.debug("Collecting information from local cache...")
|
||||
local_exists = frozenset(
|
||||
cache.hashes_exist(md5s, jobs=jobs, name=cache.cache_dir)
|
||||
)
|
||||
|
||||
traverse_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
|
||||
# For sufficiently large remotes, traverse must be weighted to account
|
||||
# for performance overhead from large lists/sets.
|
||||
# From testing with S3, for remotes with 1M+ files, object_exists is
|
||||
# faster until len(hashes) is at least 10k~100k
|
||||
if remote_size > self.tree.TRAVERSE_THRESHOLD_SIZE:
|
||||
traverse_weight = (
|
||||
traverse_pages * self.tree.TRAVERSE_WEIGHT_MULTIPLIER
|
||||
)
|
||||
# This is a performance optimization. We can safely assume that,
|
||||
# if the resources that we want to fetch are already cached,
|
||||
# there's no need to check the remote storage for the existence of
|
||||
# those files.
|
||||
if download and local_exists == md5s:
|
||||
remote_exists = local_exists
|
||||
else:
|
||||
traverse_weight = traverse_pages
|
||||
if len(hashes) < traverse_weight:
|
||||
logger.debug(
|
||||
"Large remote ('{}' hashes < '{}' traverse weight), "
|
||||
"using object_exists for remaining hashes".format(
|
||||
len(hashes), traverse_weight
|
||||
logger.debug("Collecting information from remote cache...")
|
||||
remote_exists = set()
|
||||
dir_md5s = set(named_cache.dir_keys(cache.tree.scheme))
|
||||
if dir_md5s:
|
||||
remote_exists.update(
|
||||
self._indexed_dir_hashes(cache, named_cache, dir_md5s)
|
||||
)
|
||||
)
|
||||
return (
|
||||
list(indexed_hashes)
|
||||
+ list(hashes & remote_hashes)
|
||||
+ self.tree.list_hashes_exists(
|
||||
hashes - remote_hashes, jobs, name
|
||||
md5s.difference_update(remote_exists)
|
||||
if md5s:
|
||||
remote_exists.update(
|
||||
self.hashes_exist(
|
||||
md5s, jobs=jobs, name=str(self.tree.path_info)
|
||||
)
|
||||
)
|
||||
)
|
||||
return self._make_status(
|
||||
cache,
|
||||
named_cache,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
)
|
||||
|
||||
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
|
||||
remote_hashes = set(
|
||||
self.tree.list_hashes_traverse(
|
||||
remote_size, remote_hashes, jobs, name
|
||||
def _make_status(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
show_checksums,
|
||||
local_exists,
|
||||
remote_exists,
|
||||
log_missing,
|
||||
):
|
||||
def make_names(hash_, names):
|
||||
return {"name": hash_ if show_checksums else " ".join(names)}
|
||||
|
||||
dir_status = {}
|
||||
file_status = {}
|
||||
dir_contents = {}
|
||||
for hash_, item in named_cache[cache.tree.scheme].items():
|
||||
if item.children:
|
||||
dir_status[hash_] = make_names(hash_, item.names)
|
||||
dir_contents[hash_] = set()
|
||||
for child_hash, child in item.children.items():
|
||||
file_status[child_hash] = make_names(
|
||||
child_hash, child.names
|
||||
)
|
||||
dir_contents[hash_].add(child_hash)
|
||||
else:
|
||||
file_status[hash_] = make_names(hash_, item.names)
|
||||
|
||||
self._fill_statuses(dir_status, local_exists, remote_exists)
|
||||
self._fill_statuses(file_status, local_exists, remote_exists)
|
||||
|
||||
if log_missing:
|
||||
self._log_missing_caches(dict(dir_status, **file_status))
|
||||
|
||||
return dir_status, file_status, dir_contents
|
||||
|
||||
def _indexed_dir_hashes(self, cache, named_cache, dir_md5s):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
indexed_dirs = set(self.index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
indexed_dir_exists.update(
|
||||
self.cache.list_hashes_exists(indexed_dirs)
|
||||
)
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '{}', "
|
||||
"clearing remote index".format(", ".join(missing_dirs))
|
||||
)
|
||||
self.index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_md5s.intersection(indexed_dir_exists)
|
||||
dir_exists.update(self.cache.list_hashes_exists(dir_md5s - dir_exists))
|
||||
|
||||
# If .dir hash exists on the remote, assume directory contents
|
||||
# still exists on the remote
|
||||
for dir_hash in dir_exists:
|
||||
file_hashes = list(
|
||||
named_cache.child_keys(cache.tree.scheme, dir_hash)
|
||||
)
|
||||
if dir_hash not in self.index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '{}' with '{}' nested files".format(
|
||||
dir_hash, len(file_hashes)
|
||||
)
|
||||
)
|
||||
self.index.update([dir_hash], file_hashes)
|
||||
yield dir_hash
|
||||
yield from file_hashes
|
||||
|
||||
@staticmethod
|
||||
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
|
||||
# Using sets because they are way faster for lookups
|
||||
local = set(local_exists)
|
||||
remote = set(remote_exists)
|
||||
|
||||
for md5, info in hash_info_dir.items():
|
||||
status = STATUS_MAP[(md5 in local, md5 in remote)]
|
||||
info["status"] = status
|
||||
|
||||
def _get_plans(self, cache_obj, download, status_info, status):
|
||||
cache = []
|
||||
path_infos = []
|
||||
names = []
|
||||
hashes = []
|
||||
missing = []
|
||||
for md5, info in Tqdm(
|
||||
status_info.items(), desc="Analysing status", unit="file"
|
||||
):
|
||||
if info["status"] == status:
|
||||
cache.append(cache_obj.tree.hash_to_path_info(md5))
|
||||
path_infos.append(self.tree.hash_to_path_info(md5))
|
||||
names.append(info["name"])
|
||||
hashes.append(md5)
|
||||
elif info["status"] == STATUS_MISSING:
|
||||
missing.append(md5)
|
||||
|
||||
if download:
|
||||
to_infos = cache
|
||||
from_infos = path_infos
|
||||
else:
|
||||
to_infos = path_infos
|
||||
from_infos = cache
|
||||
|
||||
return (from_infos, to_infos, names, hashes), missing
|
||||
|
||||
def _process(
|
||||
self,
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
):
|
||||
logger.debug(
|
||||
"Preparing to {} '{}'".format(
|
||||
"download data from" if download else "upload data to",
|
||||
self.tree.path_info,
|
||||
)
|
||||
)
|
||||
return list(indexed_hashes) + list(hashes & set(remote_hashes))
|
||||
|
||||
@classmethod
|
||||
if download:
|
||||
func = partial(
|
||||
_log_exceptions(self.tree.download, "download"),
|
||||
dir_mode=cache.tree.dir_mode,
|
||||
file_mode=cache.tree.file_mode,
|
||||
)
|
||||
status = STATUS_DELETED
|
||||
desc = "Downloading"
|
||||
else:
|
||||
func = _log_exceptions(self.tree.upload, "upload")
|
||||
status = STATUS_NEW
|
||||
desc = "Uploading"
|
||||
|
||||
if jobs is None:
|
||||
jobs = self.tree.JOBS
|
||||
|
||||
dir_status, file_status, dir_contents = self._status(
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
)
|
||||
|
||||
dir_plans, _ = self._get_plans(cache, download, dir_status, status)
|
||||
file_plans, missing_files = self._get_plans(
|
||||
cache, download, file_status, status
|
||||
)
|
||||
|
||||
total = len(dir_plans[0]) + len(file_plans[0])
|
||||
if total == 0:
|
||||
return 0
|
||||
|
||||
with Tqdm(total=total, unit="file", desc=desc) as pbar:
|
||||
func = pbar.wrap_fn(func)
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
if download:
|
||||
from_infos, to_infos, names, _ = (
|
||||
d + f for d, f in zip(dir_plans, file_plans)
|
||||
)
|
||||
fails = sum(
|
||||
executor.map(func, from_infos, to_infos, names)
|
||||
)
|
||||
else:
|
||||
# for uploads, push files first, and any .dir files last
|
||||
|
||||
file_futures = {}
|
||||
for from_info, to_info, name, hash_ in zip(*file_plans):
|
||||
file_futures[hash_] = executor.submit(
|
||||
func, from_info, to_info, name
|
||||
)
|
||||
dir_futures = {}
|
||||
for from_info, to_info, name, dir_hash in zip(*dir_plans):
|
||||
# if for some reason a file contained in this dir is
|
||||
# missing both locally and in the remote, we want to
|
||||
# push whatever file content we have, but should not
|
||||
# push .dir file
|
||||
for file_hash in missing_files:
|
||||
if file_hash in dir_contents[dir_hash]:
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
name,
|
||||
)
|
||||
break
|
||||
else:
|
||||
wait_futures = {
|
||||
future
|
||||
for file_hash, future in file_futures.items()
|
||||
if file_hash in dir_contents[dir_hash]
|
||||
}
|
||||
dir_futures[dir_hash] = executor.submit(
|
||||
self._dir_upload,
|
||||
func,
|
||||
wait_futures,
|
||||
from_info,
|
||||
to_info,
|
||||
name,
|
||||
)
|
||||
fails = sum(
|
||||
future.result()
|
||||
for future in concat(
|
||||
file_futures.values(), dir_futures.values()
|
||||
)
|
||||
)
|
||||
|
||||
if fails:
|
||||
if download:
|
||||
self.index.clear()
|
||||
raise DownloadError(fails)
|
||||
raise UploadError(fails)
|
||||
|
||||
if not download:
|
||||
# index successfully pushed dirs
|
||||
for dir_hash, future in dir_futures.items():
|
||||
if future.result() == 0:
|
||||
file_hashes = dir_contents[dir_hash]
|
||||
logger.debug(
|
||||
"Indexing pushed dir '{}' with "
|
||||
"'{}' nested files".format(dir_hash, len(file_hashes))
|
||||
)
|
||||
self.index.update([dir_hash], file_hashes)
|
||||
|
||||
return len(dir_plans[0]) + len(file_plans[0])
|
||||
|
||||
@staticmethod
|
||||
def _dir_upload(func, futures, from_info, to_info, name):
|
||||
for future in as_completed(futures):
|
||||
if future.result():
|
||||
# do not upload this .dir file if any file in this
|
||||
# directory failed to upload
|
||||
logger.debug(
|
||||
"failed to upload full contents of '{}', "
|
||||
"aborting .dir file upload".format(name)
|
||||
)
|
||||
logger.error(f"failed to upload '{from_info}' to '{to_info}'")
|
||||
return 1
|
||||
return func(from_info, to_info, name)
|
||||
|
||||
@index_locked
|
||||
def gc(cls, named_cache, remote, jobs=None):
|
||||
tree = remote.tree
|
||||
used = set(named_cache.scheme_keys("local"))
|
||||
def push(self, cache, named_cache, jobs=None, show_checksums=False):
|
||||
return self._process(
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=False,
|
||||
)
|
||||
|
||||
if tree.scheme != "":
|
||||
used.update(named_cache.scheme_keys(tree.scheme))
|
||||
@index_locked
|
||||
def pull(self, cache, named_cache, jobs=None, show_checksums=False):
|
||||
ret = self._process(
|
||||
cache,
|
||||
named_cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=True,
|
||||
)
|
||||
|
||||
removed = False
|
||||
# hashes must be sorted to ensure we always remove .dir files first
|
||||
for hash_ in sorted(
|
||||
tree.all(jobs, str(tree.path_info)),
|
||||
key=tree.is_dir_hash,
|
||||
reverse=True,
|
||||
):
|
||||
if hash_ in used:
|
||||
continue
|
||||
path_info = tree.hash_to_path_info(hash_)
|
||||
if tree.is_dir_hash(hash_):
|
||||
# backward compatibility
|
||||
# pylint: disable=protected-access
|
||||
tree._remove_unpacked_dir(hash_)
|
||||
tree.remove(path_info)
|
||||
removed = True
|
||||
if not self.tree.verify:
|
||||
with cache.tree.state:
|
||||
for checksum in named_cache.scheme_keys("local"):
|
||||
cache_file = cache.tree.hash_to_path_info(checksum)
|
||||
if cache.tree.exists(cache_file):
|
||||
# We can safely save here, as existing corrupted files
|
||||
# will be removed upon status, while files corrupted
|
||||
# during download will not be moved from tmp_file
|
||||
# (see `BaseTree.download()`)
|
||||
hash_info = HashInfo(
|
||||
cache.tree.PARAM_CHECKSUM, checksum
|
||||
)
|
||||
cache.tree.state.save(cache_file, hash_info)
|
||||
|
||||
if removed and hasattr(remote, "index"):
|
||||
remote.index.clear()
|
||||
return removed
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def _log_missing_caches(hash_info_dict):
|
||||
missing_caches = [
|
||||
(md5, info)
|
||||
for md5, info in hash_info_dict.items()
|
||||
if info["status"] == STATUS_MISSING
|
||||
]
|
||||
if missing_caches:
|
||||
missing_desc = "\n".join(
|
||||
"name: {}, md5: {}".format(info["name"], md5)
|
||||
for md5, info in missing_caches
|
||||
)
|
||||
msg = (
|
||||
"Some of the cache files do not exist neither locally "
|
||||
"nor on remote. Missing cache files:\n{}".format(missing_desc)
|
||||
)
|
||||
logger.warning(msg)
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ from .index import RemoteIndexNoop
|
||||
|
||||
|
||||
class LocalRemote(Remote):
|
||||
INDEX_CLS = RemoteIndexNoop
|
||||
INDEX_CLS = RemoteIndexNoop # type: ignore[assignment]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# type: ignore
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
+91
-258
@@ -2,34 +2,37 @@ import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from funcy import cached_property, cat, first
|
||||
from funcy import cached_property, cat
|
||||
from git import InvalidGitRepositoryError
|
||||
|
||||
from dvc.config import Config
|
||||
from dvc.dvcfile import PIPELINE_FILE, Dvcfile, is_valid_filename
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.exceptions import FileMissingError
|
||||
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
|
||||
from dvc.exceptions import (
|
||||
NoOutputOrStageError,
|
||||
NotDvcRepoError,
|
||||
OutputNotFoundError,
|
||||
)
|
||||
from dvc.exceptions import NotDvcRepoError, OutputNotFoundError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.scm import Base
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.utils.fs import path_isin
|
||||
|
||||
from ..stage.exceptions import StageFileDoesNotExistError, StageNotFound
|
||||
from ..utils import parse_target
|
||||
from .graph import check_acyclic, get_pipeline, get_pipelines
|
||||
from .graph import build_graph, build_outs_graph, get_pipelines
|
||||
from .trie import build_outs_trie
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.tree.base import BaseTree
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def lock_repo(repo):
|
||||
def lock_repo(repo: "Repo"):
|
||||
# pylint: disable=protected-access
|
||||
depth = getattr(repo, "_lock_depth", 0)
|
||||
repo._lock_depth = depth + 1
|
||||
depth = repo._lock_depth
|
||||
repo._lock_depth += 1
|
||||
|
||||
try:
|
||||
if depth > 0:
|
||||
@@ -65,12 +68,12 @@ class Repo:
|
||||
from dvc.repo.fetch import fetch
|
||||
from dvc.repo.freeze import freeze, unfreeze
|
||||
from dvc.repo.gc import gc
|
||||
from dvc.repo.get import get
|
||||
from dvc.repo.get_url import get_url
|
||||
from dvc.repo.get import get as _get
|
||||
from dvc.repo.get_url import get_url as _get_url
|
||||
from dvc.repo.imp import imp
|
||||
from dvc.repo.imp_url import imp_url
|
||||
from dvc.repo.install import install
|
||||
from dvc.repo.ls import ls
|
||||
from dvc.repo.ls import ls as _ls
|
||||
from dvc.repo.move import move
|
||||
from dvc.repo.pull import pull
|
||||
from dvc.repo.push import push
|
||||
@@ -80,6 +83,45 @@ class Repo:
|
||||
from dvc.repo.status import status
|
||||
from dvc.repo.update import update
|
||||
|
||||
ls = staticmethod(_ls)
|
||||
get = staticmethod(_get)
|
||||
get_url = staticmethod(_get_url)
|
||||
|
||||
def _get_repo_dirs(
|
||||
self,
|
||||
root_dir: str = None,
|
||||
scm: Base = None,
|
||||
rev: str = None,
|
||||
uninitialized: bool = False,
|
||||
):
|
||||
assert bool(scm) == bool(rev)
|
||||
|
||||
from dvc.scm import SCM
|
||||
from dvc.scm.git import Git
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
dvc_dir = None
|
||||
tmp_dir = None
|
||||
try:
|
||||
tree = scm.get_tree(rev) if isinstance(scm, Git) and rev else None
|
||||
root_dir = self.find_root(root_dir, tree)
|
||||
dvc_dir = os.path.join(root_dir, self.DVC_DIR)
|
||||
tmp_dir = os.path.join(dvc_dir, "tmp")
|
||||
makedirs(tmp_dir, exist_ok=True)
|
||||
except NotDvcRepoError:
|
||||
if not uninitialized:
|
||||
raise
|
||||
|
||||
try:
|
||||
scm = SCM(root_dir or os.curdir)
|
||||
except (SCMError, InvalidGitRepositoryError):
|
||||
scm = SCM(os.curdir, no_scm=True)
|
||||
|
||||
assert isinstance(scm, Base)
|
||||
root_dir = scm.root_dir
|
||||
|
||||
return root_dir, dvc_dir, tmp_dir
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_dir=None,
|
||||
@@ -95,40 +137,30 @@ class Repo:
|
||||
from dvc.repo.metrics import Metrics
|
||||
from dvc.repo.params import Params
|
||||
from dvc.repo.plots import Plots
|
||||
from dvc.scm import SCM
|
||||
from dvc.repo.stage import StageLoad
|
||||
from dvc.stage.cache import StageCache
|
||||
from dvc.state import State, StateNoop
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
try:
|
||||
tree = scm.get_tree(rev) if rev else None
|
||||
self.root_dir = self.find_root(root_dir, tree)
|
||||
self.dvc_dir = os.path.join(self.root_dir, self.DVC_DIR)
|
||||
self.tmp_dir = os.path.join(self.dvc_dir, "tmp")
|
||||
makedirs(self.tmp_dir, exist_ok=True)
|
||||
except NotDvcRepoError:
|
||||
if not uninitialized:
|
||||
raise
|
||||
self.root_dir = SCM(root_dir or os.curdir).root_dir
|
||||
self.dvc_dir = None
|
||||
self.tmp_dir = None
|
||||
self.root_dir, self.dvc_dir, self.tmp_dir = self._get_repo_dirs(
|
||||
root_dir=root_dir, scm=scm, rev=rev, uninitialized=uninitialized
|
||||
)
|
||||
|
||||
tree_kwargs = dict(use_dvcignore=True, dvcignore_root=self.root_dir)
|
||||
tree_kwargs = {"use_dvcignore": True, "dvcignore_root": self.root_dir}
|
||||
if scm:
|
||||
self.tree = scm.get_tree(rev, **tree_kwargs)
|
||||
else:
|
||||
self.tree = LocalTree(self, {"url": self.root_dir}, **tree_kwargs)
|
||||
|
||||
self.config = Config(self.dvc_dir, tree=self.tree)
|
||||
no_scm = self.config["core"].get("no_scm", False)
|
||||
self.scm = scm if scm else SCM(self.root_dir, no_scm=no_scm)
|
||||
self._scm = scm
|
||||
|
||||
# used by RepoTree to determine if it should traverse subrepos
|
||||
self.subrepos = subrepos
|
||||
|
||||
self.cache = Cache(self)
|
||||
self.cloud = DataCloud(self)
|
||||
self.stage = StageLoad(self)
|
||||
|
||||
if scm or not self.dvc_dir:
|
||||
self.lock = LockNoop()
|
||||
@@ -144,7 +176,7 @@ class Repo:
|
||||
# NOTE: storing state and link_state in the repository itself to
|
||||
# avoid any possible state corruption in 'shared cache dir'
|
||||
# scenario.
|
||||
self.state = State(self.cache.local)
|
||||
self.state = State(self)
|
||||
self.stage_cache = StageCache(self)
|
||||
|
||||
try:
|
||||
@@ -157,13 +189,21 @@ class Repo:
|
||||
self.metrics = Metrics(self)
|
||||
self.plots = Plots(self)
|
||||
self.params = Params(self)
|
||||
self._lock_depth = 0
|
||||
|
||||
@cached_property
|
||||
def scm(self):
|
||||
from dvc.scm import SCM
|
||||
|
||||
no_scm = self.config["core"].get("no_scm", False)
|
||||
return self._scm if self._scm else SCM(self.root_dir, no_scm=no_scm)
|
||||
|
||||
@property
|
||||
def tree(self):
|
||||
def tree(self) -> "BaseTree":
|
||||
return self._tree
|
||||
|
||||
@tree.setter
|
||||
def tree(self, tree):
|
||||
def tree(self, tree: "BaseTree"):
|
||||
self._tree = tree
|
||||
# Our graph cache is no longer valid, as it was based on the previous
|
||||
# tree.
|
||||
@@ -173,7 +213,7 @@ class Repo:
|
||||
return f"{self.__class__.__name__}: '{self.root_dir}'"
|
||||
|
||||
@classmethod
|
||||
def find_root(cls, root=None, tree=None):
|
||||
def find_root(cls, root=None, tree=None) -> str:
|
||||
root_dir = os.path.realpath(root or os.curdir)
|
||||
|
||||
if tree:
|
||||
@@ -226,25 +266,6 @@ class Repo:
|
||||
|
||||
self.scm.ignore_list(flist)
|
||||
|
||||
def get_stage(self, path=None, name=None):
|
||||
if not path:
|
||||
path = PIPELINE_FILE
|
||||
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
|
||||
|
||||
dvcfile = Dvcfile(self, path)
|
||||
return dvcfile.stages[name]
|
||||
|
||||
def get_stages(self, path=None, name=None):
|
||||
if not path:
|
||||
path = PIPELINE_FILE
|
||||
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
|
||||
|
||||
if name:
|
||||
return [self.get_stage(path, name)]
|
||||
|
||||
dvcfile = Dvcfile(self, path)
|
||||
return list(dvcfile.stages.values())
|
||||
|
||||
def check_modified_graph(self, new_stages):
|
||||
"""Generate graph including the new stage to check for errors"""
|
||||
# Building graph might be costly for the ones with many DVC-files,
|
||||
@@ -260,101 +281,7 @@ class Repo:
|
||||
#
|
||||
# [1] https://github.com/iterative/dvc/issues/2671
|
||||
if not getattr(self, "_skip_graph_checks", False):
|
||||
self._collect_graph(self.stages + new_stages)
|
||||
|
||||
def _collect_inside(self, path, graph):
|
||||
import networkx as nx
|
||||
|
||||
stages = nx.dfs_postorder_nodes(graph)
|
||||
return [stage for stage in stages if path_isin(stage.path, path)]
|
||||
|
||||
def collect(
|
||||
self, target=None, with_deps=False, recursive=False, graph=None
|
||||
):
|
||||
if not target:
|
||||
return list(graph) if graph else self.stages
|
||||
|
||||
if recursive and os.path.isdir(target):
|
||||
return self._collect_inside(
|
||||
os.path.abspath(target), graph or self.graph
|
||||
)
|
||||
|
||||
path, name = parse_target(target)
|
||||
stages = self.get_stages(path, name)
|
||||
if not with_deps:
|
||||
return stages
|
||||
|
||||
res = set()
|
||||
for stage in stages:
|
||||
res.update(self._collect_pipeline(stage, graph=graph))
|
||||
return res
|
||||
|
||||
def _collect_pipeline(self, stage, graph=None):
|
||||
import networkx as nx
|
||||
|
||||
pipeline = get_pipeline(get_pipelines(graph or self.graph), stage)
|
||||
return nx.dfs_postorder_nodes(pipeline, stage)
|
||||
|
||||
def _collect_from_default_dvcfile(self, target):
|
||||
dvcfile = Dvcfile(self, PIPELINE_FILE)
|
||||
if dvcfile.exists():
|
||||
return dvcfile.stages.get(target)
|
||||
|
||||
def collect_granular(
|
||||
self, target=None, with_deps=False, recursive=False, graph=None
|
||||
):
|
||||
"""
|
||||
Priority is in the order of following in case of ambiguity:
|
||||
- .dvc file or .yaml file
|
||||
- dir if recursive and directory exists
|
||||
- stage_name
|
||||
- output file
|
||||
"""
|
||||
if not target:
|
||||
return [(stage, None) for stage in self.stages]
|
||||
|
||||
file, name = parse_target(target)
|
||||
stages = []
|
||||
|
||||
# Optimization: do not collect the graph for a specific target
|
||||
if not file:
|
||||
# parsing is ambiguous when it does not have a colon
|
||||
# or if it's not a dvcfile, as it can be a stage name
|
||||
# in `dvc.yaml` or, an output in a stage.
|
||||
logger.debug(
|
||||
"Checking if stage '%s' is in '%s'", target, PIPELINE_FILE
|
||||
)
|
||||
if not (recursive and os.path.isdir(target)):
|
||||
stage = self._collect_from_default_dvcfile(target)
|
||||
if stage:
|
||||
stages = (
|
||||
self._collect_pipeline(stage) if with_deps else [stage]
|
||||
)
|
||||
elif not with_deps and is_valid_filename(file):
|
||||
stages = self.get_stages(file, name)
|
||||
|
||||
if not stages:
|
||||
if not (recursive and os.path.isdir(target)):
|
||||
try:
|
||||
(out,) = self.find_outs_by_path(target, strict=False)
|
||||
filter_info = PathInfo(os.path.abspath(target))
|
||||
return [(out.stage, filter_info)]
|
||||
except OutputNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
stages = self.collect(target, with_deps, recursive, graph)
|
||||
except StageFileDoesNotExistError as exc:
|
||||
# collect() might try to use `target` as a stage name
|
||||
# and throw error that dvc.yaml does not exist, whereas it
|
||||
# should say that both stage name and file does not exist.
|
||||
if file and is_valid_filename(file):
|
||||
raise
|
||||
raise NoOutputOrStageError(target, exc.file) from exc
|
||||
except StageNotFound as exc:
|
||||
raise NoOutputOrStageError(target, exc.file) from exc
|
||||
|
||||
return [(stage, None) for stage in stages]
|
||||
build_graph(self.stages + new_stages)
|
||||
|
||||
def used_cache(
|
||||
self,
|
||||
@@ -395,7 +322,7 @@ class Repo:
|
||||
targets = targets or [None]
|
||||
|
||||
pairs = cat(
|
||||
self.collect_granular(
|
||||
self.stage.collect_granular(
|
||||
target, recursive=recursive, with_deps=with_deps
|
||||
)
|
||||
for target in targets
|
||||
@@ -419,114 +346,17 @@ class Repo:
|
||||
|
||||
return cache
|
||||
|
||||
def _collect_graph(self, stages):
|
||||
"""Generate a graph by using the given stages on the given directory
|
||||
|
||||
The nodes of the graph are the stage's path relative to the root.
|
||||
|
||||
Edges are created when the output of one stage is used as a
|
||||
dependency in other stage.
|
||||
|
||||
The direction of the edges goes from the stage to its dependency:
|
||||
|
||||
For example, running the following:
|
||||
|
||||
$ dvc run -o A "echo A > A"
|
||||
$ dvc run -d A -o B "echo B > B"
|
||||
$ dvc run -d B -o C "echo C > C"
|
||||
|
||||
Will create the following graph:
|
||||
|
||||
ancestors <--
|
||||
|
|
||||
C.dvc -> B.dvc -> A.dvc
|
||||
| |
|
||||
| --> descendants
|
||||
|
|
||||
------- pipeline ------>
|
||||
|
|
||||
v
|
||||
(weakly connected components)
|
||||
|
||||
Args:
|
||||
stages (list): used to build a graph, if None given, collect stages
|
||||
in the repository.
|
||||
|
||||
Raises:
|
||||
OutputDuplicationError: two outputs with the same path
|
||||
StagePathAsOutputError: stage inside an output directory
|
||||
OverlappingOutputPathsError: output inside output directory
|
||||
CyclicGraphError: resulting graph has cycles
|
||||
"""
|
||||
import networkx as nx
|
||||
from pygtrie import Trie
|
||||
|
||||
from dvc.exceptions import (
|
||||
OutputDuplicationError,
|
||||
OverlappingOutputPathsError,
|
||||
StagePathAsOutputError,
|
||||
)
|
||||
|
||||
G = nx.DiGraph()
|
||||
stages = stages or self.stages
|
||||
outs = Trie() # Use trie to efficiently find overlapping outs and deps
|
||||
|
||||
for stage in filter(bool, stages): # bug? not using it later
|
||||
for out in stage.outs:
|
||||
out_key = out.path_info.parts
|
||||
|
||||
# Check for dup outs
|
||||
if out_key in outs:
|
||||
dup_stages = [stage, outs[out_key].stage]
|
||||
raise OutputDuplicationError(str(out), dup_stages)
|
||||
|
||||
# Check for overlapping outs
|
||||
if outs.has_subtrie(out_key):
|
||||
parent = out
|
||||
overlapping = first(outs.values(prefix=out_key))
|
||||
else:
|
||||
parent = outs.shortest_prefix(out_key).value
|
||||
overlapping = out
|
||||
if parent and overlapping:
|
||||
msg = (
|
||||
"Paths for outs:\n'{}'('{}')\n'{}'('{}')\n"
|
||||
"overlap. To avoid unpredictable behaviour, "
|
||||
"rerun command with non overlapping outs paths."
|
||||
).format(
|
||||
str(parent),
|
||||
parent.stage.addressing,
|
||||
str(overlapping),
|
||||
overlapping.stage.addressing,
|
||||
)
|
||||
raise OverlappingOutputPathsError(parent, overlapping, msg)
|
||||
|
||||
outs[out_key] = out
|
||||
|
||||
for stage in stages:
|
||||
out = outs.shortest_prefix(PathInfo(stage.path).parts).value
|
||||
if out:
|
||||
raise StagePathAsOutputError(stage, str(out))
|
||||
|
||||
# Building graph
|
||||
G.add_nodes_from(stages)
|
||||
for stage in stages:
|
||||
for dep in stage.deps:
|
||||
if dep.path_info is None:
|
||||
continue
|
||||
|
||||
dep_key = dep.path_info.parts
|
||||
overlapping = [n.value for n in outs.prefixes(dep_key)]
|
||||
if outs.has_subtrie(dep_key):
|
||||
overlapping.extend(outs.values(prefix=dep_key))
|
||||
|
||||
G.add_edges_from((stage, out.stage) for out in overlapping)
|
||||
check_acyclic(G)
|
||||
|
||||
return G
|
||||
@cached_property
|
||||
def outs_trie(self):
|
||||
return build_outs_trie(self.stages)
|
||||
|
||||
@cached_property
|
||||
def graph(self):
|
||||
return self._collect_graph(self.stages)
|
||||
return build_graph(self.stages, self.outs_trie)
|
||||
|
||||
@cached_property
|
||||
def outs_graph(self):
|
||||
return build_outs_graph(self.graph, self.outs_trie)
|
||||
|
||||
@cached_property
|
||||
def pipelines(self):
|
||||
@@ -551,7 +381,8 @@ class Repo:
|
||||
|
||||
for root, dirs, files in self.tree.walk(self.root_dir):
|
||||
for file_name in filter(is_valid_filename, files):
|
||||
new_stages = self.get_stages(os.path.join(root, file_name))
|
||||
file_path = os.path.join(root, file_name)
|
||||
new_stages = self.stage.load_file(file_path)
|
||||
stages.extend(new_stages)
|
||||
outs.update(
|
||||
out.fspath
|
||||
@@ -619,6 +450,8 @@ class Repo:
|
||||
self.scm.close()
|
||||
|
||||
def _reset(self):
|
||||
self.__dict__.pop("outs_trie", None)
|
||||
self.__dict__.pop("outs_graph", None)
|
||||
self.__dict__.pop("graph", None)
|
||||
self.__dict__.pop("stages", None)
|
||||
self.__dict__.pop("pipelines", None)
|
||||
|
||||
+69
-27
@@ -6,6 +6,7 @@ import colorama
|
||||
from dvc.dvcfile import Dvcfile, is_dvc_file
|
||||
|
||||
from ..exceptions import (
|
||||
CacheLinkError,
|
||||
OutputDuplicationError,
|
||||
OverlappingOutputPathsError,
|
||||
RecursiveAddingWhileUsingFilename,
|
||||
@@ -22,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
@locked
|
||||
@scm_context
|
||||
def add(
|
||||
repo, targets, recursive=False, no_commit=False, fname=None, external=False
|
||||
repo, targets, recursive=False, no_commit=False, fname=None, **kwargs,
|
||||
):
|
||||
if recursive and fname:
|
||||
raise RecursiveAddingWhileUsingFilename()
|
||||
@@ -30,6 +31,7 @@ def add(
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
link_failures = []
|
||||
stages_list = []
|
||||
num_targets = len(targets)
|
||||
with Tqdm(total=num_targets, desc="Add", unit="file", leave=True) as pbar:
|
||||
@@ -55,7 +57,7 @@ def add(
|
||||
)
|
||||
|
||||
stages = _create_stages(
|
||||
repo, sub_targets, fname, pbar=pbar, external=external
|
||||
repo, sub_targets, fname, pbar=pbar, **kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -79,33 +81,56 @@ def add(
|
||||
exc.output, set(exc.stages) - set(stages)
|
||||
)
|
||||
|
||||
with Tqdm(
|
||||
total=len(stages),
|
||||
desc="Processing",
|
||||
unit="file",
|
||||
disable=len(stages) == 1,
|
||||
) as pbar_stages:
|
||||
for stage in stages:
|
||||
try:
|
||||
stage.save()
|
||||
except OutputDoesNotExistError:
|
||||
pbar.n -= 1
|
||||
raise
|
||||
|
||||
if not no_commit:
|
||||
stage.commit()
|
||||
|
||||
Dvcfile(repo, stage.path).dump(stage)
|
||||
pbar_stages.update()
|
||||
|
||||
link_failures.extend(
|
||||
_process_stages(repo, stages, no_commit, pbar)
|
||||
)
|
||||
stages_list += stages
|
||||
|
||||
if num_targets == 1: # restore bar format for stats
|
||||
pbar.bar_format = pbar.BAR_FMT_DEFAULT
|
||||
|
||||
if link_failures:
|
||||
msg = (
|
||||
"Some targets could not be linked from cache to workspace.\n{}\n"
|
||||
"To re-link these targets, reconfigure cache types and then run:\n"
|
||||
"\n\tdvc checkout {}"
|
||||
).format(
|
||||
CacheLinkError.SUPPORT_LINK,
|
||||
" ".join([str(stage.relpath) for stage in link_failures]),
|
||||
)
|
||||
logger.warning(msg)
|
||||
|
||||
return stages_list
|
||||
|
||||
|
||||
def _process_stages(repo, stages, no_commit, pbar):
|
||||
link_failures = []
|
||||
|
||||
with Tqdm(
|
||||
total=len(stages),
|
||||
desc="Processing",
|
||||
unit="file",
|
||||
disable=len(stages) == 1,
|
||||
) as pbar_stages:
|
||||
for stage in stages:
|
||||
try:
|
||||
stage.save()
|
||||
except OutputDoesNotExistError:
|
||||
pbar.n -= 1
|
||||
raise
|
||||
|
||||
try:
|
||||
if not no_commit:
|
||||
stage.commit()
|
||||
except CacheLinkError:
|
||||
link_failures.append(stage)
|
||||
|
||||
Dvcfile(repo, stage.path).dump(stage)
|
||||
pbar_stages.update()
|
||||
|
||||
return link_failures
|
||||
|
||||
|
||||
def _find_all_targets(repo, target, recursive):
|
||||
if os.path.isdir(target) and recursive:
|
||||
return [
|
||||
@@ -124,15 +149,27 @@ def _find_all_targets(repo, target, recursive):
|
||||
return [target]
|
||||
|
||||
|
||||
def _create_stages(repo, targets, fname, pbar=None, external=False):
|
||||
from dvc.stage import Stage, create_stage
|
||||
def _create_stages(
|
||||
repo, targets, fname, pbar=None, external=False, glob=False, desc=None,
|
||||
):
|
||||
from glob import iglob
|
||||
|
||||
from dvc.stage import Stage, create_stage, restore_meta
|
||||
|
||||
if glob:
|
||||
expanded_targets = [
|
||||
exp_target
|
||||
for target in targets
|
||||
for exp_target in iglob(target, recursive=True)
|
||||
]
|
||||
else:
|
||||
expanded_targets = targets
|
||||
|
||||
stages = []
|
||||
|
||||
for out in Tqdm(
|
||||
targets,
|
||||
expanded_targets,
|
||||
desc="Creating DVC-files",
|
||||
disable=len(targets) < LARGE_DIR_SIZE,
|
||||
disable=len(expanded_targets) < LARGE_DIR_SIZE,
|
||||
unit="file",
|
||||
):
|
||||
path, wdir, out = resolve_paths(repo, out)
|
||||
@@ -144,8 +181,13 @@ def _create_stages(repo, targets, fname, pbar=None, external=False):
|
||||
outs=[out],
|
||||
external=external,
|
||||
)
|
||||
if stage:
|
||||
restore_meta(stage)
|
||||
if stage.can_be_skipped:
|
||||
stage = None
|
||||
else:
|
||||
Dvcfile(repo, stage.path).remove()
|
||||
if desc:
|
||||
stage.outs[0].desc = desc
|
||||
|
||||
repo._reset() # pylint: disable=protected-access
|
||||
|
||||
|
||||
+38
-23
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Set
|
||||
|
||||
from dvc.exceptions import (
|
||||
CheckoutError,
|
||||
@@ -11,6 +12,10 @@ from dvc.utils import relpath
|
||||
|
||||
from . import locked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import Repo
|
||||
from .stage import StageInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -38,6 +43,34 @@ def get_all_files_numbers(pairs):
|
||||
)
|
||||
|
||||
|
||||
def _collect_pairs(
|
||||
self: "Repo", targets, with_deps: bool, recursive: bool
|
||||
) -> Set["StageInfo"]:
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileBadNameError,
|
||||
StageFileDoesNotExistError,
|
||||
)
|
||||
|
||||
pairs: Set["StageInfo"] = set()
|
||||
for target in targets:
|
||||
try:
|
||||
pairs.update(
|
||||
self.stage.collect_granular(
|
||||
target, with_deps=with_deps, recursive=recursive
|
||||
)
|
||||
)
|
||||
except (
|
||||
StageFileDoesNotExistError,
|
||||
StageFileBadNameError,
|
||||
NoOutputOrStageError,
|
||||
) as exc:
|
||||
if not target:
|
||||
raise
|
||||
raise CheckoutErrorSuggestGit(target) from exc
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
@locked
|
||||
def checkout(
|
||||
self,
|
||||
@@ -46,12 +79,9 @@ def checkout(
|
||||
force=False,
|
||||
relink=False,
|
||||
recursive=False,
|
||||
allow_persist_missing=False,
|
||||
allow_missing=False,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileBadNameError,
|
||||
StageFileDoesNotExistError,
|
||||
)
|
||||
|
||||
unused = []
|
||||
stats = {
|
||||
@@ -70,23 +100,7 @@ def checkout(
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
pairs = set()
|
||||
for target in targets:
|
||||
try:
|
||||
pairs.update(
|
||||
self.collect_granular(
|
||||
target, with_deps=with_deps, recursive=recursive
|
||||
)
|
||||
)
|
||||
except (
|
||||
StageFileDoesNotExistError,
|
||||
StageFileBadNameError,
|
||||
NoOutputOrStageError,
|
||||
) as exc:
|
||||
if not target:
|
||||
raise
|
||||
raise CheckoutErrorSuggestGit(target) from exc
|
||||
|
||||
pairs = _collect_pairs(self, targets, with_deps, recursive)
|
||||
total = get_all_files_numbers(pairs)
|
||||
with Tqdm(
|
||||
total=total, unit="file", desc="Checkout", disable=total == 0
|
||||
@@ -97,7 +111,8 @@ def checkout(
|
||||
progress_callback=pbar.update_msg,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
allow_persist_missing=allow_persist_missing,
|
||||
allow_missing=allow_missing,
|
||||
**kwargs,
|
||||
)
|
||||
for key, items in result.items():
|
||||
stats[key].extend(_fspath_dir(path) for path in items)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable, Iterable, Set, Tuple
|
||||
|
||||
from dvc.output.base import BaseOutput
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.types import DvcPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FilterFn = Callable[[BaseOutput], bool]
|
||||
Outputs = Set[BaseOutput]
|
||||
DvcPaths = Set[DvcPath]
|
||||
|
||||
|
||||
def _collect_outs(
|
||||
repo: Repo, output_filter: FilterFn = None, deps: bool = False,
|
||||
) -> Outputs:
|
||||
outs = {
|
||||
out
|
||||
for stage in repo.stages
|
||||
for out in (stage.deps if deps else stage.outs)
|
||||
}
|
||||
return set(filter(output_filter, outs)) if output_filter else outs
|
||||
|
||||
|
||||
def _collect_paths(
|
||||
repo: Repo,
|
||||
targets: Iterable[str],
|
||||
recursive: bool = False,
|
||||
rev: str = None,
|
||||
):
|
||||
path_infos = {PathInfo(os.path.abspath(target)) for target in targets}
|
||||
tree = RepoTree(repo)
|
||||
|
||||
target_infos = set()
|
||||
for path_info in path_infos:
|
||||
|
||||
if recursive and tree.isdir(path_info):
|
||||
target_infos.update(set(tree.walk_files(path_info)))
|
||||
|
||||
if not tree.isfile(path_info):
|
||||
if not recursive:
|
||||
logger.warning(
|
||||
"'%s' was not found at: '%s'.", path_info, rev,
|
||||
)
|
||||
continue
|
||||
target_infos.add(path_info)
|
||||
return target_infos
|
||||
|
||||
|
||||
def _filter_duplicates(
|
||||
outs: Outputs, path_infos: DvcPaths
|
||||
) -> Tuple[Outputs, DvcPaths]:
|
||||
res_outs: Outputs = set()
|
||||
res_infos = set(path_infos)
|
||||
|
||||
for out in outs:
|
||||
if out.path_info in path_infos:
|
||||
res_outs.add(out)
|
||||
res_infos.remove(out.path_info)
|
||||
|
||||
return res_outs, res_infos
|
||||
|
||||
|
||||
def collect(
|
||||
repo: Repo,
|
||||
deps: bool = False,
|
||||
targets: Iterable[str] = None,
|
||||
output_filter: FilterFn = None,
|
||||
rev: str = None,
|
||||
recursive: bool = False,
|
||||
) -> Tuple[Outputs, DvcPaths]:
|
||||
assert targets or output_filter
|
||||
|
||||
outs: Outputs = _collect_outs(repo, output_filter=output_filter, deps=deps)
|
||||
|
||||
if not targets:
|
||||
path_infos: DvcPaths = set()
|
||||
return outs, path_infos
|
||||
|
||||
target_infos = _collect_paths(repo, targets, recursive=recursive, rev=rev)
|
||||
|
||||
return _filter_duplicates(outs, target_infos)
|
||||
+6
-2
@@ -36,8 +36,12 @@ def prompt_to_commit(stage, changes, force=False):
|
||||
|
||||
|
||||
@locked
|
||||
def commit(self, target, with_deps=False, recursive=False, force=False):
|
||||
stages = self.collect(target, with_deps=with_deps, recursive=recursive)
|
||||
def commit(
|
||||
self, target, with_deps=False, recursive=False, force=False,
|
||||
):
|
||||
stages = self.stage.collect(
|
||||
target, with_deps=with_deps, recursive=recursive
|
||||
)
|
||||
for stage in stages:
|
||||
changes = stage.changed_entries()
|
||||
if any(changes):
|
||||
|
||||
+1
-1
@@ -122,5 +122,5 @@ def _filter_missing(repo, paths):
|
||||
metadata = repo_tree.metadata(path)
|
||||
if metadata.is_dvc:
|
||||
out = metadata.outs[0]
|
||||
if out.status()[str(out)] == "not in cache":
|
||||
if out.status().get(str(out)) == "not in cache":
|
||||
yield path
|
||||
|
||||
+382
-501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.executor import BaseExecutor
|
||||
from dvc.repo.scm_context import scm_context
|
||||
from dvc.utils.fs import remove
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApplyError(InvalidArgumentError):
|
||||
def __init__(self, rev):
|
||||
super().__init__(
|
||||
f"'{rev}' does not appear to be an experiment commit."
|
||||
)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def apply(repo, rev, *args, **kwargs):
|
||||
from git.exc import GitCommandError
|
||||
|
||||
from dvc.repo.checkout import checkout as dvc_checkout
|
||||
from dvc.repo.experiments import BaselineMismatchError
|
||||
|
||||
exps = repo.experiments
|
||||
|
||||
try:
|
||||
exps.check_baseline(rev)
|
||||
except BaselineMismatchError as exc:
|
||||
raise ApplyError(rev) from exc
|
||||
|
||||
stash_rev = rev in exps.stash_revs
|
||||
if stash_rev:
|
||||
branch = rev
|
||||
else:
|
||||
branch = exps.get_branch_containing(rev)
|
||||
if not branch:
|
||||
raise ApplyError(rev)
|
||||
|
||||
# Note that we don't use stash_workspace() here since we need finer control
|
||||
# over the merge behavior when we unstash everything
|
||||
if repo.scm.is_dirty(untracked_files=True):
|
||||
logger.debug("Stashing workspace")
|
||||
workspace = repo.scm.stash.push(include_untracked=True)
|
||||
else:
|
||||
workspace = None
|
||||
|
||||
repo.scm.repo.git.merge(branch, squash=True, no_commit=True)
|
||||
|
||||
if workspace:
|
||||
try:
|
||||
repo.scm.stash.apply(workspace)
|
||||
except GitCommandError:
|
||||
# if stash apply returns merge conflicts, prefer experiment
|
||||
# changes over prior stashed changes
|
||||
repo.scm.repo.git.checkout("--ours", "--", ".")
|
||||
repo.scm.stash.drop()
|
||||
repo.scm.repo.git.reset()
|
||||
|
||||
if stash_rev:
|
||||
args_path = os.path.join(repo.tmp_dir, BaseExecutor.PACKED_ARGS_FILE)
|
||||
if os.path.exists(args_path):
|
||||
remove(args_path)
|
||||
|
||||
dvc_checkout(repo, **kwargs)
|
||||
|
||||
logger.info(
|
||||
"Changes for experiment '%s' have been applied to your current "
|
||||
"workspace.",
|
||||
rev,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
from typing import Optional
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
# Experiment refs are stored according baseline git SHA:
|
||||
# refs/exps/01/234abcd.../<exp_name>
|
||||
EXPS_NAMESPACE = "refs/exps"
|
||||
EXPS_STASH = f"{EXPS_NAMESPACE}/stash"
|
||||
EXEC_NAMESPACE = f"{EXPS_NAMESPACE}/exec"
|
||||
EXEC_CHECKPOINT = f"{EXEC_NAMESPACE}/EXEC_CHECKPOINT"
|
||||
EXEC_BRANCH = f"{EXEC_NAMESPACE}/EXEC_BRANCH"
|
||||
EXEC_BASELINE = f"{EXEC_NAMESPACE}/EXEC_BASELINE"
|
||||
EXEC_HEAD = f"{EXEC_NAMESPACE}/EXEC_HEAD"
|
||||
EXEC_MERGE = f"{EXEC_NAMESPACE}/EXEC_MERGE"
|
||||
|
||||
|
||||
class UnchangedExperimentError(DvcException):
|
||||
def __init__(self, rev):
|
||||
super().__init__(f"Experiment unchanged from '{rev[:7]}'.")
|
||||
self.rev = rev
|
||||
|
||||
|
||||
class ExperimentExistsError(DvcException):
|
||||
def __init__(self, name: str):
|
||||
msg = (
|
||||
"Reproduced experiment conflicts with existing experiment "
|
||||
f"'{name}'. To overwrite the existing experiment run:\n\n"
|
||||
"\tdvc exp run -f ...\n\n"
|
||||
"To run this experiment with a different name run:\n\n"
|
||||
f"\tdvc exp run -n <new_name> ...\n"
|
||||
)
|
||||
super().__init__(msg)
|
||||
self.name = name
|
||||
|
||||
|
||||
class CheckpointExistsError(DvcException):
|
||||
def __init__(self, name: str):
|
||||
msg = (
|
||||
"Reproduced checkpoint experiment conflicts with existing "
|
||||
f"experiment '{name}'. To restart (and overwrite) the existing "
|
||||
"experiment run:\n\n"
|
||||
"\tdvc exp run -f ...\n\n"
|
||||
"To resume the existing experiment, run:\n\n"
|
||||
f"\tdvc exp resume {name}\n"
|
||||
)
|
||||
super().__init__(msg)
|
||||
self.name = name
|
||||
|
||||
|
||||
class InvalidExpRefError(DvcException):
|
||||
def __init__(self, ref):
|
||||
super().__init__(f"'{ref}' is not a valid experiment refname.")
|
||||
self.ref = ref
|
||||
|
||||
|
||||
class ExpRefInfo:
|
||||
|
||||
namespace = EXPS_NAMESPACE
|
||||
|
||||
def __init__(
|
||||
self, baseline_sha: Optional[str] = None, name: Optional[str] = None,
|
||||
):
|
||||
self.baseline_sha = baseline_sha
|
||||
self.name = name
|
||||
|
||||
def __str__(self):
|
||||
return "/".join(self.parts)
|
||||
|
||||
@property
|
||||
def parts(self):
|
||||
return (
|
||||
(self.namespace,)
|
||||
+ (
|
||||
(self.baseline_sha[:2], self.baseline_sha[2:])
|
||||
if self.baseline_sha
|
||||
else ()
|
||||
)
|
||||
+ ((self.name,) if self.name else ())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_ref(cls, ref: str):
|
||||
try:
|
||||
parts = ref.split("/")
|
||||
if (
|
||||
len(parts) < 2
|
||||
or len(parts) == 3
|
||||
or len(parts) > 5
|
||||
or "/".join(parts[:2]) != EXPS_NAMESPACE
|
||||
):
|
||||
InvalidExpRefError(ref)
|
||||
except ValueError:
|
||||
raise InvalidExpRefError(ref)
|
||||
baseline_sha = parts[2] + parts[3] if len(parts) >= 4 else None
|
||||
name = parts[4] if len(parts) == 5 else None
|
||||
return cls(baseline_sha, name)
|
||||
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.scm_context import scm_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def branch(repo, exp_rev, branch_name, *args, **kwargs):
|
||||
from dvc.repo.experiments.base import ExpRefInfo, InvalidExpRefError
|
||||
from dvc.scm.base import RevError
|
||||
from dvc.scm.git import Git
|
||||
|
||||
ref_info = None
|
||||
if exp_rev.startswith("refs/"):
|
||||
try:
|
||||
ref_info = ExpRefInfo.from_ref(exp_rev)
|
||||
except InvalidExpRefError:
|
||||
pass
|
||||
elif Git.is_sha(exp_rev):
|
||||
try:
|
||||
rev = repo.scm.resolve_rev(exp_rev)
|
||||
ref = repo.experiments.get_branch_containing(rev)
|
||||
if ref:
|
||||
ref_info = ExpRefInfo.from_ref(ref)
|
||||
except RevError:
|
||||
pass
|
||||
|
||||
if not ref_info:
|
||||
infos = list(repo.experiments.iter_ref_infos_by_name(exp_rev))
|
||||
if len(infos) == 1:
|
||||
ref_info = infos[0]
|
||||
else:
|
||||
current_rev = repo.scm.get_rev()
|
||||
for info in infos:
|
||||
if info.baseline_sha == current_rev:
|
||||
ref_info = info
|
||||
break
|
||||
if not ref_info:
|
||||
msg = [
|
||||
f"Ambiguous experiment name '{exp_rev}' can refer to "
|
||||
"multiple experiments. To create a branch use a full "
|
||||
"experiment ref:",
|
||||
"",
|
||||
]
|
||||
msg.extend([str(info) for info in infos])
|
||||
raise InvalidArgumentError("\n".join(msg))
|
||||
|
||||
if not ref_info:
|
||||
raise InvalidArgumentError(
|
||||
f"'{exp_rev}' does not appear to be a valid experiment."
|
||||
)
|
||||
|
||||
branch_ref = f"refs/heads/{branch_name}"
|
||||
if repo.scm.get_ref(branch_ref):
|
||||
raise InvalidArgumentError(
|
||||
f"Git branch '{branch_name}' already exists."
|
||||
)
|
||||
|
||||
target = repo.scm.get_ref(str(ref_info))
|
||||
repo.scm.set_ref(
|
||||
branch_ref,
|
||||
target,
|
||||
message=f"dvc: Created from experiment '{ref_info.name}'",
|
||||
)
|
||||
fmt = (
|
||||
"Git branch '%s' has been created from experiment '%s'.\n"
|
||||
"To switch to the new branch run:\n\n"
|
||||
"\tgit checkout %s"
|
||||
)
|
||||
logger.info(fmt, branch_name, ref_info.name, branch_name)
|
||||
@@ -1,17 +0,0 @@
|
||||
import logging
|
||||
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.scm_context import scm_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@scm_context
|
||||
def checkout(repo, rev, *args, **kwargs):
|
||||
repo.experiments.checkout_exp(rev, *args, **kwargs)
|
||||
logger.info(
|
||||
"Changes for experiment '%s' have been applied to your current "
|
||||
"workspace.",
|
||||
rev,
|
||||
)
|
||||
@@ -7,22 +7,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
|
||||
from dvc.repo.experiments.show import _collect_experiment
|
||||
from dvc.repo.experiments.show import _collect_experiment_commit
|
||||
|
||||
if repo.scm.no_commits:
|
||||
return {}
|
||||
|
||||
if a_rev:
|
||||
with repo.experiments.chdir():
|
||||
old = _collect_experiment(repo.experiments.exp_dvc, a_rev)
|
||||
old = _collect_experiment_commit(repo, a_rev)
|
||||
else:
|
||||
old = _collect_experiment(repo, "HEAD")
|
||||
old = _collect_experiment_commit(repo, "HEAD")
|
||||
|
||||
if b_rev:
|
||||
with repo.experiments.chdir():
|
||||
new = _collect_experiment(repo.experiments.exp_dvc, b_rev)
|
||||
new = _collect_experiment_commit(repo, b_rev)
|
||||
else:
|
||||
new = _collect_experiment(repo, "workspace")
|
||||
new = _collect_experiment_commit(repo, "workspace")
|
||||
|
||||
with_unchanged = kwargs.pop("all", False)
|
||||
|
||||
|
||||
+277
-100
@@ -1,60 +1,134 @@
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from functools import partial
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Iterable, Optional
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, Optional, Tuple, Union
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.dvcfile import is_lock_file
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.repo.experiments.base import (
|
||||
EXEC_BASELINE,
|
||||
EXEC_BRANCH,
|
||||
EXEC_CHECKPOINT,
|
||||
EXEC_HEAD,
|
||||
EXEC_MERGE,
|
||||
EXEC_NAMESPACE,
|
||||
EXPS_NAMESPACE,
|
||||
EXPS_STASH,
|
||||
ExpRefInfo,
|
||||
UnchangedExperimentError,
|
||||
)
|
||||
from dvc.scm import SCM
|
||||
from dvc.scm.git import Git
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.tree.base import BaseTree
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.stage.serialize import to_lockfile
|
||||
from dvc.utils import dict_sha256
|
||||
from dvc.utils.fs import remove
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from multiprocessing import Queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExperimentExecutor:
|
||||
class BaseExecutor:
|
||||
"""Base class for executing experiments in parallel.
|
||||
|
||||
Args:
|
||||
baseline_rev: baseline revision that this experiment is derived from.
|
||||
src: source Git SCM instance.
|
||||
dvc_dir: relpath to DVC root from SCM root.
|
||||
|
||||
Optional keyword args:
|
||||
branch: Existing git branch for this experiment.
|
||||
repro_args: Args to be passed into reproduce.
|
||||
repro_kwargs: Keyword args to be passed into reproduce.
|
||||
"""
|
||||
|
||||
PACKED_ARGS_FILE = "repro.dat"
|
||||
|
||||
def __init__(
|
||||
self, baseline_rev: str, branch: Optional[str] = None, **kwargs,
|
||||
self,
|
||||
src: "Git",
|
||||
dvc_dir: str,
|
||||
root_dir: Optional[Union[str, PathInfo]] = None,
|
||||
branch: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.baseline_rev = baseline_rev
|
||||
self.branch = branch
|
||||
self.repro_args = kwargs.pop("repro_args", [])
|
||||
self.repro_kwargs = kwargs.pop("repro_kwargs", {})
|
||||
assert root_dir is not None
|
||||
self._dvc_dir = dvc_dir
|
||||
self.root_dir = root_dir
|
||||
self._init_git(src, branch)
|
||||
self.name = name
|
||||
|
||||
def _init_git(self, scm: "Git", branch: Optional[str] = None):
|
||||
"""Init git repo and collect executor refs from the specified SCM."""
|
||||
from dulwich.repo import Repo as DulwichRepo
|
||||
|
||||
DulwichRepo.init(os.fspath(self.root_dir))
|
||||
|
||||
cwd = os.getcwd()
|
||||
os.chdir(self.root_dir)
|
||||
try:
|
||||
refspec = f"{EXEC_NAMESPACE}/"
|
||||
scm.push_refspec(self.git_url, refspec, refspec)
|
||||
if branch:
|
||||
scm.push_refspec(self.git_url, branch, branch)
|
||||
self.scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
|
||||
|
||||
if self.scm.get_ref(EXEC_CHECKPOINT):
|
||||
self.scm.remove_ref(EXEC_CHECKPOINT)
|
||||
|
||||
# checkout EXEC_HEAD and apply EXEC_MERGE on top of it without
|
||||
# committing
|
||||
head = EXEC_BRANCH if branch else EXEC_HEAD
|
||||
self.scm.checkout(head, detach=True)
|
||||
self.scm.repo.git.merge(EXEC_MERGE, squash=True, no_commit=True)
|
||||
self.scm.repo.git.reset()
|
||||
self._prune_lockfiles()
|
||||
finally:
|
||||
os.chdir(cwd)
|
||||
|
||||
def _prune_lockfiles(self):
|
||||
# NOTE: dirty DVC lock files must be restored to index state to
|
||||
# avoid checking out incorrect persist or checkpoint outs
|
||||
dirty = [diff.a_path for diff in self.scm.repo.index.diff(None)]
|
||||
to_checkout = [fname for fname in dirty if is_lock_file(fname)]
|
||||
self.scm.repo.index.checkout(paths=to_checkout, force=True)
|
||||
|
||||
untracked = self.scm.repo.untracked_files
|
||||
to_remove = [fname for fname in untracked if is_lock_file(fname)]
|
||||
for fname in to_remove:
|
||||
remove(fname)
|
||||
return (
|
||||
len(dirty) - len(to_checkout) + len(untracked) - len(to_remove)
|
||||
) != 0
|
||||
|
||||
@cached_property
|
||||
def scm(self):
|
||||
return SCM(self.root_dir)
|
||||
|
||||
@property
|
||||
def tree(self) -> BaseTree:
|
||||
def git_url(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def dvc_dir(self) -> str:
|
||||
return os.path.join(self.root_dir, self._dvc_dir)
|
||||
|
||||
@staticmethod
|
||||
def reproduce(dvc_dir, cwd=None, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def collect_output(self) -> Iterable["PathInfo"]:
|
||||
"""Iterate over output pathnames for this executor.
|
||||
|
||||
For DVC outs, only the .dvc file path will be yielded. DVC outs
|
||||
themselves should be fetched from remote executor cache in the normal
|
||||
fetch/pull way. For local executors outs will already be available via
|
||||
shared local cache.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
def hash_exp(stages):
|
||||
exp_data = {}
|
||||
for stage in stages:
|
||||
if isinstance(stage, PipelineStage):
|
||||
exp_data.update(to_lockfile(stage))
|
||||
return dict_sha256(exp_data)
|
||||
|
||||
def cleanup(self):
|
||||
pass
|
||||
self.scm.close()
|
||||
del self.scm
|
||||
|
||||
# TODO: come up with better way to stash repro arguments
|
||||
@staticmethod
|
||||
@@ -73,82 +147,101 @@ class ExperimentExecutor:
|
||||
pickle.dump(data, fobj)
|
||||
|
||||
@staticmethod
|
||||
def unpack_repro_args(path, tree=None):
|
||||
open_func = tree.open if tree else open
|
||||
with open_func(path, "rb") as fobj:
|
||||
def unpack_repro_args(path):
|
||||
with open(path, "rb") as fobj:
|
||||
data = pickle.load(fobj)
|
||||
return data["args"], data["kwargs"]
|
||||
|
||||
def fetch_exps(
|
||||
self,
|
||||
dest_scm: "Git",
|
||||
force: bool = False,
|
||||
on_diverged: Callable[[str], None] = None,
|
||||
) -> Iterable[str]:
|
||||
"""Fetch reproduced experiments into the specified SCM."""
|
||||
refs = []
|
||||
for ref in self.scm.iter_refs(base=EXPS_NAMESPACE):
|
||||
if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
|
||||
refs.append(ref)
|
||||
|
||||
class LocalExecutor(ExperimentExecutor):
|
||||
"""Local machine experiment executor."""
|
||||
def on_diverged_ref(orig_ref, new_rev):
|
||||
orig_rev = dest_scm.get_ref(orig_ref)
|
||||
if dest_scm.diff(orig_rev, new_rev):
|
||||
if force:
|
||||
logger.debug(
|
||||
"Replacing existing experiment '%s'",
|
||||
os.fsdecode(orig_ref),
|
||||
)
|
||||
return True
|
||||
if on_diverged:
|
||||
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None
|
||||
on_diverged(orig_ref, checkpoint)
|
||||
logger.debug(
|
||||
"Reproduced existing experiment '%s'", os.fsdecode(orig_ref)
|
||||
)
|
||||
return False
|
||||
|
||||
def __init__(self, baseline_rev: str, **kwargs):
|
||||
from dvc.repo import Repo
|
||||
|
||||
dvc_dir = kwargs.pop("dvc_dir")
|
||||
cache_dir = kwargs.pop("cache_dir")
|
||||
super().__init__(baseline_rev, **kwargs)
|
||||
self.tmp_dir = TemporaryDirectory()
|
||||
|
||||
# init empty DVC repo (will be overwritten when input is uploaded)
|
||||
Repo.init(root_dir=self.tmp_dir.name, no_scm=True)
|
||||
logger.debug(
|
||||
"Init local executor in dir '%s' with baseline '%s'.",
|
||||
self.tmp_dir,
|
||||
baseline_rev[:7],
|
||||
# fetch experiments
|
||||
dest_scm.fetch_refspecs(
|
||||
self.git_url,
|
||||
[f"{ref}:{ref}" for ref in refs],
|
||||
on_diverged=on_diverged_ref,
|
||||
force=force,
|
||||
)
|
||||
self.dvc_dir = os.path.join(self.tmp_dir.name, dvc_dir)
|
||||
self._config(cache_dir)
|
||||
self._tree = LocalTree(self.dvc, {"url": self.dvc.root_dir})
|
||||
# override default CACHE_MODE since files must be writable in order
|
||||
# to run repro
|
||||
self._tree.CACHE_MODE = 0o644
|
||||
# update last run checkpoint (if it exists)
|
||||
if self.scm.get_ref(EXEC_CHECKPOINT):
|
||||
dest_scm.fetch_refspecs(
|
||||
self.git_url,
|
||||
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
|
||||
force=True,
|
||||
)
|
||||
return refs
|
||||
|
||||
def _config(self, cache_dir):
|
||||
local_config = os.path.join(self.dvc_dir, "config.local")
|
||||
logger.debug("Writing experiments local config '%s'", local_config)
|
||||
with open(local_config, "w") as fobj:
|
||||
fobj.write("[core]\n no_scm = true\n")
|
||||
fobj.write(f"[cache]\n dir = {cache_dir}")
|
||||
|
||||
@cached_property
|
||||
def dvc(self):
|
||||
from dvc.repo import Repo
|
||||
|
||||
return Repo(self.dvc_dir)
|
||||
|
||||
@cached_property
|
||||
def path_info(self):
|
||||
return PathInfo(self.tmp_dir.name)
|
||||
|
||||
@property
|
||||
def tree(self):
|
||||
return self._tree
|
||||
|
||||
@staticmethod
|
||||
def reproduce(dvc_dir, cwd=None, **kwargs):
|
||||
"""Run dvc repro and return the result."""
|
||||
from dvc.repo import Repo
|
||||
from dvc.repo.experiments import hash_exp
|
||||
@classmethod
|
||||
def reproduce(
|
||||
cls,
|
||||
dvc_dir: str,
|
||||
queue: "Queue",
|
||||
rev: str,
|
||||
cwd: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""Run dvc repro and return the result.
|
||||
|
||||
Returns tuple of (exp_hash, force) where exp_hash is the experiment
|
||||
hash (or None on error) and force is a bool specifying whether or
|
||||
not this experiment should force overwrite any existing duplicates.
|
||||
"""
|
||||
unchanged = []
|
||||
|
||||
queue.put((rev, os.getpid()))
|
||||
|
||||
def filter_pipeline(stages):
|
||||
unchanged.extend(
|
||||
[stage for stage in stages if isinstance(stage, PipelineStage)]
|
||||
)
|
||||
|
||||
if cwd:
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(cwd)
|
||||
else:
|
||||
old_cwd = None
|
||||
cwd = os.getcwd()
|
||||
result = None
|
||||
force = False
|
||||
|
||||
try:
|
||||
logger.debug("Running repro in '%s'", cwd)
|
||||
dvc = Repo(dvc_dir)
|
||||
old_cwd = os.getcwd()
|
||||
new_cwd = cwd if cwd else dvc.root_dir
|
||||
os.chdir(new_cwd)
|
||||
logger.debug("Running repro in '%s'", cwd)
|
||||
|
||||
args_path = os.path.join(
|
||||
dvc.tmp_dir, BaseExecutor.PACKED_ARGS_FILE
|
||||
)
|
||||
if os.path.exists(args_path):
|
||||
args, kwargs = BaseExecutor.unpack_repro_args(args_path)
|
||||
remove(args_path)
|
||||
else:
|
||||
args = []
|
||||
kwargs = {}
|
||||
|
||||
force = kwargs.get("force", False)
|
||||
|
||||
# NOTE: for checkpoint experiments we handle persist outs slightly
|
||||
# differently than normal:
|
||||
@@ -161,29 +254,113 @@ class LocalExecutor(ExperimentExecutor):
|
||||
# be removed/does not yet exist) so that our executor workspace
|
||||
# is not polluted with the (persistent) out from an unrelated
|
||||
# experiment run
|
||||
checkpoint = kwargs.pop("checkpoint", False)
|
||||
dvc.checkout(allow_persist_missing=checkpoint, force=checkpoint)
|
||||
stages = dvc.reproduce(on_unchanged=filter_pipeline, **kwargs)
|
||||
dvc.checkout(force=True, quiet=True)
|
||||
|
||||
# We cannot use dvc.scm to make commits inside the executor since
|
||||
# cached props are not picklable.
|
||||
scm = Git()
|
||||
checkpoint_func = partial(cls.checkpoint_callback, scm, name)
|
||||
stages = dvc.reproduce(
|
||||
*args,
|
||||
on_unchanged=filter_pipeline,
|
||||
checkpoint_func=checkpoint_func,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
exp_hash = cls.hash_exp(stages)
|
||||
exp_rev = cls.commit(scm, exp_hash, exp_name=name)
|
||||
if scm.get_ref(EXEC_CHECKPOINT):
|
||||
scm.set_ref(EXEC_CHECKPOINT, exp_rev)
|
||||
except UnchangedExperimentError:
|
||||
pass
|
||||
finally:
|
||||
if old_cwd is not None:
|
||||
if scm:
|
||||
scm.close()
|
||||
del scm
|
||||
if old_cwd:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
# ideally we would return stages here like a normal repro() call, but
|
||||
# stages is not currently picklable and cannot be returned across
|
||||
# multiprocessing calls
|
||||
return hash_exp(stages + unchanged)
|
||||
return result, force
|
||||
|
||||
def collect_output(self) -> Iterable["PathInfo"]:
|
||||
repo_tree = RepoTree(self.dvc)
|
||||
yield from self.collect_files(self.tree, repo_tree)
|
||||
@classmethod
|
||||
def checkpoint_callback(
|
||||
cls,
|
||||
scm: "Git",
|
||||
name: Optional[str],
|
||||
unchanged: Iterable[PipelineStage],
|
||||
stages: Iterable[PipelineStage],
|
||||
):
|
||||
try:
|
||||
exp_hash = cls.hash_exp(stages + unchanged)
|
||||
exp_rev = cls.commit(scm, exp_hash, exp_name=name)
|
||||
scm.set_ref(EXEC_CHECKPOINT, exp_rev)
|
||||
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
|
||||
except UnchangedExperimentError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def collect_files(tree: BaseTree, repo_tree: RepoTree):
|
||||
for fname in repo_tree.walk_files(repo_tree.root_dir, dvcfiles=True):
|
||||
if not repo_tree.isdvc(fname):
|
||||
yield tree.path_info / fname.relative_to(repo_tree.root_dir)
|
||||
@classmethod
|
||||
def commit(cls, scm: "Git", exp_hash: str, exp_name: Optional[str] = None):
|
||||
"""Commit stages as an experiment and return the commit SHA."""
|
||||
rev = scm.get_rev()
|
||||
if not scm.is_dirty(untracked_files=True):
|
||||
logger.debug("No changes to commit")
|
||||
raise UnchangedExperimentError(rev)
|
||||
|
||||
branch = scm.get_ref(EXEC_BRANCH, follow=False)
|
||||
if branch:
|
||||
old_ref = rev
|
||||
logger.debug("Commit to current experiment branch '%s'", branch)
|
||||
else:
|
||||
baseline_rev = scm.get_ref(EXEC_BASELINE)
|
||||
name = exp_name if exp_name else f"exp-{exp_hash[:5]}"
|
||||
branch = str(ExpRefInfo(baseline_rev, name))
|
||||
old_ref = None
|
||||
logger.debug("Commit to new experiment branch '%s'", branch)
|
||||
|
||||
scm.repo.git.add(A=True)
|
||||
scm.commit(f"dvc: commit experiment {exp_hash}")
|
||||
new_rev = scm.get_rev()
|
||||
scm.set_ref(branch, new_rev, old_ref=old_ref)
|
||||
scm.set_ref(EXEC_BRANCH, branch, symbolic=True)
|
||||
return new_rev
|
||||
|
||||
|
||||
class LocalExecutor(BaseExecutor):
|
||||
"""Local machine experiment executor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
tmp_dir: Optional[str] = None,
|
||||
cache_dir: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self._tmp_dir = TemporaryDirectory(dir=tmp_dir)
|
||||
super().__init__(*args, root_dir=self._tmp_dir.name, **kwargs)
|
||||
if cache_dir:
|
||||
self._config(cache_dir)
|
||||
logger.debug(
|
||||
"Init local executor in dir '%s'", self._tmp_dir,
|
||||
)
|
||||
|
||||
def _config(self, cache_dir):
|
||||
local_config = os.path.join(self.dvc_dir, "config.local")
|
||||
logger.debug("Writing experiments local config '%s'", local_config)
|
||||
with open(local_config, "w") as fobj:
|
||||
fobj.write("[core]\n no_scm = true\n")
|
||||
fobj.write(f"[cache]\n dir = {cache_dir}")
|
||||
|
||||
@property
|
||||
def git_url(self) -> str:
|
||||
root_dir = os.path.abspath(self.root_dir)
|
||||
if os.name == "nt":
|
||||
root_dir = root_dir.replace(os.sep, "/")
|
||||
return f"file://{root_dir}"
|
||||
|
||||
def cleanup(self):
|
||||
logger.debug("Removing tmpdir '%s'", self.tmp_dir)
|
||||
self.tmp_dir.cleanup()
|
||||
super().cleanup()
|
||||
logger.debug("Removing tmpdir '%s'", self._tmp_dir)
|
||||
self._tmp_dir.cleanup()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import (
|
||||
EXEC_NAMESPACE,
|
||||
EXPS_NAMESPACE,
|
||||
EXPS_STASH,
|
||||
ExpRefInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
def gc(
|
||||
repo,
|
||||
all_branches: Optional[bool] = False,
|
||||
all_tags: Optional[bool] = False,
|
||||
all_commits: Optional[bool] = False,
|
||||
workspace: Optional[bool] = False,
|
||||
queued: Optional[bool] = False,
|
||||
):
|
||||
keep_revs = set(
|
||||
repo.brancher(
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
sha_only=True,
|
||||
)
|
||||
)
|
||||
if workspace:
|
||||
keep_revs.add(repo.scm.get_rev())
|
||||
|
||||
if not keep_revs:
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
for ref in repo.scm.iter_refs(EXPS_NAMESPACE):
|
||||
if ref.startswith(EXEC_NAMESPACE) or ref == EXPS_STASH:
|
||||
continue
|
||||
ref_info = ExpRefInfo.from_ref(ref)
|
||||
if ref_info.baseline_sha not in keep_revs:
|
||||
repo.scm.remove_ref(ref)
|
||||
removed += 1
|
||||
|
||||
delete_stashes = []
|
||||
for _, entry in repo.experiments.stash_revs.items():
|
||||
if not queued or entry.baseline_rev not in keep_revs:
|
||||
delete_stashes.append(entry.index)
|
||||
for index in sorted(delete_stashes, reverse=True):
|
||||
repo.experiments.stash.drop(index)
|
||||
removed += len(delete_stashes)
|
||||
|
||||
return removed
|
||||
@@ -0,0 +1,68 @@
|
||||
import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import UnchangedExperimentError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_params(path_params: Iterable):
|
||||
from ruamel.yaml import YAMLError
|
||||
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.utils.serialize import loads_yaml
|
||||
|
||||
ret = {}
|
||||
for path_param in path_params:
|
||||
path, _, params_str = path_param.rpartition(":")
|
||||
# remove empty strings from params, on condition such as `-p "file1:"`
|
||||
params = {}
|
||||
for param_str in filter(bool, params_str.split(",")):
|
||||
try:
|
||||
# interpret value strings using YAML rules
|
||||
key, value = param_str.split("=")
|
||||
params[key] = loads_yaml(value)
|
||||
except (ValueError, YAMLError):
|
||||
raise InvalidArgumentError(
|
||||
f"Invalid param/value pair '{param_str}'"
|
||||
)
|
||||
if not path:
|
||||
path = ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
ret[path] = params
|
||||
return ret
|
||||
|
||||
|
||||
@locked
|
||||
def run(
|
||||
repo,
|
||||
target: Optional[str] = None,
|
||||
params: Optional[Iterable] = None,
|
||||
run_all: Optional[bool] = False,
|
||||
jobs: Optional[int] = 1,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Reproduce the specified target as an experiment.
|
||||
|
||||
Accepts the same additional kwargs as Repo.reproduce.
|
||||
|
||||
Returns a dict mapping new experiment SHAs to the results
|
||||
of `repro` for that experiment.
|
||||
"""
|
||||
if run_all:
|
||||
return repo.experiments.reproduce_queued(jobs=jobs)
|
||||
|
||||
if params:
|
||||
params = _parse_params(params)
|
||||
else:
|
||||
params = []
|
||||
try:
|
||||
return repo.experiments.reproduce_one(
|
||||
target=target, params=params, **kwargs
|
||||
)
|
||||
except UnchangedExperimentError:
|
||||
# If experiment contains no changes, just run regular repro
|
||||
kwargs.pop("queue", None)
|
||||
kwargs.pop("checkpoint_resume", None)
|
||||
return {None: repo.reproduce(target=target, **kwargs)}
|
||||
@@ -1,84 +1,76 @@
|
||||
import logging
|
||||
from collections import OrderedDict, defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from funcy import first
|
||||
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.experiments.base import ExpRefInfo
|
||||
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
|
||||
from dvc.repo.params.show import _collect_configs, _read_params
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _collect_experiment(repo, rev, stash=False, sha_only=True):
|
||||
from git.exc import GitCommandError
|
||||
|
||||
def _collect_experiment_commit(repo, rev, stash=False, sha_only=True):
|
||||
res = defaultdict(dict)
|
||||
for rev in repo.brancher(revs=[rev]):
|
||||
if rev == "workspace":
|
||||
res["timestamp"] = None
|
||||
else:
|
||||
commit = _resolve_commit(repo, rev)
|
||||
commit = repo.scm.resolve_commit(rev)
|
||||
res["timestamp"] = datetime.fromtimestamp(commit.committed_date)
|
||||
|
||||
configs = _collect_configs(repo)
|
||||
configs = _collect_configs(repo, rev=rev)
|
||||
params = _read_params(repo, configs, rev)
|
||||
if params:
|
||||
res["params"] = params
|
||||
|
||||
res["queued"] = stash
|
||||
if not stash:
|
||||
metrics = _collect_metrics(repo, None, False)
|
||||
metrics = _collect_metrics(repo, None, rev, False)
|
||||
vals = _read_metrics(repo, metrics, rev)
|
||||
res["metrics"] = vals
|
||||
|
||||
if not sha_only and rev != "workspace":
|
||||
try:
|
||||
name = repo.scm.repo.git.describe(
|
||||
rev, all=True, exact_match=True
|
||||
)
|
||||
for refspec in ["refs/tags", "refs/heads"]:
|
||||
name = repo.scm.describe(rev, base=refspec)
|
||||
if name:
|
||||
break
|
||||
if not name:
|
||||
if stash:
|
||||
pass
|
||||
else:
|
||||
name = repo.experiments.get_exact_name(rev)
|
||||
if name:
|
||||
name = name.rsplit("/")[-1]
|
||||
res["name"] = name
|
||||
except GitCommandError:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _resolve_commit(repo, rev):
|
||||
from git.objects.tag import TagObject
|
||||
|
||||
commit = repo.scm.repo.rev_parse(rev)
|
||||
if isinstance(commit, TagObject):
|
||||
commit = commit.object
|
||||
return commit
|
||||
|
||||
|
||||
def _collect_checkpoint_experiment(repo, branch, baseline, **kwargs):
|
||||
res = OrderedDict()
|
||||
def _collect_experiment_branch(res, repo, branch, baseline, **kwargs):
|
||||
exp_rev = repo.scm.resolve_rev(branch)
|
||||
for rev in _branch_revs(repo, exp_rev, baseline):
|
||||
res[rev] = _collect_experiment(repo, rev, **kwargs)
|
||||
res[rev]["checkpoint_tip"] = exp_rev
|
||||
prev = None
|
||||
revs = list(repo.scm.branch_revs(exp_rev, baseline))
|
||||
for rev in revs:
|
||||
collected_exp = _collect_experiment_commit(repo, rev, **kwargs)
|
||||
if len(revs) > 1:
|
||||
exp = {"checkpoint_tip": exp_rev}
|
||||
if prev:
|
||||
res[prev]["checkpoint_parent"] = rev
|
||||
if rev in res:
|
||||
res[rev].update(exp)
|
||||
res.move_to_end(rev)
|
||||
else:
|
||||
exp.update(collected_exp)
|
||||
else:
|
||||
exp = collected_exp
|
||||
res[rev] = exp
|
||||
prev = rev
|
||||
if len(revs) > 1:
|
||||
res[prev]["checkpoint_parent"] = baseline
|
||||
return res
|
||||
|
||||
|
||||
def _branch_revs(repo, branch_tip, baseline: Optional[str] = None):
|
||||
"""Iterate over revisions in a given branch (from newest to oldest).
|
||||
|
||||
If baseline is set, iterator will stop when the specified revision is
|
||||
reached.
|
||||
"""
|
||||
commit = _resolve_commit(repo, branch_tip)
|
||||
while commit is not None:
|
||||
yield commit.hexsha
|
||||
commit = first(commit.parents)
|
||||
if commit and commit.hexsha == baseline:
|
||||
return
|
||||
|
||||
|
||||
@locked
|
||||
def show(
|
||||
repo,
|
||||
@@ -105,36 +97,33 @@ def show(
|
||||
)
|
||||
|
||||
for rev in revs:
|
||||
res[rev]["baseline"] = _collect_experiment(
|
||||
res[rev]["baseline"] = _collect_experiment_commit(
|
||||
repo, rev, sha_only=sha_only
|
||||
)
|
||||
|
||||
# collect reproduced experiments
|
||||
for exp_branch in repo.experiments.scm.list_branches():
|
||||
m = repo.experiments.BRANCH_RE.match(exp_branch)
|
||||
if m:
|
||||
rev = repo.scm.resolve_rev(m.group("baseline_rev"))
|
||||
if rev in revs:
|
||||
with repo.experiments.chdir():
|
||||
if m.group("checkpoint"):
|
||||
checkpoint_exps = _collect_checkpoint_experiment(
|
||||
repo.experiments.exp_dvc, exp_branch, rev
|
||||
)
|
||||
res[rev].update(checkpoint_exps)
|
||||
else:
|
||||
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
|
||||
experiment = _collect_experiment(
|
||||
repo.experiments.exp_dvc, exp_branch
|
||||
)
|
||||
res[rev][exp_rev] = experiment
|
||||
if rev == "workspace":
|
||||
continue
|
||||
|
||||
ref_info = ExpRefInfo(baseline_sha=rev)
|
||||
commits = [
|
||||
(ref, repo.scm.resolve_commit(ref))
|
||||
for ref in repo.scm.iter_refs(base=str(ref_info))
|
||||
]
|
||||
for exp_ref, _ in sorted(
|
||||
commits, key=lambda x: x[1].committed_date, reverse=True,
|
||||
):
|
||||
ref_info = ExpRefInfo.from_ref(exp_ref)
|
||||
assert ref_info.baseline_sha == rev
|
||||
_collect_experiment_branch(
|
||||
res[rev], repo, exp_ref, rev, sha_only=sha_only
|
||||
)
|
||||
|
||||
# collect queued (not yet reproduced) experiments
|
||||
for stash_rev, entry in repo.experiments.stash_revs.items():
|
||||
if entry.baseline_rev in revs:
|
||||
with repo.experiments.chdir():
|
||||
experiment = _collect_experiment(
|
||||
repo.experiments.exp_dvc, stash_rev, stash=True
|
||||
)
|
||||
experiment = _collect_experiment_commit(
|
||||
repo, stash_rev, stash=True
|
||||
)
|
||||
res[entry.baseline_rev][stash_rev] = experiment
|
||||
|
||||
return res
|
||||
|
||||
+7
-5
@@ -1,12 +1,14 @@
|
||||
import typing
|
||||
|
||||
from . import locked
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Repo
|
||||
|
||||
|
||||
@locked
|
||||
def _set(repo, target, frozen):
|
||||
from dvc.utils import parse_target
|
||||
|
||||
path, name = parse_target(target)
|
||||
stage = repo.get_stage(path, name)
|
||||
def _set(repo: "Repo", target, frozen):
|
||||
stage = repo.stage.get_target(target)
|
||||
stage.frozen = frozen
|
||||
stage.dvcfile.dump(stage, update_lock=False)
|
||||
|
||||
|
||||
+13
-25
@@ -3,19 +3,12 @@ import logging
|
||||
from dvc.cache import NamedCache
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import locked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _do_gc(typ, remote, clist, jobs=None):
|
||||
from dvc.remote.base import Remote
|
||||
|
||||
removed = Remote.gc(clist, remote, jobs=jobs)
|
||||
if not removed:
|
||||
logger.info(f"No unused '{typ}' cache to remove.")
|
||||
|
||||
|
||||
def _raise_error_if_all_disabled(**kwargs):
|
||||
if not any(kwargs.values()):
|
||||
raise InvalidArgumentError(
|
||||
@@ -76,23 +69,18 @@ def gc(
|
||||
)
|
||||
)
|
||||
|
||||
# treat caches as remotes for garbage collection
|
||||
_do_gc("local", self.cache.local, used, jobs)
|
||||
for scheme, cache in self.cache.by_scheme():
|
||||
if not cache:
|
||||
continue
|
||||
|
||||
if self.cache.s3:
|
||||
_do_gc("s3", self.cache.s3, used, jobs)
|
||||
removed = cache.gc(set(used.scheme_keys(scheme)), jobs=jobs)
|
||||
if not removed:
|
||||
logger.info(f"No unused '{scheme}' cache to remove.")
|
||||
|
||||
if self.cache.gs:
|
||||
_do_gc("gs", self.cache.gs, used, jobs)
|
||||
if not cloud:
|
||||
return
|
||||
|
||||
if self.cache.ssh:
|
||||
_do_gc("ssh", self.cache.ssh, used, jobs)
|
||||
|
||||
if self.cache.hdfs:
|
||||
_do_gc("hdfs", self.cache.hdfs, used, jobs)
|
||||
|
||||
if self.cache.azure:
|
||||
_do_gc("azure", self.cache.azure, used, jobs)
|
||||
|
||||
if cloud:
|
||||
_do_gc("remote", self.cloud.get_remote(remote, "gc -c"), used, jobs)
|
||||
remote = self.cloud.get_remote(remote, "gc -c")
|
||||
removed = remote.gc(set(used.scheme_keys(Schemes.LOCAL)), jobs=jobs)
|
||||
if not removed:
|
||||
logger.info("No unused cache to remove from remote.")
|
||||
|
||||
@@ -18,7 +18,6 @@ class GetDVCFileError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get(url, path, out=None, rev=None):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
+2
-2
@@ -5,7 +5,6 @@ import dvc.output as output
|
||||
from dvc.utils import resolve_output
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_url(url, out=None):
|
||||
out = resolve_output(url, out)
|
||||
|
||||
@@ -16,6 +15,7 @@ def get_url(url, out=None):
|
||||
|
||||
(dep,) = dependency.loads_from(None, [url])
|
||||
(out,) = output.loads_from(None, [out], use_cache=False)
|
||||
dep.save()
|
||||
if not dep.exists:
|
||||
raise dep.DoesNotExistError(dep)
|
||||
dep.download(out)
|
||||
out.save()
|
||||
|
||||
+120
-2
@@ -1,4 +1,14 @@
|
||||
def check_acyclic(graph):
|
||||
from typing import TYPE_CHECKING, Iterator, List, Set
|
||||
|
||||
from dvc.utils.fs import path_isin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from networkx import DiGraph
|
||||
|
||||
from dvc.stage import Stage
|
||||
|
||||
|
||||
def check_acyclic(graph: "DiGraph") -> None:
|
||||
import networkx as nx
|
||||
|
||||
from dvc.exceptions import CyclicGraphError
|
||||
@@ -8,7 +18,7 @@ def check_acyclic(graph):
|
||||
except nx.NetworkXNoCycle:
|
||||
return
|
||||
|
||||
stages = set()
|
||||
stages: Set["Stage"] = set()
|
||||
for from_node, to_node, _ in edges:
|
||||
stages.add(from_node)
|
||||
stages.add(to_node)
|
||||
@@ -26,3 +36,111 @@ def get_pipelines(G):
|
||||
import networkx as nx
|
||||
|
||||
return [G.subgraph(c).copy() for c in nx.weakly_connected_components(G)]
|
||||
|
||||
|
||||
def collect_pipeline(stage: "Stage", graph: "DiGraph") -> Iterator["Stage"]:
|
||||
import networkx as nx
|
||||
|
||||
pipeline = get_pipeline(get_pipelines(graph), stage)
|
||||
return nx.dfs_postorder_nodes(pipeline, stage)
|
||||
|
||||
|
||||
def collect_inside_path(path: str, graph: "DiGraph") -> List["Stage"]:
|
||||
import networkx as nx
|
||||
|
||||
stages = nx.dfs_postorder_nodes(graph)
|
||||
return [stage for stage in stages if path_isin(stage.path, path)]
|
||||
|
||||
|
||||
def build_graph(stages, outs_trie=None):
|
||||
"""Generate a graph by using the given stages on the given directory
|
||||
|
||||
The nodes of the graph are the stage's path relative to the root.
|
||||
|
||||
Edges are created when the output of one stage is used as a
|
||||
dependency in other stage.
|
||||
|
||||
The direction of the edges goes from the stage to its dependency:
|
||||
|
||||
For example, running the following:
|
||||
|
||||
$ dvc run -o A "echo A > A"
|
||||
$ dvc run -d A -o B "echo B > B"
|
||||
$ dvc run -d B -o C "echo C > C"
|
||||
|
||||
Will create the following graph:
|
||||
|
||||
ancestors <--
|
||||
|
|
||||
C.dvc -> B.dvc -> A.dvc
|
||||
| |
|
||||
| --> descendants
|
||||
|
|
||||
------- pipeline ------>
|
||||
|
|
||||
v
|
||||
(weakly connected components)
|
||||
|
||||
Args:
|
||||
stages (list): used to build a graph from
|
||||
|
||||
Raises:
|
||||
OutputDuplicationError: two outputs with the same path
|
||||
StagePathAsOutputError: stage inside an output directory
|
||||
OverlappingOutputPathsError: output inside output directory
|
||||
CyclicGraphError: resulting graph has cycles
|
||||
"""
|
||||
import networkx as nx
|
||||
|
||||
from dvc.exceptions import StagePathAsOutputError
|
||||
|
||||
from ..path_info import PathInfo
|
||||
from .trie import build_outs_trie
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
# Use trie to efficiently find overlapping outs and deps
|
||||
outs_trie = outs_trie or build_outs_trie(stages)
|
||||
|
||||
for stage in stages:
|
||||
out = outs_trie.shortest_prefix(PathInfo(stage.path).parts).value
|
||||
if out:
|
||||
raise StagePathAsOutputError(stage, str(out))
|
||||
|
||||
# Building graph
|
||||
G.add_nodes_from(stages)
|
||||
for stage in stages:
|
||||
for dep in stage.deps:
|
||||
if dep.path_info is None:
|
||||
continue
|
||||
|
||||
dep_key = dep.path_info.parts
|
||||
overlapping = [n.value for n in outs_trie.prefixes(dep_key)]
|
||||
if outs_trie.has_subtrie(dep_key):
|
||||
overlapping.extend(outs_trie.values(prefix=dep_key))
|
||||
|
||||
G.add_edges_from((stage, out.stage) for out in overlapping)
|
||||
check_acyclic(G)
|
||||
|
||||
return G
|
||||
|
||||
|
||||
# NOTE: using stage graph instead of just list of stages to make sure that it
|
||||
# has already passed all the sanity checks like cycles/overlapping outputs and
|
||||
# so on.
|
||||
def build_outs_graph(graph, outs_trie):
|
||||
import networkx as nx
|
||||
|
||||
G = nx.DiGraph()
|
||||
|
||||
G.add_nodes_from(outs_trie.values())
|
||||
for stage in graph.nodes():
|
||||
for dep in stage.deps:
|
||||
dep_key = dep.path_info.parts
|
||||
overlapping = [n.value for n in outs_trie.prefixes(dep_key)]
|
||||
if outs_trie.has_subtrie(dep_key):
|
||||
overlapping.extend(outs_trie.values(prefix=dep_key))
|
||||
|
||||
for from_out in stage.outs:
|
||||
G.add_edges_from((from_out, out) for out in overlapping)
|
||||
return G
|
||||
|
||||
+4
-2
@@ -1,6 +1,8 @@
|
||||
def imp(self, url, path, out=None, fname=None, rev=None):
|
||||
def imp(self, url, path, out=None, fname=None, rev=None, **kwargs):
|
||||
erepo = {"url": url}
|
||||
if rev is not None:
|
||||
erepo["rev"] = rev
|
||||
|
||||
return self.imp_url(path, out=out, fname=fname, erepo=erepo, frozen=True)
|
||||
return self.imp_url(
|
||||
path, out=out, fname=fname, erepo=erepo, frozen=True, **kwargs
|
||||
)
|
||||
|
||||
+14
-4
@@ -11,10 +11,17 @@ from . import locked
|
||||
@locked
|
||||
@scm_context
|
||||
def imp_url(
|
||||
self, url, out=None, fname=None, erepo=None, frozen=True, no_exec=False
|
||||
self,
|
||||
url,
|
||||
out=None,
|
||||
fname=None,
|
||||
erepo=None,
|
||||
frozen=True,
|
||||
no_exec=False,
|
||||
desc=None,
|
||||
):
|
||||
from dvc.dvcfile import Dvcfile
|
||||
from dvc.stage import Stage, create_stage
|
||||
from dvc.stage import Stage, create_stage, restore_meta
|
||||
|
||||
out = resolve_output(url, out)
|
||||
path, wdir, out = resolve_paths(self, out)
|
||||
@@ -36,10 +43,13 @@ def imp_url(
|
||||
outs=[out],
|
||||
erepo=erepo,
|
||||
)
|
||||
|
||||
if stage is None:
|
||||
restore_meta(stage)
|
||||
if stage.can_be_skipped:
|
||||
return None
|
||||
|
||||
if desc:
|
||||
stage.outs[0].desc = desc
|
||||
|
||||
dvcfile = Dvcfile(self, stage.path)
|
||||
dvcfile.remove()
|
||||
|
||||
|
||||
+10
-36
@@ -1,45 +1,18 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import colorama
|
||||
|
||||
from dvc import analytics
|
||||
from dvc.config import Config
|
||||
from dvc.exceptions import InitError, InvalidArgumentError
|
||||
from dvc.ignore import init as init_dvcignore
|
||||
from dvc.repo import Repo
|
||||
from dvc.scm import SCM
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.utils import boxify
|
||||
from dvc.utils import format_link as fmt_link
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import remove
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _welcome_message():
|
||||
if analytics.is_enabled():
|
||||
logger.info(
|
||||
boxify(
|
||||
"DVC has enabled anonymous aggregate usage analytics.\n"
|
||||
"Read the analytics documentation (and how to opt-out) here:\n"
|
||||
+ fmt_link("https://dvc.org/doc/user-guide/analytics"),
|
||||
border_color="red",
|
||||
)
|
||||
)
|
||||
|
||||
msg = (
|
||||
"{yellow}What's next?{nc}\n"
|
||||
"{yellow}------------{nc}\n"
|
||||
f"- Check out the documentation: {fmt_link('https://dvc.org/doc')}\n"
|
||||
f"- Get help and share ideas: {fmt_link('https://dvc.org/chat')}\n"
|
||||
f"- Star us on GitHub: {fmt_link('https://github.com/iterative/dvc')}"
|
||||
).format(yellow=colorama.Fore.YELLOW, nc=colorama.Fore.RESET)
|
||||
|
||||
logger.info(msg)
|
||||
|
||||
|
||||
def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
|
||||
"""
|
||||
Creates an empty repo on the given directory -- basically a
|
||||
@@ -72,19 +45,22 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
|
||||
scm = SCM(root_dir, search_parent_directories=subdir, no_scm=no_scm)
|
||||
except SCMError:
|
||||
raise InitError(
|
||||
"{repo} is not tracked by any supported SCM tool (e.g. Git). "
|
||||
f"{root_dir} is not tracked by any supported SCM tool (e.g. Git). "
|
||||
"Use `--no-scm` if you don't want to use any SCM or "
|
||||
"`--subdir` if initializing inside a subdirectory of a parent SCM "
|
||||
"repository.".format(repo=root_dir)
|
||||
"repository."
|
||||
)
|
||||
|
||||
if scm.is_ignored(dvc_dir):
|
||||
raise InitError(
|
||||
f"{dvc_dir} is ignored by your SCM tool. \n"
|
||||
"Make sure that it's tracked, "
|
||||
"for example, by adding '!.dvc' to .gitignore."
|
||||
)
|
||||
|
||||
if os.path.isdir(dvc_dir):
|
||||
if not force:
|
||||
raise InitError(
|
||||
"'{repo}' exists. Use `-f` to force.".format(
|
||||
repo=relpath(dvc_dir)
|
||||
)
|
||||
)
|
||||
raise InitError(f"'{relpath(dvc_dir)}' exists. Use `-f` to force.")
|
||||
|
||||
remove(dvc_dir)
|
||||
|
||||
@@ -110,6 +86,4 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
|
||||
scm.add([os.path.join(dvc_dir, scm.ignore_file)])
|
||||
logger.info("\nYou can now commit the changes to git.\n")
|
||||
|
||||
_welcome_message()
|
||||
|
||||
return proj
|
||||
|
||||
+2
-5
@@ -4,7 +4,6 @@ from dvc.exceptions import PathMissingError
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
|
||||
@staticmethod
|
||||
def ls(
|
||||
url, path=None, rev=None, recursive=None, dvc_only=False,
|
||||
):
|
||||
@@ -38,7 +37,7 @@ def ls(
|
||||
if path:
|
||||
path_info /= path
|
||||
|
||||
ret = _ls(repo, path_info, recursive, dvc_only)
|
||||
ret = _ls(repo.repo_tree, path_info, recursive, dvc_only)
|
||||
|
||||
if path and not ret:
|
||||
raise PathMissingError(path, repo, dvc_only=dvc_only)
|
||||
@@ -51,12 +50,10 @@ def ls(
|
||||
return ret_list
|
||||
|
||||
|
||||
def _ls(repo, path_info, recursive=None, dvc_only=False):
|
||||
def _ls(tree, path_info, recursive=None, dvc_only=False):
|
||||
def onerror(exc):
|
||||
raise exc
|
||||
|
||||
tree = repo.repo_tree
|
||||
|
||||
infos = []
|
||||
try:
|
||||
for root, dirs, files in tree.walk(
|
||||
|
||||
+21
-39
@@ -1,41 +1,28 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.exceptions import NoMetricsError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.exceptions import NoMetricsFoundError, NoMetricsParsedError
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.collect import collect
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.utils.serialize import YAMLFileCorruptedError, load_yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _collect_metrics(repo, targets, recursive):
|
||||
def _is_metric(out):
|
||||
return bool(out.metric)
|
||||
|
||||
if targets:
|
||||
target_infos = [
|
||||
PathInfo(os.path.abspath(target)) for target in targets
|
||||
]
|
||||
tree = RepoTree(repo)
|
||||
|
||||
rec_files = []
|
||||
if recursive:
|
||||
for target_info in target_infos:
|
||||
if tree.isdir(target_info):
|
||||
rec_files.extend(list(tree.walk_files(target_info)))
|
||||
|
||||
result = [t for t in target_infos if tree.isfile(t)]
|
||||
result.extend(rec_files)
|
||||
|
||||
return result
|
||||
|
||||
metrics = set()
|
||||
for stage in repo.stages:
|
||||
for out in stage.outs:
|
||||
if not out.metric:
|
||||
continue
|
||||
metrics.add(out.path_info)
|
||||
return list(metrics)
|
||||
def _collect_metrics(repo, targets, revision, recursive):
|
||||
metrics, path_infos = collect(
|
||||
repo,
|
||||
targets=targets,
|
||||
output_filter=_is_metric,
|
||||
recursive=recursive,
|
||||
rev=revision,
|
||||
)
|
||||
return [m.path_info for m in metrics] + list(path_infos)
|
||||
|
||||
|
||||
def _extract_metrics(metrics, path, rev):
|
||||
@@ -105,7 +92,7 @@ def show(
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
):
|
||||
metrics = _collect_metrics(repo, targets, recursive)
|
||||
metrics = _collect_metrics(repo, targets, rev, recursive)
|
||||
|
||||
if not metrics_found and metrics:
|
||||
metrics_found = True
|
||||
@@ -117,22 +104,17 @@ def show(
|
||||
|
||||
if not res:
|
||||
if metrics_found:
|
||||
msg = (
|
||||
"Could not parse metric files. Use `-v` option to see more "
|
||||
"details."
|
||||
)
|
||||
raise NoMetricsParsedError("metrics")
|
||||
else:
|
||||
msg = (
|
||||
"no metric files in this repository. Use `-m/-M` options for "
|
||||
"`dvc run` to mark stage outputs as metrics."
|
||||
)
|
||||
raise NoMetricsError(msg)
|
||||
raise NoMetricsFoundError("metrics", "-m/-M")
|
||||
|
||||
# Hide workspace metrics if they are the same as in the active branch
|
||||
try:
|
||||
active_branch = repo.scm.active_branch()
|
||||
except TypeError:
|
||||
pass # Detached head
|
||||
except (TypeError, SCMError):
|
||||
# TypeError - detached head
|
||||
# SCMError - no repo case
|
||||
pass
|
||||
else:
|
||||
if res.get("workspace") == res.get(active_branch):
|
||||
res.pop("workspace", None)
|
||||
|
||||
+29
-9
@@ -1,11 +1,18 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.collect import collect
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.utils.serialize import LOADERS, ParseError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.output.base import BaseOutput
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -13,15 +20,14 @@ class NoParamsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def _collect_configs(repo):
|
||||
configs = set()
|
||||
configs.add(PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE)
|
||||
for stage in repo.stages:
|
||||
for dep in stage.deps:
|
||||
if not isinstance(dep, ParamsDependency):
|
||||
continue
|
||||
def _is_params(dep: "BaseOutput"):
|
||||
return isinstance(dep, ParamsDependency)
|
||||
|
||||
configs.add(dep.path_info)
|
||||
|
||||
def _collect_configs(repo: "Repo", rev) -> List[PathInfo]:
|
||||
params, _ = collect(repo, deps=True, output_filter=_is_params, rev=rev)
|
||||
configs = {p.path_info for p in params}
|
||||
configs.add(PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE)
|
||||
return list(configs)
|
||||
|
||||
|
||||
@@ -44,13 +50,27 @@ def _read_params(repo, configs, rev):
|
||||
return res
|
||||
|
||||
|
||||
def _add_vars(repo, configs, params):
|
||||
for stage in repo.stages:
|
||||
if isinstance(stage, PipelineStage) and stage.tracked_vars:
|
||||
for file, vars_ in stage.tracked_vars.items():
|
||||
# `params` file are shown regardless of `tracked` or not
|
||||
# to reduce noise and duplication, they are skipped
|
||||
if file in configs:
|
||||
continue
|
||||
|
||||
params[file] = params.get(file, {})
|
||||
params[file].update(vars_)
|
||||
|
||||
|
||||
@locked
|
||||
def show(repo, revs=None):
|
||||
res = {}
|
||||
|
||||
for branch in repo.brancher(revs=revs):
|
||||
configs = _collect_configs(repo)
|
||||
configs = _collect_configs(repo, branch)
|
||||
params = _read_params(repo, configs, branch)
|
||||
_add_vars(repo, configs, params)
|
||||
|
||||
if params:
|
||||
res[branch] = params
|
||||
|
||||
+37
-36
@@ -1,10 +1,14 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from funcy import cached_property, first, project
|
||||
|
||||
from dvc.exceptions import DvcException, NoPlotsError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.exceptions import (
|
||||
DvcException,
|
||||
NoMetricsFoundError,
|
||||
NoMetricsParsedError,
|
||||
)
|
||||
from dvc.repo.collect import collect
|
||||
from dvc.repo.plots.data import PlotParsingError
|
||||
from dvc.schema import PLOT_PROPS
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.utils import relpath
|
||||
@@ -72,10 +76,24 @@ class Plots:
|
||||
# Merge data by plot file and apply overriding props
|
||||
plots = _prepare_plots(data, revs, props)
|
||||
|
||||
return {
|
||||
datafile: _render(datafile, desc["data"], desc["props"], templates)
|
||||
for datafile, desc in plots.items()
|
||||
}
|
||||
result = {}
|
||||
for datafile, desc in plots.items():
|
||||
try:
|
||||
result[datafile] = _render(
|
||||
datafile, desc["data"], desc["props"], templates
|
||||
)
|
||||
except PlotParsingError as e:
|
||||
logger.debug(
|
||||
"failed to read '%s' on '%s'",
|
||||
e.path,
|
||||
e.revision,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if not any(result.values()):
|
||||
raise NoMetricsParsedError("plots")
|
||||
|
||||
return result
|
||||
|
||||
def show(self, targets=None, revs=None, props=None, templates=None):
|
||||
from .data import NoMetricInHistoryError
|
||||
@@ -85,12 +103,13 @@ class Plots:
|
||||
# If any mentioned plot doesn't have any data then that's an error
|
||||
targets = [targets] if isinstance(targets, str) else targets or []
|
||||
for target in targets:
|
||||
if not any("data" in d[target] for d in data.values()):
|
||||
rpath = relpath(target, self.repo.root_dir)
|
||||
if not any("data" in d[rpath] for d in data.values()):
|
||||
raise NoMetricInHistoryError(target)
|
||||
|
||||
# No data at all is a special error with a special message
|
||||
if not data:
|
||||
raise NoPlotsError()
|
||||
raise NoMetricsFoundError("plots", "--plots/--plots-no-cache")
|
||||
|
||||
if templates is None:
|
||||
templates = self.templates
|
||||
@@ -149,34 +168,16 @@ class Plots:
|
||||
return PlotTemplates(self.repo.dvc_dir)
|
||||
|
||||
|
||||
def _is_plot(out):
|
||||
return bool(out.plot)
|
||||
|
||||
|
||||
def _collect_plots(repo, targets=None, rev=None):
|
||||
plots = {out for stage in repo.stages for out in stage.outs if out.plot}
|
||||
|
||||
def to_result(plots):
|
||||
return {plot.path_info: _plot_props(plot) for plot in plots}
|
||||
|
||||
if not targets:
|
||||
return to_result(plots)
|
||||
|
||||
target_infos = {PathInfo(os.path.abspath(target)) for target in targets}
|
||||
|
||||
target_plots = set()
|
||||
for p in plots:
|
||||
if p.path_info in target_infos:
|
||||
target_plots.add(p)
|
||||
target_infos.remove(p.path_info)
|
||||
|
||||
tree = RepoTree(repo)
|
||||
result = to_result(target_plots)
|
||||
|
||||
for t in target_infos:
|
||||
if tree.isfile(t):
|
||||
result[t] = {}
|
||||
else:
|
||||
logger.warning(
|
||||
"'%s' was not found at: '%s'. It will not be plotted.", t, rev,
|
||||
)
|
||||
|
||||
plots, path_infos = collect(
|
||||
repo, output_filter=_is_plot, targets=targets, rev=rev
|
||||
)
|
||||
result = {plot.path_info: _plot_props(plot) for plot in plots}
|
||||
result.update({path_info: {} for path_info in path_infos})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+19
-6
@@ -1,14 +1,13 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from copy import copy
|
||||
|
||||
from funcy import first
|
||||
from funcy import first, reraise
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils.serialize import loads_yaml
|
||||
from dvc.utils.serialize import ParseError, parse_json, parse_yaml
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
@@ -32,6 +31,14 @@ class NoMetricInHistoryError(DvcException):
|
||||
super().__init__(f"Could not find '{path}'.")
|
||||
|
||||
|
||||
class PlotParsingError(ParseError):
|
||||
def __init__(self, path, revision):
|
||||
self.path = path
|
||||
self.revision = revision
|
||||
|
||||
super().__init__(path, f"revision: {revision}")
|
||||
|
||||
|
||||
def plot_data(filename, revision, content):
|
||||
_, extension = os.path.splitext(filename.lower())
|
||||
if extension == ".json":
|
||||
@@ -159,7 +166,11 @@ class PlotData:
|
||||
return [_filter_fields, _append_index, _append_revision]
|
||||
|
||||
def to_datapoints(self, **kwargs):
|
||||
data = self.raw(**kwargs)
|
||||
with reraise(
|
||||
[ParseError, csv.Error],
|
||||
PlotParsingError(self.filename, self.revision),
|
||||
):
|
||||
data = self.raw(**kwargs)
|
||||
|
||||
for data_proc in self._processors():
|
||||
data = data_proc(
|
||||
@@ -170,7 +181,9 @@ class PlotData:
|
||||
|
||||
class JSONPlotData(PlotData):
|
||||
def raw(self, **kwargs):
|
||||
return json.loads(self.content, object_pairs_hook=OrderedDict)
|
||||
return parse_json(
|
||||
self.content, self.filename, object_pairs_hook=OrderedDict
|
||||
)
|
||||
|
||||
def _processors(self):
|
||||
parent_processors = super()._processors()
|
||||
@@ -207,7 +220,7 @@ class CSVPlotData(PlotData):
|
||||
|
||||
class YAMLPlotData(PlotData):
|
||||
def raw(self, **kwargs):
|
||||
return loads_yaml(self.content, typ="rt")
|
||||
return parse_yaml(self.content, self.filename, typ="rt")
|
||||
|
||||
def _processors(self):
|
||||
parent_processors = super()._processors()
|
||||
|
||||
+353
-37
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
@@ -29,8 +30,8 @@ class Template:
|
||||
EXTENSION = ".json"
|
||||
ANCHOR = "<DVC_METRIC_{}>"
|
||||
|
||||
DEFAULT_CONTENT = None
|
||||
DEFAULT_NAME = None
|
||||
DEFAULT_CONTENT: Optional[Dict[str, Any]] = None
|
||||
DEFAULT_NAME: Optional[str] = None
|
||||
|
||||
def __init__(self, content=None, name=None):
|
||||
if content:
|
||||
@@ -106,13 +107,15 @@ class Template:
|
||||
raise NoFieldInDataError(field)
|
||||
|
||||
|
||||
class DefaultLinearTemplate(Template):
|
||||
class DefaultTemplate(Template):
|
||||
DEFAULT_NAME = "default"
|
||||
|
||||
DEFAULT_CONTENT = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"data": {"values": Template.anchor("data")},
|
||||
"title": Template.anchor("title"),
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"mark": {"type": "line"},
|
||||
"encoding": {
|
||||
"x": {
|
||||
@@ -131,56 +134,266 @@ class DefaultLinearTemplate(Template):
|
||||
}
|
||||
|
||||
|
||||
class DefaultConfusionTemplate(Template):
|
||||
class ConfusionTemplate(Template):
|
||||
DEFAULT_NAME = "confusion"
|
||||
DEFAULT_CONTENT = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"data": {"values": Template.anchor("data")},
|
||||
"title": Template.anchor("title"),
|
||||
"mark": "rect",
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("x_label"),
|
||||
"facet": {"field": "rev", "type": "nominal"},
|
||||
"spec": {
|
||||
"transform": [
|
||||
{
|
||||
"aggregate": [{"op": "count", "as": "xy_count"}],
|
||||
"groupby": [Template.anchor("y"), Template.anchor("x")],
|
||||
},
|
||||
{
|
||||
"impute": "xy_count",
|
||||
"groupby": ["rev", Template.anchor("y")],
|
||||
"key": Template.anchor("x"),
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"impute": "xy_count",
|
||||
"groupby": ["rev", Template.anchor("x")],
|
||||
"key": Template.anchor("y"),
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"joinaggregate": [
|
||||
{"op": "max", "field": "xy_count", "as": "max_count"}
|
||||
],
|
||||
"groupby": [],
|
||||
},
|
||||
{
|
||||
"calculate": "datum.xy_count / datum.max_count",
|
||||
"as": "percent_of_max",
|
||||
},
|
||||
],
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("x_label"),
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("y_label"),
|
||||
},
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("y_label"),
|
||||
},
|
||||
"color": {"aggregate": "count", "type": "quantitative"},
|
||||
"facet": {"field": "rev", "type": "nominal"},
|
||||
"layer": [
|
||||
{
|
||||
"mark": "rect",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"encoding": {
|
||||
"color": {
|
||||
"field": "xy_count",
|
||||
"type": "quantitative",
|
||||
"title": "",
|
||||
"scale": {"domainMin": 0, "nice": True},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"mark": "text",
|
||||
"encoding": {
|
||||
"text": {"field": "xy_count", "type": "quantitative"},
|
||||
"color": {
|
||||
"condition": {
|
||||
"test": "datum.percent_of_max > 0.5",
|
||||
"value": "white",
|
||||
},
|
||||
"value": "black",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class DefaultScatterTemplate(Template):
|
||||
DEFAULT_NAME = "scatter"
|
||||
class NormalizedConfusionTemplate(Template):
|
||||
DEFAULT_NAME = "confusion_normalized"
|
||||
DEFAULT_CONTENT = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"data": {"values": Template.anchor("data")},
|
||||
"title": Template.anchor("title"),
|
||||
"mark": "point",
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("x_label"),
|
||||
"facet": {"field": "rev", "type": "nominal"},
|
||||
"spec": {
|
||||
"transform": [
|
||||
{
|
||||
"aggregate": [{"op": "count", "as": "xy_count"}],
|
||||
"groupby": [Template.anchor("y"), Template.anchor("x")],
|
||||
},
|
||||
{
|
||||
"impute": "xy_count",
|
||||
"groupby": ["rev", Template.anchor("y")],
|
||||
"key": Template.anchor("x"),
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"impute": "xy_count",
|
||||
"groupby": ["rev", Template.anchor("x")],
|
||||
"key": Template.anchor("y"),
|
||||
"value": 0,
|
||||
},
|
||||
{
|
||||
"joinaggregate": [
|
||||
{"op": "sum", "field": "xy_count", "as": "sum_y"}
|
||||
],
|
||||
"groupby": [Template.anchor("y")],
|
||||
},
|
||||
{
|
||||
"calculate": "datum.xy_count / datum.sum_y",
|
||||
"as": "percent_of_y",
|
||||
},
|
||||
],
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("x_label"),
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "nominal",
|
||||
"sort": "ascending",
|
||||
"title": Template.anchor("y_label"),
|
||||
},
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("y_label"),
|
||||
"scale": {"zero": False},
|
||||
},
|
||||
"color": {"field": "rev", "type": "nominal"},
|
||||
"layer": [
|
||||
{
|
||||
"mark": "rect",
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"encoding": {
|
||||
"color": {
|
||||
"field": "percent_of_y",
|
||||
"type": "quantitative",
|
||||
"title": "",
|
||||
"scale": {"domain": [0, 1]},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"mark": "text",
|
||||
"encoding": {
|
||||
"text": {
|
||||
"field": "percent_of_y",
|
||||
"type": "quantitative",
|
||||
"format": ".2f",
|
||||
},
|
||||
"color": {
|
||||
"condition": {
|
||||
"test": "datum.percent_of_y > 0.5",
|
||||
"value": "white",
|
||||
},
|
||||
"value": "black",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ScatterTemplate(Template):
|
||||
DEFAULT_NAME = "scatter"
|
||||
|
||||
DEFAULT_CONTENT = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"data": {"values": Template.anchor("data")},
|
||||
"title": Template.anchor("title"),
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"layer": [
|
||||
{
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("x_label"),
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("y_label"),
|
||||
"scale": {"zero": False},
|
||||
},
|
||||
"color": {"field": "rev", "type": "nominal"},
|
||||
},
|
||||
"layer": [
|
||||
{"mark": "point"},
|
||||
{
|
||||
"selection": {
|
||||
"label": {
|
||||
"type": "single",
|
||||
"nearest": True,
|
||||
"on": "mouseover",
|
||||
"encodings": ["x"],
|
||||
"empty": "none",
|
||||
"clear": "mouseout",
|
||||
}
|
||||
},
|
||||
"mark": "point",
|
||||
"encoding": {
|
||||
"opacity": {
|
||||
"condition": {
|
||||
"selection": "label",
|
||||
"value": 1,
|
||||
},
|
||||
"value": 0,
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"transform": [{"filter": {"selection": "label"}}],
|
||||
"layer": [
|
||||
{
|
||||
"encoding": {
|
||||
"text": {
|
||||
"type": "quantitative",
|
||||
"field": Template.anchor("y"),
|
||||
},
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "quantitative",
|
||||
},
|
||||
},
|
||||
"layer": [
|
||||
{
|
||||
"mark": {
|
||||
"type": "text",
|
||||
"align": "left",
|
||||
"dx": 5,
|
||||
"dy": -5,
|
||||
},
|
||||
"encoding": {
|
||||
"color": {
|
||||
"type": "nominal",
|
||||
"field": "rev",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class SmoothLinearTemplate(Template):
|
||||
DEFAULT_NAME = "smooth"
|
||||
|
||||
@@ -214,12 +427,115 @@ class SmoothLinearTemplate(Template):
|
||||
}
|
||||
|
||||
|
||||
class LinearTemplate(Template):
|
||||
DEFAULT_NAME = "linear"
|
||||
|
||||
DEFAULT_CONTENT = {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
|
||||
"data": {"values": Template.anchor("data")},
|
||||
"title": Template.anchor("title"),
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"layer": [
|
||||
{
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("x_label"),
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "quantitative",
|
||||
"title": Template.anchor("y_label"),
|
||||
"scale": {"zero": False},
|
||||
},
|
||||
"color": {"field": "rev", "type": "nominal"},
|
||||
},
|
||||
"layer": [
|
||||
{"mark": "line"},
|
||||
{
|
||||
"selection": {
|
||||
"label": {
|
||||
"type": "single",
|
||||
"nearest": True,
|
||||
"on": "mouseover",
|
||||
"encodings": ["x"],
|
||||
"empty": "none",
|
||||
"clear": "mouseout",
|
||||
}
|
||||
},
|
||||
"mark": "point",
|
||||
"encoding": {
|
||||
"opacity": {
|
||||
"condition": {
|
||||
"selection": "label",
|
||||
"value": 1,
|
||||
},
|
||||
"value": 0,
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"transform": [{"filter": {"selection": "label"}}],
|
||||
"layer": [
|
||||
{
|
||||
"mark": {"type": "rule", "color": "gray"},
|
||||
"encoding": {
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"encoding": {
|
||||
"text": {
|
||||
"type": "quantitative",
|
||||
"field": Template.anchor("y"),
|
||||
},
|
||||
"x": {
|
||||
"field": Template.anchor("x"),
|
||||
"type": "quantitative",
|
||||
},
|
||||
"y": {
|
||||
"field": Template.anchor("y"),
|
||||
"type": "quantitative",
|
||||
},
|
||||
},
|
||||
"layer": [
|
||||
{
|
||||
"mark": {
|
||||
"type": "text",
|
||||
"align": "left",
|
||||
"dx": 5,
|
||||
"dy": -5,
|
||||
},
|
||||
"encoding": {
|
||||
"color": {
|
||||
"type": "nominal",
|
||||
"field": "rev",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class PlotTemplates:
|
||||
TEMPLATES_DIR = "plots"
|
||||
TEMPLATES = [
|
||||
DefaultLinearTemplate,
|
||||
DefaultConfusionTemplate,
|
||||
DefaultScatterTemplate,
|
||||
DefaultTemplate,
|
||||
LinearTemplate,
|
||||
ConfusionTemplate,
|
||||
NormalizedConfusionTemplate,
|
||||
ScatterTemplate,
|
||||
SmoothLinearTemplate,
|
||||
]
|
||||
|
||||
|
||||
+6
-4
@@ -1,15 +1,17 @@
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from ..utils import parse_target
|
||||
from . import locked
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
def remove(self, target, outs=False):
|
||||
path, name = parse_target(target)
|
||||
stages = self.get_stages(path, name)
|
||||
def remove(self: "Repo", target: str, outs: bool = False):
|
||||
stages = self.stage.from_target(target)
|
||||
|
||||
for stage in stages:
|
||||
stage.remove(remove_outs=outs, force=outs)
|
||||
|
||||
+69
-104
@@ -1,13 +1,17 @@
|
||||
import logging
|
||||
import typing
|
||||
from functools import partial
|
||||
|
||||
from dvc.exceptions import InvalidArgumentError, ReproductionError
|
||||
from dvc.repo.experiments import UnchangedExperimentError
|
||||
from dvc.repo.scm_context import scm_context
|
||||
from dvc.stage.run import CheckpointKilledError
|
||||
|
||||
from . import locked
|
||||
from .graph import get_pipeline, get_pipelines
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from . import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -17,8 +21,15 @@ def _reproduce_stage(stage, **kwargs):
|
||||
repro_callback([stage])
|
||||
|
||||
checkpoint_func = kwargs.pop("checkpoint_func", None)
|
||||
if checkpoint_func:
|
||||
kwargs["checkpoint_func"] = partial(_run_callback, checkpoint_func)
|
||||
if stage.is_checkpoint:
|
||||
if checkpoint_func:
|
||||
kwargs["checkpoint_func"] = partial(_run_callback, checkpoint_func)
|
||||
else:
|
||||
logger.warning(
|
||||
"Checkpoint stages are not fully supported in 'dvc repro'. "
|
||||
"Checkpoint stages should be reproduced with 'dvc exp run' "
|
||||
"or 'dvc exp resume'."
|
||||
)
|
||||
|
||||
if stage.frozen and not stage.is_import:
|
||||
logger.warning(
|
||||
@@ -68,47 +79,21 @@ def _get_active_graph(G):
|
||||
@locked
|
||||
@scm_context
|
||||
def reproduce(
|
||||
self,
|
||||
self: "Repo",
|
||||
target=None,
|
||||
recursive=False,
|
||||
pipeline=False,
|
||||
all_pipelines=False,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.utils import parse_target
|
||||
|
||||
glob = kwargs.pop("glob", False)
|
||||
accept_group = not glob
|
||||
assert target is None or isinstance(target, str)
|
||||
if not target and not all_pipelines:
|
||||
raise InvalidArgumentError(
|
||||
"Neither `target` nor `--all-pipelines` are specified."
|
||||
)
|
||||
|
||||
experiment = kwargs.pop("experiment", False)
|
||||
params = _parse_params(kwargs.pop("params", []))
|
||||
queue = kwargs.pop("queue", False)
|
||||
run_all = kwargs.pop("run_all", False)
|
||||
jobs = kwargs.pop("jobs", 1)
|
||||
checkpoint = kwargs.pop("checkpoint", False)
|
||||
checkpoint_continue = kwargs.pop("checkpoint_continue", None)
|
||||
if (experiment or run_all) and self.experiments:
|
||||
try:
|
||||
return _reproduce_experiments(
|
||||
self,
|
||||
target=target,
|
||||
recursive=recursive,
|
||||
all_pipelines=all_pipelines,
|
||||
params=params,
|
||||
queue=queue,
|
||||
run_all=run_all,
|
||||
jobs=jobs,
|
||||
checkpoint=checkpoint,
|
||||
checkpoint_continue=checkpoint_continue,
|
||||
**kwargs,
|
||||
)
|
||||
except UnchangedExperimentError:
|
||||
# If experiment contains no changes, just run regular repro
|
||||
pass
|
||||
|
||||
interactive = kwargs.get("interactive", False)
|
||||
if not interactive:
|
||||
kwargs["interactive"] = self.config["core"].get("interactive", False)
|
||||
@@ -116,12 +101,11 @@ def reproduce(
|
||||
active_graph = _get_active_graph(self.graph)
|
||||
active_pipelines = get_pipelines(active_graph)
|
||||
|
||||
path, name = parse_target(target)
|
||||
if pipeline or all_pipelines:
|
||||
if all_pipelines:
|
||||
pipelines = active_pipelines
|
||||
else:
|
||||
stage = self.get_stage(path, name)
|
||||
stage = self.stage.get_target(target)
|
||||
pipelines = [get_pipeline(active_pipelines, stage)]
|
||||
|
||||
targets = []
|
||||
@@ -130,44 +114,17 @@ def reproduce(
|
||||
if pipeline.in_degree(stage) == 0:
|
||||
targets.append(stage)
|
||||
else:
|
||||
targets = self.collect(target, recursive=recursive, graph=active_graph)
|
||||
targets = self.stage.collect(
|
||||
target,
|
||||
recursive=recursive,
|
||||
graph=active_graph,
|
||||
accept_group=accept_group,
|
||||
glob=glob,
|
||||
)
|
||||
|
||||
return _reproduce_stages(active_graph, targets, **kwargs)
|
||||
|
||||
|
||||
def _parse_params(path_params):
|
||||
from ruamel.yaml import YAMLError
|
||||
|
||||
from dvc.dependency.param import ParamsDependency
|
||||
from dvc.utils.flatten import unflatten
|
||||
from dvc.utils.serialize import loads_yaml
|
||||
|
||||
ret = {}
|
||||
for path_param in path_params:
|
||||
path, _, params_str = path_param.rpartition(":")
|
||||
# remove empty strings from params, on condition such as `-p "file1:"`
|
||||
params = {}
|
||||
for param_str in filter(bool, params_str.split(",")):
|
||||
try:
|
||||
# interpret value strings using YAML rules
|
||||
key, value = param_str.split("=")
|
||||
params[key] = loads_yaml(value)
|
||||
except (ValueError, YAMLError):
|
||||
raise InvalidArgumentError(
|
||||
f"Invalid param/value pair '{param_str}'"
|
||||
)
|
||||
if not path:
|
||||
path = ParamsDependency.DEFAULT_PARAMS_FILE
|
||||
ret[path] = unflatten(params)
|
||||
return ret
|
||||
|
||||
|
||||
def _reproduce_experiments(repo, run_all=False, jobs=1, **kwargs):
|
||||
if run_all:
|
||||
return repo.experiments.reproduce_queued(jobs=jobs)
|
||||
return repo.experiments.reproduce_one(**kwargs)
|
||||
|
||||
|
||||
def _reproduce_stages(
|
||||
G, stages, downstream=False, single_item=False, on_unchanged=None, **kwargs
|
||||
):
|
||||
@@ -206,6 +163,49 @@ def _reproduce_stages(
|
||||
|
||||
The derived evaluation of _downstream_ B would be: [B, D, E]
|
||||
"""
|
||||
pipeline = _get_pipeline(G, stages, downstream, single_item)
|
||||
|
||||
force_downstream = kwargs.pop("force_downstream", False)
|
||||
result = []
|
||||
unchanged = []
|
||||
# `ret` is used to add a cosmetic newline.
|
||||
ret = []
|
||||
checkpoint_func = kwargs.pop("checkpoint_func", None)
|
||||
for stage in pipeline:
|
||||
if ret:
|
||||
logger.info("")
|
||||
|
||||
if checkpoint_func:
|
||||
kwargs["checkpoint_func"] = partial(
|
||||
_repro_callback, checkpoint_func, unchanged
|
||||
)
|
||||
|
||||
try:
|
||||
ret = _reproduce_stage(stage, **kwargs)
|
||||
|
||||
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
|
||||
|
||||
if ret:
|
||||
result.extend(ret)
|
||||
except CheckpointKilledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ReproductionError(stage.relpath) from exc
|
||||
|
||||
if on_unchanged is not None:
|
||||
on_unchanged(unchanged)
|
||||
return result
|
||||
|
||||
|
||||
def _get_pipeline(G, stages, downstream, single_item):
|
||||
import networkx as nx
|
||||
|
||||
if single_item:
|
||||
@@ -234,42 +234,7 @@ def _reproduce_stages(
|
||||
if stage not in pipeline:
|
||||
pipeline.append(stage)
|
||||
|
||||
force_downstream = kwargs.pop("force_downstream", False)
|
||||
result = []
|
||||
unchanged = []
|
||||
# `ret` is used to add a cosmetic newline.
|
||||
ret = []
|
||||
for stage in pipeline:
|
||||
if ret:
|
||||
logger.info("")
|
||||
|
||||
checkpoint_func = kwargs.pop("checkpoint_func", None)
|
||||
if checkpoint_func:
|
||||
kwargs["checkpoint_func"] = partial(
|
||||
_repro_callback, checkpoint_func, unchanged
|
||||
)
|
||||
|
||||
try:
|
||||
ret = _reproduce_stage(stage, **kwargs)
|
||||
|
||||
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
|
||||
|
||||
if ret:
|
||||
result.extend(ret)
|
||||
except Exception as exc:
|
||||
raise ReproductionError(stage.relpath) from exc
|
||||
|
||||
if on_unchanged is not None:
|
||||
on_unchanged(unchanged)
|
||||
return result
|
||||
return pipeline
|
||||
|
||||
|
||||
def _repro_callback(experiments_callback, unchanged, stages):
|
||||
|
||||
+4
-2
@@ -48,6 +48,7 @@ def _get_file_path(kwargs):
|
||||
kwargs.get("plots_no_cache", []),
|
||||
kwargs.get("outs_persist", []),
|
||||
kwargs.get("outs_persist_no_cache", []),
|
||||
kwargs.get("checkpoints", []),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -77,7 +78,7 @@ def _check_stage_exists(dvcfile, stage):
|
||||
@scm_context
|
||||
def run(self, fname=None, no_exec=False, single_stage=False, **kwargs):
|
||||
from dvc.dvcfile import PIPELINE_FILE, Dvcfile
|
||||
from dvc.stage import Stage, create_stage
|
||||
from dvc.stage import Stage, create_stage, restore_meta
|
||||
|
||||
if not kwargs.get("cmd"):
|
||||
raise InvalidArgumentError("command is not specified")
|
||||
@@ -112,7 +113,8 @@ def run(self, fname=None, no_exec=False, single_stage=False, **kwargs):
|
||||
stage = create_stage(
|
||||
stage_cls, repo=self, path=path, params=params, **kwargs
|
||||
)
|
||||
if stage is None:
|
||||
restore_meta(stage)
|
||||
if kwargs.get("run_cache", True) and stage.can_be_skipped:
|
||||
return None
|
||||
|
||||
dvcfile = Dvcfile(self, stage.path)
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
from contextlib import suppress
|
||||
from typing import Iterable, List, NamedTuple, Optional, Set, Tuple
|
||||
|
||||
from dvc.dvcfile import PIPELINE_FILE, Dvcfile, is_valid_filename
|
||||
from dvc.exceptions import NoOutputOrStageError, OutputNotFoundError
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo.graph import collect_inside_path, collect_pipeline
|
||||
from dvc.stage.exceptions import StageFileDoesNotExistError, StageNotFound
|
||||
from dvc.utils import parse_target
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from networkx import DiGraph
|
||||
|
||||
from dvc.repo import Repo
|
||||
from dvc.stage import Stage
|
||||
from dvc.stage.loader import StageLoader
|
||||
from dvc.types import OptStr
|
||||
|
||||
|
||||
class StageInfo(NamedTuple):
|
||||
stage: "Stage"
|
||||
filter_info: Optional[PathInfo] = None
|
||||
|
||||
|
||||
StageList = List["Stage"]
|
||||
StageIter = Iterable["Stage"]
|
||||
StageSet = Set["Stage"]
|
||||
|
||||
|
||||
def _collect_with_deps(stages: StageList, graph: "DiGraph") -> StageSet:
|
||||
res: StageSet = set()
|
||||
for stage in stages:
|
||||
res.update(collect_pipeline(stage, graph=graph))
|
||||
return res
|
||||
|
||||
|
||||
def _maybe_collect_from_dvc_yaml(
|
||||
loader: "StageLoad", target, with_deps: bool, **load_kwargs,
|
||||
) -> StageIter:
|
||||
|
||||
stages: StageList = []
|
||||
if loader.tree.exists(PIPELINE_FILE):
|
||||
with suppress(StageNotFound):
|
||||
stages = loader.load_all(PIPELINE_FILE, target, **load_kwargs)
|
||||
return _collect_with_deps(stages, loader.graph) if with_deps else stages
|
||||
|
||||
|
||||
def _collect_specific_target(
|
||||
loader: "StageLoad",
|
||||
target: str,
|
||||
with_deps: bool,
|
||||
recursive: bool,
|
||||
accept_group: bool,
|
||||
) -> Tuple[StageIter, "OptStr", "OptStr"]:
|
||||
# Optimization: do not collect the graph for a specific target
|
||||
file, name = parse_target(target)
|
||||
|
||||
# if the target has a file, we can load directly from it.
|
||||
if not file:
|
||||
# but, if there's no file, parsing is ambiguous as it can be a
|
||||
# stage name in `dvc.yaml` file or an output. We prioritize
|
||||
# `dvc.yaml` stage name here. If it exists, then we move on.
|
||||
# else, we assume it's a output name in the `collect_granular()` below
|
||||
msg = "Checking if stage '%s' is in '%s'"
|
||||
logger.debug(msg, target, PIPELINE_FILE)
|
||||
if not (recursive and loader.tree.isdir(target)):
|
||||
stages = _maybe_collect_from_dvc_yaml(
|
||||
loader, target, with_deps, accept_group=accept_group,
|
||||
)
|
||||
if stages:
|
||||
return stages, file, name
|
||||
elif not with_deps and is_valid_filename(file):
|
||||
stages = loader.load_all(file, name, accept_group=accept_group)
|
||||
return stages, file, name
|
||||
return [], file, name
|
||||
|
||||
|
||||
class StageLoad:
|
||||
def __init__(self, repo: "Repo") -> None:
|
||||
self.repo = repo
|
||||
|
||||
def from_target(
|
||||
self, target: str, accept_group: bool = False, glob: bool = False,
|
||||
) -> StageList:
|
||||
"""
|
||||
Returns a list of stage from the provided target.
|
||||
(see load method below for further details)
|
||||
"""
|
||||
path, name = parse_target(target, isa_glob=glob)
|
||||
return self.load_all(
|
||||
path=path, name=name, accept_group=accept_group, glob=glob,
|
||||
)
|
||||
|
||||
def get_target(self, target: str) -> "Stage":
|
||||
"""
|
||||
Returns a stage from the provided target.
|
||||
(see load_one method for further details)
|
||||
"""
|
||||
path, name = parse_target(target)
|
||||
return self.load_one(path=path, name=name)
|
||||
|
||||
@staticmethod
|
||||
def _get_filepath(path: str = None, name: str = None) -> str:
|
||||
if path:
|
||||
return path
|
||||
|
||||
path = PIPELINE_FILE
|
||||
logger.debug("Assuming '%s' to be a stage inside '%s'", name, path)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _get_group_keys(stages: "StageLoader", group: str) -> Iterable[str]:
|
||||
from dvc.parsing import JOIN
|
||||
|
||||
for key in stages:
|
||||
assert isinstance(key, str)
|
||||
if key.startswith(f"{group}{JOIN}"):
|
||||
yield key
|
||||
|
||||
def _get_keys(
|
||||
self,
|
||||
stages: "StageLoader",
|
||||
name: str = None,
|
||||
accept_group: bool = False,
|
||||
glob: bool = False,
|
||||
) -> Iterable[str]:
|
||||
|
||||
assert not (accept_group and glob)
|
||||
|
||||
if not name:
|
||||
return stages.keys()
|
||||
|
||||
if accept_group and stages.is_foreach_generated(name):
|
||||
return self._get_group_keys(stages, name)
|
||||
elif glob:
|
||||
return fnmatch.filter(stages.keys(), name)
|
||||
return [name]
|
||||
|
||||
def load_all(
|
||||
self,
|
||||
path: str = None,
|
||||
name: str = None,
|
||||
accept_group: bool = False,
|
||||
glob: bool = False,
|
||||
) -> StageList:
|
||||
"""Load a list of stages from a file.
|
||||
|
||||
Args:
|
||||
path: if not provided, default `dvc.yaml` is assumed.
|
||||
name: required for `dvc.yaml` files, ignored for `.dvc` files.
|
||||
accept_group: if true, all of the the stages generated from `name`
|
||||
foreach are returned.
|
||||
glob: if true, `name` is considered as a glob, which is
|
||||
used to filter list of stages from the given `path`.
|
||||
"""
|
||||
from dvc.stage.loader import SingleStageLoader, StageLoader
|
||||
|
||||
path = self._get_filepath(path, name)
|
||||
dvcfile = Dvcfile(self.repo, path)
|
||||
# `dvcfile.stages` is not cached
|
||||
stages = dvcfile.stages # type: ignore
|
||||
|
||||
if isinstance(stages, SingleStageLoader):
|
||||
stage = stages[name]
|
||||
return [stage]
|
||||
|
||||
assert isinstance(stages, StageLoader)
|
||||
keys = self._get_keys(stages, name, accept_group, glob)
|
||||
return [stages[key] for key in keys]
|
||||
|
||||
def load_one(self, path: str = None, name: str = None) -> "Stage":
|
||||
"""Load a single stage from a file.
|
||||
|
||||
Args:
|
||||
path: if not provided, default `dvc.yaml` is assumed.
|
||||
name: required for `dvc.yaml` files, ignored for `.dvc` files.
|
||||
"""
|
||||
path = self._get_filepath(path, name)
|
||||
dvcfile = Dvcfile(self.repo, path)
|
||||
|
||||
stages = dvcfile.stages # type: ignore
|
||||
|
||||
return stages[name]
|
||||
|
||||
def load_file(self, path: str = None) -> StageList:
|
||||
"""Load all of the stages from a file."""
|
||||
return self.load_all(path)
|
||||
|
||||
def load_glob(self, path: str, expr: str = None):
|
||||
"""Load stages from `path`, filtered with `expr` provided."""
|
||||
return self.load_all(path, expr, glob=True)
|
||||
|
||||
@property
|
||||
def tree(self):
|
||||
return self.repo.tree
|
||||
|
||||
@property
|
||||
def graph(self) -> "DiGraph":
|
||||
return self.repo.graph
|
||||
|
||||
def collect(
|
||||
self,
|
||||
target: str = None,
|
||||
with_deps: bool = False,
|
||||
recursive: bool = False,
|
||||
graph: "DiGraph" = None,
|
||||
accept_group: bool = False,
|
||||
glob: bool = False,
|
||||
) -> StageIter:
|
||||
"""Collect list of stages from the provided target.
|
||||
|
||||
Args:
|
||||
target: if not provided, all of the stages in the graph are
|
||||
returned.
|
||||
Target can be:
|
||||
- a stage name in the `dvc.yaml` file.
|
||||
- a path to `dvc.yaml` or `.dvc` file.
|
||||
- in case of a stage to a dvc.yaml file in a different
|
||||
directory than current working directory, it can be a path
|
||||
to dvc.yaml file, followed by a colon `:`, followed by stage
|
||||
name (eg: `../dvc.yaml:build`).
|
||||
- in case of `recursive`, it can be a path to a directory.
|
||||
- in case of `accept_group`, it can be a group name of
|
||||
`foreach` generated stage.
|
||||
- in case of `glob`, it can be a wildcard pattern to match
|
||||
stages. Example: `build*` for stages in `dvc.yaml` file, or
|
||||
`../dvc.yaml:build*` for stages in dvc.yaml in a different
|
||||
directory.
|
||||
Note that, glob only applies for the stage name, not to the
|
||||
file, so `**/dvc.yaml:build*` is not possible.
|
||||
with_deps: if true, the stages including their dependencies are
|
||||
returned.
|
||||
recursive: if true and if `target` is a directory, all of the
|
||||
stages inside that directory is returned.
|
||||
graph: graph to use. Defaults to `repo.graph`.
|
||||
accept_group: if true, all of the `foreach` generated stages of
|
||||
the specified target is returned.
|
||||
glob: Use `target` as a pattern to match stages in a file.
|
||||
"""
|
||||
if not target:
|
||||
return list(graph) if graph else self.repo.stages
|
||||
|
||||
if recursive and self.repo.tree.isdir(target):
|
||||
path = os.path.abspath(target)
|
||||
return collect_inside_path(path, graph or self.graph)
|
||||
|
||||
stages = self.from_target(target, accept_group=accept_group, glob=glob)
|
||||
if not with_deps:
|
||||
return stages
|
||||
|
||||
return _collect_with_deps(stages, graph or self.graph)
|
||||
|
||||
def collect_granular(
|
||||
self,
|
||||
target: str = None,
|
||||
with_deps: bool = False,
|
||||
recursive: bool = False,
|
||||
graph: "DiGraph" = None,
|
||||
accept_group: bool = False,
|
||||
) -> List[StageInfo]:
|
||||
"""Collects a list of (stage, filter_info) from the given target.
|
||||
|
||||
Priority is in the order of following in case of ambiguity:
|
||||
- .dvc file or .yaml file
|
||||
- dir if recursive and directory exists
|
||||
- stage_name
|
||||
- output file
|
||||
|
||||
Args:
|
||||
target: if not provided, all of the stages without any filters are
|
||||
returned.
|
||||
If `target` is a path to a dvc-tracked output,
|
||||
a (stage, output_path_info) is returned.
|
||||
Otherwise, the details above for `target` in `collect()`
|
||||
applies.
|
||||
|
||||
(see `collect()` for other arguments)
|
||||
"""
|
||||
if not target:
|
||||
return [StageInfo(stage) for stage in self.repo.stages]
|
||||
|
||||
stages, file, _ = _collect_specific_target(
|
||||
self, target, with_deps, recursive, accept_group
|
||||
)
|
||||
if not stages:
|
||||
if not (recursive and self.tree.isdir(target)):
|
||||
try:
|
||||
(out,) = self.repo.find_outs_by_path(target, strict=False)
|
||||
filter_info = PathInfo(os.path.abspath(target))
|
||||
return [StageInfo(out.stage, filter_info)]
|
||||
except OutputNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
stages = self.collect(
|
||||
target,
|
||||
with_deps,
|
||||
recursive,
|
||||
graph,
|
||||
accept_group=accept_group,
|
||||
)
|
||||
except StageFileDoesNotExistError as exc:
|
||||
# collect() might try to use `target` as a stage name
|
||||
# and throw error that dvc.yaml does not exist, whereas it
|
||||
# should say that both stage name and file does not exist.
|
||||
if file and is_valid_filename(file):
|
||||
raise
|
||||
raise NoOutputOrStageError(target, exc.file) from exc
|
||||
except StageNotFound as exc:
|
||||
raise NoOutputOrStageError(target, exc.file) from exc
|
||||
|
||||
return [StageInfo(stage) for stage in stages]
|
||||
+4
-2
@@ -29,7 +29,9 @@ def _joint_status(pairs):
|
||||
def _local_status(self, targets=None, with_deps=False, recursive=False):
|
||||
targets = targets or [None]
|
||||
pairs = cat(
|
||||
self.collect_granular(t, with_deps=with_deps, recursive=recursive)
|
||||
self.stage.collect_granular(
|
||||
t, with_deps=with_deps, recursive=recursive
|
||||
)
|
||||
for t in targets
|
||||
)
|
||||
|
||||
@@ -74,7 +76,7 @@ def _cloud_status(
|
||||
|
||||
{ "bar": "deleted" }
|
||||
"""
|
||||
import dvc.cache.base as cloud
|
||||
import dvc.remote.base as cloud
|
||||
|
||||
used = self.used_cache(
|
||||
targets,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from funcy import first
|
||||
from pygtrie import Trie
|
||||
|
||||
from dvc.exceptions import OutputDuplicationError, OverlappingOutputPathsError
|
||||
|
||||
|
||||
def build_outs_trie(stages):
|
||||
outs = Trie()
|
||||
|
||||
for stage in filter(bool, stages): # bug? not using it later
|
||||
for out in stage.outs:
|
||||
out_key = out.path_info.parts
|
||||
|
||||
# Check for dup outs
|
||||
if out_key in outs:
|
||||
dup_stages = [stage, outs[out_key].stage]
|
||||
raise OutputDuplicationError(str(out), dup_stages)
|
||||
|
||||
# Check for overlapping outs
|
||||
if outs.has_subtrie(out_key):
|
||||
parent = out
|
||||
overlapping = first(outs.values(prefix=out_key))
|
||||
else:
|
||||
parent = outs.shortest_prefix(out_key).value
|
||||
overlapping = out
|
||||
if parent and overlapping:
|
||||
msg = (
|
||||
"Paths for outs:\n'{}'('{}')\n'{}'('{}')\n"
|
||||
"overlap. To avoid unpredictable behaviour, "
|
||||
"rerun command with non overlapping outs paths."
|
||||
).format(
|
||||
str(parent),
|
||||
parent.stage.addressing,
|
||||
str(overlapping),
|
||||
overlapping.stage.addressing,
|
||||
)
|
||||
raise OverlappingOutputPathsError(parent, overlapping, msg)
|
||||
|
||||
outs[out_key] = out
|
||||
|
||||
return outs
|
||||
+2
-2
@@ -3,7 +3,7 @@ from . import locked
|
||||
|
||||
|
||||
@locked
|
||||
def update(self, targets, rev=None, recursive=False):
|
||||
def update(self, targets=None, rev=None, recursive=False):
|
||||
if not targets:
|
||||
targets = [None]
|
||||
|
||||
@@ -12,7 +12,7 @@ def update(self, targets, rev=None, recursive=False):
|
||||
|
||||
stages = set()
|
||||
for target in targets:
|
||||
stages.update(self.collect(target, recursive=recursive))
|
||||
stages.update(self.stage.collect(target, recursive=recursive))
|
||||
|
||||
for stage in stages:
|
||||
stage.update(rev)
|
||||
|
||||
+44
-22
@@ -1,7 +1,9 @@
|
||||
from voluptuous import Any, Optional, Required, Schema
|
||||
|
||||
from dvc import dependency, output
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.output import CHECKSUMS_SCHEMA, BaseOutput
|
||||
from dvc.parsing import DO_KWD, FOREACH_KWD, SET_KWD, VARS_KWD
|
||||
from dvc.stage.params import StageParams
|
||||
|
||||
STAGES = "stages"
|
||||
@@ -15,9 +17,15 @@ SINGLE_STAGE_SCHEMA = {
|
||||
StageParams.PARAM_FROZEN: bool,
|
||||
StageParams.PARAM_META: object,
|
||||
StageParams.PARAM_ALWAYS_CHANGED: bool,
|
||||
StageParams.PARAM_DESC: str,
|
||||
}
|
||||
|
||||
DATA_SCHEMA = {**CHECKSUMS_SCHEMA, Required("path"): str}
|
||||
DATA_SCHEMA = {
|
||||
**CHECKSUMS_SCHEMA,
|
||||
Required("path"): str,
|
||||
HashInfo.PARAM_SIZE: int,
|
||||
HashInfo.PARAM_NFILES: int,
|
||||
}
|
||||
LOCK_FILE_STAGE_SCHEMA = {
|
||||
Required(StageParams.PARAM_CMD): str,
|
||||
StageParams.PARAM_DEPS: [DATA_SCHEMA],
|
||||
@@ -27,7 +35,12 @@ LOCK_FILE_STAGE_SCHEMA = {
|
||||
LOCKFILE_SCHEMA = {str: LOCK_FILE_STAGE_SCHEMA}
|
||||
|
||||
OUT_PSTAGE_DETAILED_SCHEMA = {
|
||||
str: {BaseOutput.PARAM_CACHE: bool, BaseOutput.PARAM_PERSIST: bool}
|
||||
str: {
|
||||
BaseOutput.PARAM_CACHE: bool,
|
||||
BaseOutput.PARAM_PERSIST: bool,
|
||||
BaseOutput.PARAM_CHECKPOINT: bool,
|
||||
BaseOutput.PARAM_DESC: str,
|
||||
}
|
||||
}
|
||||
|
||||
PLOT_PROPS = {
|
||||
@@ -47,27 +60,36 @@ PLOT_PSTAGE_SCHEMA = {str: Any(PLOT_PROPS_SCHEMA, [PLOT_PROPS_SCHEMA])}
|
||||
|
||||
PARAM_PSTAGE_NON_DEFAULT_SCHEMA = {str: [str]}
|
||||
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {
|
||||
str: {
|
||||
StageParams.PARAM_CMD: str,
|
||||
Optional(StageParams.PARAM_WDIR): str,
|
||||
Optional(StageParams.PARAM_DEPS): [str],
|
||||
Optional(StageParams.PARAM_PARAMS): [
|
||||
Any(str, PARAM_PSTAGE_NON_DEFAULT_SCHEMA)
|
||||
],
|
||||
Optional(StageParams.PARAM_FROZEN): bool,
|
||||
Optional(StageParams.PARAM_META): object,
|
||||
Optional(StageParams.PARAM_ALWAYS_CHANGED): bool,
|
||||
Optional(StageParams.PARAM_OUTS): [
|
||||
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
|
||||
],
|
||||
Optional(StageParams.PARAM_METRICS): [
|
||||
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
|
||||
],
|
||||
Optional(StageParams.PARAM_PLOTS): [Any(str, PLOT_PSTAGE_SCHEMA)],
|
||||
}
|
||||
VARS_SCHEMA = [str, dict]
|
||||
|
||||
STAGE_DEFINITION = {
|
||||
StageParams.PARAM_CMD: str,
|
||||
Optional(SET_KWD): dict,
|
||||
Optional(StageParams.PARAM_WDIR): str,
|
||||
Optional(StageParams.PARAM_DEPS): [str],
|
||||
Optional(StageParams.PARAM_PARAMS): [Any(str, dict)],
|
||||
Optional(VARS_KWD): VARS_SCHEMA,
|
||||
Optional(StageParams.PARAM_FROZEN): bool,
|
||||
Optional(StageParams.PARAM_META): object,
|
||||
Optional(StageParams.PARAM_DESC): str,
|
||||
Optional(StageParams.PARAM_ALWAYS_CHANGED): bool,
|
||||
Optional(StageParams.PARAM_OUTS): [Any(str, OUT_PSTAGE_DETAILED_SCHEMA)],
|
||||
Optional(StageParams.PARAM_METRICS): [
|
||||
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
|
||||
],
|
||||
Optional(StageParams.PARAM_PLOTS): [Any(str, PLOT_PSTAGE_SCHEMA)],
|
||||
}
|
||||
|
||||
FOREACH_IN = {
|
||||
Optional(SET_KWD): dict,
|
||||
Required(FOREACH_KWD): Any(dict, list, str),
|
||||
Required(DO_KWD): STAGE_DEFINITION,
|
||||
}
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {str: Any(STAGE_DEFINITION, FOREACH_IN)}
|
||||
MULTI_STAGE_SCHEMA = {
|
||||
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
|
||||
VARS_KWD: VARS_SCHEMA,
|
||||
}
|
||||
MULTI_STAGE_SCHEMA = {STAGES: SINGLE_PIPELINE_STAGE_SCHEMA}
|
||||
|
||||
COMPILED_SINGLE_STAGE_SCHEMA = Schema(SINGLE_STAGE_SCHEMA)
|
||||
COMPILED_MULTI_STAGE_SCHEMA = Schema(MULTI_STAGE_SCHEMA)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Schemes:
|
||||
SSH = "ssh"
|
||||
HDFS = "hdfs"
|
||||
WEBHDFS = "webhdfs"
|
||||
S3 = "s3"
|
||||
AZURE = "azure"
|
||||
HTTP = "http"
|
||||
|
||||
+5
-1
@@ -41,7 +41,7 @@ class Base:
|
||||
self._root_dir = os.path.realpath(root_dir)
|
||||
|
||||
@property
|
||||
def root_dir(self):
|
||||
def root_dir(self) -> str:
|
||||
return self._root_dir
|
||||
|
||||
def __repr__(self):
|
||||
@@ -66,6 +66,10 @@ class Base:
|
||||
"""
|
||||
return True
|
||||
|
||||
def is_ignored(self, path): # pylint: disable=unused-argument
|
||||
"""Returns whether or not path is ignored by SCM."""
|
||||
return False
|
||||
|
||||
def ignore(self, path): # pylint: disable=unused-argument
|
||||
"""Makes SCM ignore a specified path."""
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
from functools import partial
|
||||
from contextlib import contextmanager
|
||||
from functools import partial, partialmethod
|
||||
from typing import Optional
|
||||
|
||||
from funcy import cached_property
|
||||
from funcy import cached_property, first
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
|
||||
from dvc.exceptions import GitHookAlreadyExistsError
|
||||
@@ -21,6 +23,11 @@ from dvc.utils import fix_env, is_binary, relpath
|
||||
from dvc.utils.fs import path_isin
|
||||
from dvc.utils.serialize import modify_yaml
|
||||
|
||||
from .backend.base import NoGitBackendError
|
||||
from .backend.dulwich import DulwichBackend
|
||||
from .backend.gitpython import GitPythonBackend
|
||||
from .stash import Stash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -61,6 +68,8 @@ class Git(Base):
|
||||
|
||||
GITIGNORE = ".gitignore"
|
||||
GIT_DIR = ".git"
|
||||
DEFAULT_BACKENDS = (DulwichBackend, GitPythonBackend)
|
||||
LOCAL_BRANCH_PREFIX = "refs/heads/"
|
||||
|
||||
def __init__(self, root_dir=os.curdir, search_parent_directories=True):
|
||||
"""Git class constructor.
|
||||
@@ -88,10 +97,16 @@ class Git(Base):
|
||||
self.ignored_paths = []
|
||||
self.files_to_track = set()
|
||||
|
||||
self.backends = [backend(self) for backend in self.DEFAULT_BACKENDS]
|
||||
|
||||
@property
|
||||
def root_dir(self):
|
||||
def root_dir(self) -> str:
|
||||
return self.repo.working_tree_dir
|
||||
|
||||
@cached_property
|
||||
def stash(self):
|
||||
return Stash(self)
|
||||
|
||||
@staticmethod
|
||||
def clone(url, to_path, rev=None, shallow_branch=None):
|
||||
import git
|
||||
@@ -182,19 +197,10 @@ class Git(Base):
|
||||
|
||||
return entry, gitignore
|
||||
|
||||
def _ignored(self, path):
|
||||
from git.exc import GitCommandError
|
||||
|
||||
try:
|
||||
self.repo.git.check_ignore(path)
|
||||
return True
|
||||
except GitCommandError:
|
||||
return False
|
||||
|
||||
def ignore(self, path):
|
||||
entry, gitignore = self._get_gitignore(path)
|
||||
|
||||
if self._ignored(path):
|
||||
if self.is_ignored(path):
|
||||
return
|
||||
|
||||
msg = "Adding '{}' to '{}'.".format(relpath(path), relpath(gitignore))
|
||||
@@ -257,11 +263,11 @@ class Git(Base):
|
||||
def commit(self, msg):
|
||||
self.repo.index.commit(msg)
|
||||
|
||||
def checkout(self, branch, create_new=False):
|
||||
def checkout(self, branch, create_new=False, **kwargs):
|
||||
if create_new:
|
||||
self.repo.git.checkout("HEAD", b=branch)
|
||||
self.repo.git.checkout("HEAD", b=branch, **kwargs)
|
||||
else:
|
||||
self.repo.git.checkout(branch)
|
||||
self.repo.git.checkout(branch, **kwargs)
|
||||
|
||||
def pull(self, **kwargs):
|
||||
infos = self.repo.remote().pull(**kwargs)
|
||||
@@ -288,8 +294,8 @@ class Git(Base):
|
||||
def is_tracked(self, path):
|
||||
return bool(self.repo.git.ls_files(path))
|
||||
|
||||
def is_dirty(self):
|
||||
return self.repo.is_dirty()
|
||||
def is_dirty(self, **kwargs):
|
||||
return self.repo.is_dirty(**kwargs)
|
||||
|
||||
def active_branch(self):
|
||||
return self.repo.active_branch.name
|
||||
@@ -448,6 +454,8 @@ class Git(Base):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
for backend in self.backends:
|
||||
backend.close()
|
||||
self.repo.close()
|
||||
|
||||
@cached_property
|
||||
@@ -469,3 +477,94 @@ class Git(Base):
|
||||
@property
|
||||
def no_commits(self):
|
||||
return not self.list_all_commits()
|
||||
|
||||
def branch_revs(self, branch: str, end_rev: Optional[str] = None):
|
||||
"""Iterate over revisions in a given branch (from newest to oldest).
|
||||
|
||||
If end_rev is set, iterator will stop when the specified revision is
|
||||
reached.
|
||||
"""
|
||||
commit = self.resolve_commit(branch)
|
||||
while commit is not None:
|
||||
yield commit.hexsha
|
||||
commit = first(commit.parents)
|
||||
if commit and commit.hexsha == end_rev:
|
||||
return
|
||||
|
||||
def resolve_commit(self, rev):
|
||||
"""Return Commit object for the specified revision."""
|
||||
from git.exc import BadName, GitCommandError
|
||||
from git.objects.tag import TagObject
|
||||
|
||||
try:
|
||||
commit = self.repo.rev_parse(rev)
|
||||
except (BadName, GitCommandError):
|
||||
commit = None
|
||||
if isinstance(commit, TagObject):
|
||||
commit = commit.object
|
||||
return commit
|
||||
|
||||
def _backend_func(self, name, *args, **kwargs):
|
||||
for backend in self.backends:
|
||||
try:
|
||||
func = getattr(backend, name)
|
||||
return func(*args, **kwargs)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
raise NoGitBackendError(name)
|
||||
|
||||
is_ignored = partialmethod(_backend_func, "is_ignored")
|
||||
set_ref = partialmethod(_backend_func, "set_ref")
|
||||
get_ref = partialmethod(_backend_func, "get_ref")
|
||||
remove_ref = partialmethod(_backend_func, "remove_ref")
|
||||
iter_refs = partialmethod(_backend_func, "iter_refs")
|
||||
get_refs_containing = partialmethod(_backend_func, "get_refs_containing")
|
||||
push_refspec = partialmethod(_backend_func, "push_refspec")
|
||||
fetch_refspecs = partialmethod(_backend_func, "fetch_refspecs")
|
||||
_stash_iter = partialmethod(_backend_func, "_stash_iter")
|
||||
_stash_push = partialmethod(_backend_func, "_stash_push")
|
||||
_stash_apply = partialmethod(_backend_func, "_stash_apply")
|
||||
reflog_delete = partialmethod(_backend_func, "reflog_delete")
|
||||
describe = partialmethod(_backend_func, "describe")
|
||||
diff = partialmethod(_backend_func, "diff")
|
||||
|
||||
@contextmanager
|
||||
def detach_head(self, rev: Optional[str] = None):
|
||||
"""Context manager for performing detached HEAD SCM operations.
|
||||
|
||||
Detaches and restores HEAD similar to interactive git rebase.
|
||||
Restore is equivalent to 'reset --soft', meaning the caller is
|
||||
is responsible for preserving & restoring working tree state
|
||||
(i.e. via stash) when applicable.
|
||||
|
||||
Yields revision of detached head.
|
||||
"""
|
||||
if not rev:
|
||||
rev = "HEAD"
|
||||
orig_head = self.get_ref("HEAD", follow=False)
|
||||
logger.debug("Detaching HEAD at '%s'", rev)
|
||||
self.checkout(rev, detach=True)
|
||||
try:
|
||||
yield self.get_ref("HEAD")
|
||||
finally:
|
||||
prefix = self.LOCAL_BRANCH_PREFIX
|
||||
if orig_head.startswith(prefix):
|
||||
orig_head = orig_head[len(prefix) :]
|
||||
logger.debug("Restore HEAD to '%s'", orig_head)
|
||||
self.checkout(orig_head)
|
||||
|
||||
@contextmanager
|
||||
def stash_workspace(self, **kwargs):
|
||||
"""Stash and restore any workspace changes.
|
||||
|
||||
Yields revision of the stash commit. Yields None if there were no
|
||||
changes to stash.
|
||||
"""
|
||||
logger.debug("Stashing workspace")
|
||||
rev = self.stash.push(**kwargs)
|
||||
try:
|
||||
yield rev
|
||||
finally:
|
||||
if rev:
|
||||
logger.debug("Restoring stashed workspace")
|
||||
self.stash.pop()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user