mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-04 07:18:27 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4496dbf5b5 | ||
|
|
334afebd31 | ||
|
|
775e1e8374 | ||
|
|
548c2dcc1f | ||
|
|
15b1e7072b | ||
|
|
f0a729df4d | ||
|
|
7424f22e2a | ||
|
|
9ee5018c63 | ||
|
|
026047aa80 | ||
|
|
3878c50ea8 | ||
|
|
3ffb6320ac | ||
|
|
52fe76cefc | ||
|
|
d0e1c88792 | ||
|
|
fcdb503b4e | ||
|
|
3d2493a391 | ||
|
|
fc6999ad17 | ||
|
|
e0b3e98039 | ||
|
|
04bd460136 | ||
|
|
d7a35b9852 | ||
|
|
ab22118cd4 | ||
|
|
b0dbcdcdb6 | ||
|
|
fb756c0538 | ||
|
|
00267706b7 | ||
|
|
1fcb94dee8 | ||
|
|
dc6abba679 | ||
|
|
fdc455a08f | ||
|
|
7b590e7c5d | ||
|
|
e3a7857b13 | ||
|
|
644c2df5e8 | ||
|
|
de24794cd5 | ||
|
|
1ea8cc9e54 | ||
|
|
738dfd49cf | ||
|
|
a5ab01390f | ||
|
|
a664059eff | ||
|
|
c94ab359b6 | ||
|
|
9643ff7229 | ||
|
|
8da40af3e4 | ||
|
|
36935bdce2 | ||
|
|
2f030e870b | ||
|
|
a2adc956b9 | ||
|
|
deaa27dde2 | ||
|
|
b784216f68 | ||
|
|
32bb46f34f | ||
|
|
2cbd46ba75 | ||
|
|
954e4784ab | ||
|
|
62edbaf07c | ||
|
|
e928034fac | ||
|
|
749fec381f | ||
|
|
21de21fe37 | ||
|
|
0756af5012 | ||
|
|
2457633990 | ||
|
|
46bc89319a | ||
|
|
4a82974da4 | ||
|
|
688da04d4a | ||
|
|
e1b34442aa | ||
|
|
38dd1c9fe7 | ||
|
|
b31bd1cb30 | ||
|
|
2b3c30cb0c | ||
|
|
ebaffd728e | ||
|
|
ece7d6ba67 | ||
|
|
cf5d461f4d | ||
|
|
ff3b686058 | ||
|
|
0b9cd4c93c | ||
|
|
15488ebefd | ||
|
|
6a3c516b71 | ||
|
|
de768375ed | ||
|
|
2f88fa88c4 | ||
|
|
01abf7c19c | ||
|
|
4ebacf1e0a | ||
|
|
5e69e29b2f | ||
|
|
bc5611968f | ||
|
|
8c19492ba6 | ||
|
|
61be19b9b5 | ||
|
|
424396b068 | ||
|
|
8b16af965c | ||
|
|
cf7e534e2d | ||
|
|
2931d30ca5 | ||
|
|
7fa90e425d | ||
|
|
54a48e3256 | ||
|
|
1ca2dc19c2 | ||
|
|
67ddb7b41f | ||
|
|
61bf1194c3 | ||
|
|
ccdcb1f4c1 | ||
|
|
226322d312 | ||
|
|
e1f43965a2 | ||
|
|
18e80b25ac | ||
|
|
131099750e | ||
|
|
d1cc143ef2 | ||
|
|
93a298343f | ||
|
|
636a019289 | ||
|
|
79e8e4edaf | ||
|
|
1f495d66dd | ||
|
|
4564e8be30 | ||
|
|
d460d18942 | ||
|
|
4901765cd4 | ||
|
|
24be96fce9 | ||
|
|
981dd401fa | ||
|
|
9887de6a65 | ||
|
|
8a270329ed | ||
|
|
747cb649f7 | ||
|
|
fc321683d8 | ||
|
|
56500601bd | ||
|
|
4f8432886d | ||
|
|
54402f3de5 | ||
|
|
1633245769 | ||
|
|
f52763a0b3 | ||
|
|
4ee3d0d602 | ||
|
|
777a23afac | ||
|
|
e2b5ab996f | ||
|
|
fcbbfac7f8 | ||
|
|
1a35128457 | ||
|
|
69c63a8374 | ||
|
|
4013256c91 | ||
|
|
d6bb4341dd | ||
|
|
be2e0778b4 | ||
|
|
a61291039e | ||
|
|
d5fcf7377d | ||
|
|
b9d685a650 | ||
|
|
334556f07d | ||
|
|
2e47674a46 | ||
|
|
c31ec3038a | ||
|
|
54deca73d3 | ||
|
|
1d9acdc8f3 | ||
|
|
bc7d952e88 | ||
|
|
718c9c98ce | ||
|
|
fcca6361ed | ||
|
|
714503a9e8 | ||
|
|
01a6580b3d | ||
|
|
026c4fa47d | ||
|
|
6c352eeaf0 | ||
|
|
f789cdff89 | ||
|
|
bfb98e9288 | ||
|
|
a82d6e4173 | ||
|
|
05c1e76091 | ||
|
|
adce620621 | ||
|
|
1c14a7e202 | ||
|
|
dac17e247d | ||
|
|
d89c16dfe8 | ||
|
|
a2a6f8bd58 | ||
|
|
1d196b4df6 | ||
|
|
2f2e7121eb | ||
|
|
de0fe94cd0 | ||
|
|
5d1ad03fe9 | ||
|
|
9f1bd98fcc | ||
|
|
41e6c22e7d | ||
|
|
b82104a608 | ||
|
|
14745039a4 | ||
|
|
958aa265e7 | ||
|
|
ac24b5c6f0 | ||
|
|
5b352491d4 | ||
|
|
32b5b33329 | ||
|
|
4aecb12988 | ||
|
|
ef944e6509 | ||
|
|
e31364979b | ||
|
|
8cd4eeac18 | ||
|
|
6ae0be2179 | ||
|
|
70ddb7c931 | ||
|
|
c4bc4c83e8 | ||
|
|
31e9cc2e40 | ||
|
|
b8e40f5091 | ||
|
|
1cfccef22e | ||
|
|
44392941af | ||
|
|
023dec4aa6 | ||
|
|
b773ba73cf | ||
|
|
0b1d4f10d6 | ||
|
|
8b41655894 | ||
|
|
35f125f3df | ||
|
|
00925029b4 | ||
|
|
9c772f5f6c | ||
|
|
e3cfa7348c | ||
|
|
9b5740555c | ||
|
|
ce9205a3cc | ||
|
|
5cb36fc983 | ||
|
|
64f038fda8 | ||
|
|
a8f0c5241c | ||
|
|
12c7013062 | ||
|
|
9cb32b5d29 | ||
|
|
765d2ed61b | ||
|
|
78458ee5a1 | ||
|
|
07dcf6953c | ||
|
|
172032d3b3 | ||
|
|
4e5a81402d | ||
|
|
fef635c6f0 | ||
|
|
8d1934d494 | ||
|
|
dea1950ba0 | ||
|
|
e2a5741374 | ||
|
|
ab23bcd2e8 | ||
|
|
4759517c8d | ||
|
|
7afff1ef29 | ||
|
|
d128d51a2b | ||
|
|
afa0a30a73 | ||
|
|
6bb654750a | ||
|
|
35dd1fda9c | ||
|
|
d8b03737ee | ||
|
|
73750df73f | ||
|
|
11d906ee51 | ||
|
|
b823ce48e1 | ||
|
|
4061b74cf3 | ||
|
|
baa9a5701f | ||
|
|
00de04c82d | ||
|
|
ed1c3a7a15 | ||
|
|
fcf2e3522c | ||
|
|
dfdb24d89d | ||
|
|
99ffa7c6fd | ||
|
|
9592246746 | ||
|
|
815444ffdc | ||
|
|
2f48bb4089 | ||
|
|
c8d2f4f1fb | ||
|
|
9ca3c05f6d |
+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
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# Config for https://github.com/apps/release-drafter
|
||||
branches:
|
||||
- master
|
||||
name-template: "$RESOLVED_VERSION 🦉 "
|
||||
tag-template: "$RESOLVED_VERSION"
|
||||
categories:
|
||||
- title: "🚀 New Features and Enhancements "
|
||||
labels:
|
||||
- "feature"
|
||||
- "enhancement"
|
||||
- "ui"
|
||||
- title: "💣 Breaking Changes "
|
||||
labels:
|
||||
- "breaking-change"
|
||||
- title: "🏇 Optimizations "
|
||||
labels:
|
||||
- "optimize"
|
||||
- "performance"
|
||||
- title: "🐛 Bug Fixes "
|
||||
labels:
|
||||
- "bugfix"
|
||||
- "bug"
|
||||
- title: "🔨 Maintenance "
|
||||
labels:
|
||||
- "maintenance"
|
||||
- "refactoring"
|
||||
- "chore"
|
||||
- "build"
|
||||
- "ci"
|
||||
change-template: "- $TITLE (#$NUMBER) @$AUTHOR"
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- "major"
|
||||
minor:
|
||||
labels:
|
||||
- "minor"
|
||||
patch:
|
||||
labels:
|
||||
- "patch"
|
||||
default: patch
|
||||
exclude-labels:
|
||||
- "skip-changelog"
|
||||
template: |
|
||||
> Refer to https://dvc.org/doc/install for installation instructions.
|
||||
|
||||
## Changes
|
||||
|
||||
$CHANGES
|
||||
|
||||
Thanks again to $CONTRIBUTORS for the contributions! 🎉
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Lint Python
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install requirements
|
||||
run: bash ./scripts/ci/install.sh
|
||||
- name: Check Patch
|
||||
run: ./scripts/ci/check_patch.sh
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Build packages
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
release:
|
||||
types: [released]
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-16.04
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up Ruby 2.4
|
||||
uses: actions/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 2.4
|
||||
- name: Build
|
||||
run: ./scripts/build_posix.sh
|
||||
- name: Publish deb
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: dvc_${{ github.event.release.tag_name }}_amd64.deb
|
||||
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
|
||||
asset_content_type: binary/octet-stream
|
||||
- name: Publish rpm
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm
|
||||
asset_content_type: binary/octet-stream
|
||||
mac:
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Set up Ruby 2.4
|
||||
uses: actions/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 2.4
|
||||
- name: Build
|
||||
run: ./scripts/build_posix.sh
|
||||
- name: Publish
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: dvc-${{ github.event.release.tag_name }}.pkg
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}.pkg
|
||||
asset_content_type: binary/octet-stream
|
||||
windows:
|
||||
runs-on: windows-2019
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Build
|
||||
run: scripts\build_windows.cmd
|
||||
shell: cmd
|
||||
- name: Publish
|
||||
if: github.event_name == 'release'
|
||||
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
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}.exe
|
||||
asset_content_type: binary/octet-stream
|
||||
python:
|
||||
runs-on: ubuntu-16.04
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: 3.7
|
||||
- name: Install
|
||||
run: pip install ".[all,tests]"
|
||||
- name: Build packages
|
||||
run: ./scripts/build_package.sh
|
||||
- name: Publish packages to PyPI
|
||||
if: github.event_name == 'release'
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
snap:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Build the package
|
||||
uses: snapcore/action-build@v1
|
||||
id: snapcraft
|
||||
- name: Test the package
|
||||
run: |
|
||||
sudo snap install --dangerous --classic ${{ steps.snapcraft.outputs.snap }}
|
||||
dvc version
|
||||
sudo snap remove dvc
|
||||
- name: Publish to the edge channel
|
||||
uses: snapcore/action-publish@v1
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
store_login: ${{ secrets.STORE_LOGIN }}
|
||||
snap: ${{ steps.snapcraft.outputs.snap }}
|
||||
release: edge
|
||||
- name: Publish to the stable channel
|
||||
uses: snapcore/action-publish@v1
|
||||
if: github.event_name == 'release'
|
||||
with:
|
||||
store_login: ${{ secrets.STORE_LOGIN }}
|
||||
snap: ${{ steps.snapcraft.outputs.snap }}
|
||||
release: stable
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Tests
|
||||
|
||||
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/[email protected]
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install requirements
|
||||
run: pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
|
||||
- name: Check README
|
||||
run: python setup.py checkdocs
|
||||
- name: Check formatting
|
||||
run: pre-commit run --all-files
|
||||
tests:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-18.04, windows-2019, macos-10.15]
|
||||
pyv: ["3.6", "3.7", "3.8", "3.9"]
|
||||
include:
|
||||
- os: "macos-11.0"
|
||||
pyv: "3.9"
|
||||
exclude:
|
||||
- os: macos-10.15
|
||||
pyv: "3.9"
|
||||
|
||||
steps:
|
||||
- uses: actions/[email protected]
|
||||
- name: Set up Python
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
python-version: ${{ matrix.pyv }}
|
||||
- name: install
|
||||
run: |
|
||||
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 --all --cov-report=xml --cov-report=term --tap-combined
|
||||
- name: upload coverage report
|
||||
uses: codecov/[email protected]
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
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
|
||||
+1
-2
@@ -1,12 +1,11 @@
|
||||
neatlynx.conf
|
||||
neatlynx/__pycache__
|
||||
.idea
|
||||
cache
|
||||
/cache
|
||||
*.pyc
|
||||
.env/
|
||||
.env2.7/
|
||||
|
||||
cache
|
||||
.dvc.conf.lock
|
||||
.DS_Store
|
||||
build/
|
||||
|
||||
@@ -4,15 +4,11 @@ repos:
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 19.10b0
|
||||
- repo: https://github.com/asottile/seed-isort-config
|
||||
rev: v2.1.1
|
||||
hooks:
|
||||
- id: seed-isort-config
|
||||
- hooks:
|
||||
- id: isort
|
||||
language_version: python3
|
||||
repo: https://github.com/timothycrosley/isort
|
||||
rev: 4.3.21
|
||||
rev: 5.4.2
|
||||
- hooks:
|
||||
- id: flake8
|
||||
language_version: python3
|
||||
@@ -22,7 +18,7 @@ repos:
|
||||
- flake8-debugger
|
||||
- flake8-string-format
|
||||
repo: https://gitlab.com/pycqa/flake8
|
||||
rev: 3.7.9
|
||||
rev: 3.8.3
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pylint
|
||||
|
||||
@@ -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:v4.3.21
|
||||
command:
|
||||
- isort
|
||||
arguments: []
|
||||
include:
|
||||
- "**/*.py"
|
||||
interpreters:
|
||||
- python
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
- $HOME/.pyenv
|
||||
dist: xenial
|
||||
env:
|
||||
global:
|
||||
- HOMEBREW_NO_AUTO_UPDATE=1
|
||||
- PYENV_ROOT="$HOME/.pyenv"
|
||||
- PATH="$PYENV_ROOT/bin:$PATH"
|
||||
- PIP_CACHE_DIR="$HOME/.cache/pip" # unify pip cache location for all platforms
|
||||
stages:
|
||||
- check
|
||||
- test
|
||||
- build
|
||||
# Travis doesn't support testing python on Mac yet, so we need
|
||||
# to workaround it by installing it directly with brew.
|
||||
#
|
||||
# Note that we specify language as ruby-2.4.3 to help us satisfy
|
||||
# fpm dependencies, as travis runs el capitan with an old ruby
|
||||
# version, that causes fpm to crash.
|
||||
#
|
||||
# Each job will use the previous job's `stage` tag if not specified.
|
||||
jobs:
|
||||
include:
|
||||
# patch formatting checks
|
||||
- name: "Check patch formatting"
|
||||
stage: check
|
||||
os: linux
|
||||
language: python
|
||||
python: 3.7
|
||||
before_install: bash ./scripts/ci/install.sh
|
||||
install:
|
||||
script: ./scripts/ci/check_patch.sh
|
||||
# test jobs
|
||||
- name: "3.7 on Windows"
|
||||
stage: test
|
||||
os: windows # Windows 10.0.17134 N/A Build 17134
|
||||
language: shell # 'language: python' is an error on Travis CI Windows
|
||||
env:
|
||||
- PYTHON_VERSION=3.7.5
|
||||
- PATH=/c/Python37:/c/Python37/Scripts:$PATH
|
||||
- name: "3.8 on Windows"
|
||||
os: windows # Windows 10.0.17134 N/A Build 17134
|
||||
language: shell # 'language: python' is an error on Travis CI Windows
|
||||
env:
|
||||
- PYTHON_VERSION=3.8.0
|
||||
- PATH=/c/Python38:/c/Python38/Scripts:$PATH
|
||||
- name: "3.6 on Linux"
|
||||
language: python
|
||||
python: 3.6
|
||||
- name: "3.7 on Linux"
|
||||
language: python
|
||||
python: 3.7
|
||||
- name: "3.8 on Linux"
|
||||
language: python
|
||||
python: 3.8
|
||||
- name: "3.7 on Mac"
|
||||
os: osx
|
||||
osx_image: xcode11.3
|
||||
# build jobs
|
||||
- name: "Mac pkg"
|
||||
stage: build
|
||||
os: osx
|
||||
osx_image: xcode9.4
|
||||
language: ruby
|
||||
rvm: 2.4.3
|
||||
install:
|
||||
script: ./scripts/build_posix.sh
|
||||
- name: "Windows pkg"
|
||||
os: windows
|
||||
language: shell
|
||||
install:
|
||||
script: ./scripts/build_windows.cmd
|
||||
env:
|
||||
- PYTHON_VERSION=3.7.5
|
||||
- PATH=/c/Python37:/c/Python37/Scripts:$PATH
|
||||
- name: "Linux pkgs"
|
||||
language: python
|
||||
python: 3.7
|
||||
before_install:
|
||||
install:
|
||||
script: ./scripts/build_posix.sh
|
||||
- name: "PyPI pkgs"
|
||||
language: python
|
||||
python: 3.8
|
||||
script: ./scripts/build_package.sh
|
||||
- name: Snapcraft snap
|
||||
language: python
|
||||
python: 3.8
|
||||
addons:
|
||||
snaps:
|
||||
- name: snapcraft
|
||||
channel: stable
|
||||
confinement: classic
|
||||
- name: review-tools
|
||||
channel: stable
|
||||
- name: lxd
|
||||
channel: stable
|
||||
env:
|
||||
- SNAPCRAFT_IMAGE_INFO: |
|
||||
'{"build_url": "$TRAVIS_JOB_WEB_URL"}'
|
||||
- SNAPCRAFT_BUILD_ENVIRONMENT: lxd
|
||||
- SNAPCRAFT_BUILD_INFO: 1 # https://snapcraft.io/blog/introducing-developer-notifications-for-snap-security-updates
|
||||
install:
|
||||
- sudo usermod --append --groups lxd $USER
|
||||
- sudo /snap/bin/lxd.migrate -yes
|
||||
- sudo /snap/bin/lxd waitready
|
||||
- sudo /snap/bin/lxd init --auto
|
||||
script:
|
||||
- ./scripts/build_snap.sh
|
||||
after_failure:
|
||||
- sudo journalctl -u snapd
|
||||
|
||||
before_install:
|
||||
- bash ./scripts/ci/before_install.sh
|
||||
- source env.sh
|
||||
- bash ./scripts/ci/check_python.sh
|
||||
install:
|
||||
- bash ./scripts/ci/install.sh
|
||||
script:
|
||||
- bash ./scripts/ci/script.sh
|
||||
deploy:
|
||||
- provider: releases
|
||||
api_key:
|
||||
secure: nyo17gF4Xl6DFL/KVkMewC805Cv+eK99L7SiYCU+WUAYDIzzFL3YJm7fm8bznPeor7DmpO7uSLSveMnJETBtdurW6JWhCTUss4yD66yFzpk6Z5cvHf/1UA5pdMHkkMM54fRaTRt6L2Yal7RPwGEQTBECLXW1Xmf78Y6+0l8lTvXQ9jXOQSRO/63VDKI5Zww2+XytVicUFlr0hLMGKUuaCjjXcQlAPzBgQz3/5Iz6EtzeTf2o+gTPjocbRbSFPikfiBXUn8XlJ8jBj7i114XJRJffw59KxYGDzxp+enfLBULGNoW0Np2hZqkKkPUIdB08C8XVbRXlFYX8cEYBczjqhv8A05p5F5CnltnqQ0KazcoUP2awPVqn68dWNRF+kqrnMBus8euYKIZ4dy/6bVEKJRa84ESJXZVDcwXTAXRqyovSxOlXqg9ZgAImMKBfKn/fpDX2xIvQPaXcnJxKbmcePfdbC1d0HYJ5jkbNaxKE/T15VvhzIho7mJ2WAv1r8QouQUuzqtpzIGbRn/KXHRUzCtyP0k4+eMKMLFOPzg848WsDarP3v8VYiSy4nFY7ZP7DRd3KttvW5bgVbSeO4jPmTgS6TnnpRwual9BJXSG7bjbfpIMii3bNZ7jWL4zpKT9Jfm8w9RGKLcX9HqkZ3+y60+LtYdhEfBvqsIlWQoIJBXg=
|
||||
file_glob: true
|
||||
file:
|
||||
- dvc*.rpm
|
||||
- dvc*.deb
|
||||
- dvc*.pkg
|
||||
- dvc*.exe
|
||||
skip_cleanup: true
|
||||
on:
|
||||
condition: "$(./scripts/ci/deploy_condition.sh dvc*.rpm dvc*.deb dvc*.pkg dvc*.exe)"
|
||||
tags: true
|
||||
repo: iterative/dvc
|
||||
stage: build
|
||||
- provider: pypi
|
||||
user:
|
||||
secure: "IOzNfgPoG3ICg3+s4AuZCCnawgd/KqMzeiAB9R0B9Xb6ProPQJp9O3GBZrtg5OJMHdWQQtwULeOh/LWk4sFdXMcX1iNEgm5Tb7DwO7TDtltW14jUgN9AqfY2twj4UIsW/ON8w0P8v36ZoQq1kO6Pfgjijdm3w+9h6rpps8rJ6jXYpVZVO+SSzzijWddanMkEz4GgctZ2r7U5MWlmm1UogF60kEZIZCQykz0yJnhTpP9nMFDKw6Maki/mcfVwTQ/v3RqjzttHei/aaEMGQXYr3TOX8NPYJXerBpjShDZqcgcCfqPzhi7uCwVYaPvRsMMYH5jgtMz/SO3J6ToeGKT0f1F1ihRyCdusg325TMK7Vv8yHJzL4ngeUbb/oqxWtxhLY7f77Lkx6hBwe6ROpTqiQHIfZad5qyIHihKcDcIHo4FwLHVzxR4y+Ds2js5dXwBkUBaEAx0Dmc6ZdgXqxd815GDwv+VVpzXJhtO6qXmKE36F/Oe6eCNG3dk5MdOK4C2DrM9M8MXllqeQtX6JrM2stWlBalAc9Jn4b0xxAoghX3x3G3hNsLc9Uist1VinY2+IcPozusqj66c+Vbi5YngWSbICOjlIcq2GaOlBi3xioGts396442ztf1eLjMVuvykCI3b4Yz/vJYZOZjUvG/6nLfaFQTJLSXkTvI4do3foeY4="
|
||||
password:
|
||||
secure: "ts/3FSPL+w4euXryip9wCJWUqUwuJ2CqHi0nEpYVM10KL0cp9aMkQXN/7C8ih+j/JZ25kaO+tis1nx0JERVnKrg/1F/fJhxHTpakCP4OSIlhKRXDt7yUUHcKKt5QdM2XCKhApptP6RTpWL1vlc36CMrhdvTcMYp7hlDOiWjdhhim24FGQzabL42Ko59ttyyCROqgcnjHqj+WlhJl7iYO+lv2PLOtslpkIYY5EMjgBLuGyOvxTqZsAXtworsASdB2CrsTE6iMRcrM1afgkyBlUqDezoiv9TwfZrZ85oRoAxPB7su4QWK7lEP0wGQV/SDz/RL7c+cNfRaauxUKVcC8I2LfaEpudv8vi205+v/08wzPbS4xSOnf89irUJnyGxUy+bYLoXPPVPMyzcQRAUnrQl+ENCmN62HU+/2pkIyiUJvt0wYYP0vp+3pIbXbYQGmAIMq7nZJd7K3rikfnQIGKI03xZsFe3PEdjzhDI9Ej/tRUbJx4pgg084QuYYca6MSYn/zJ5pqIG4A7ngCSxoxoWZT0duo8PrUWQeT17L73Q86HeEv3OzZhWfEF2fNDN3Qbsu4CllSZnbE7DXgAuysTwTYHS/na6lp7wejOqdRUXrHbidVla9Mwxyc9JdWNWzUDxALvNxAdNbV6l85mO8izy8wjQF6M1O7idqTfjQeR4pc="
|
||||
skip_existing: true
|
||||
file_glob: true
|
||||
file:
|
||||
- dist/dvc*.whl
|
||||
- dist/dvc*.tar.gz
|
||||
skip_cleanup: true
|
||||
on:
|
||||
condition: "$(./scripts/ci/deploy_condition.sh dist/dvc*.whl dist/dvc*.tar.gz)"
|
||||
tags: true
|
||||
repo: iterative/dvc
|
||||
stage: build
|
||||
- provider: script
|
||||
skip_cleanup: true
|
||||
script: "(echo $SNAP_TOKEN | snapcraft login --with -) && timeout 600 snapcraft push dvc_*.snap --release $SNAP_CHANNEL || echo timed out"
|
||||
on:
|
||||
all_branches: true
|
||||
condition: "$(./scripts/ci/deploy_condition.sh dvc_*.snap) && ($TRAVIS_BRANCH = master || -n $TRAVIS_TAG) && -n $SNAP_CHANNEL"
|
||||
repo: iterative/dvc
|
||||
stage: build
|
||||
+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
|
||||
|
||||
+52
-15
@@ -2,35 +2,40 @@ import os
|
||||
from contextlib import _GeneratorContextManager as GCM
|
||||
from contextlib import contextmanager
|
||||
|
||||
from dvc.exceptions import DvcException, NotDvcRepoError
|
||||
from funcy import reraise
|
||||
|
||||
from dvc.exceptions import (
|
||||
NotDvcRepoError,
|
||||
OutputNotFoundError,
|
||||
PathMissingError,
|
||||
)
|
||||
from dvc.external_repo import external_repo
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
|
||||
|
||||
class UrlNotDvcRepoError(DvcException):
|
||||
"""Thrown if the given URL is not a DVC repository."""
|
||||
|
||||
def __init__(self, url):
|
||||
super().__init__(f"'{url}' is not a DVC repository.")
|
||||
|
||||
|
||||
def get_url(path, repo=None, rev=None, remote=None):
|
||||
"""
|
||||
Returns the URL to the storage location of a data file or directory tracked
|
||||
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 UrlNotDvcRepoError if repo is not a DVC project.
|
||||
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.
|
||||
"""
|
||||
with _make_repo(repo, rev=rev) as _repo:
|
||||
if not isinstance(_repo, Repo):
|
||||
raise UrlNotDvcRepoError(_repo.url) # pylint: disable=no-member
|
||||
out = _repo.find_out_by_relpath(path)
|
||||
remote_obj = _repo.cloud.get_remote(remote)
|
||||
return str(remote_obj.tree.hash_to_path_info(out.checksum))
|
||||
path_info = PathInfo(_repo.root_dir) / path
|
||||
with reraise(FileNotFoundError, PathMissingError(path, repo)):
|
||||
metadata = _repo.repo_tree.metadata(path_info)
|
||||
|
||||
if not metadata.is_dvc:
|
||||
raise OutputNotFoundError(path, repo)
|
||||
|
||||
cloud = metadata.repo.cloud
|
||||
hash_info = _repo.repo_tree.get_hash(path_info)
|
||||
return cloud.get_url_for(remote, checksum=hash_info.value)
|
||||
|
||||
|
||||
def open( # noqa, pylint: disable=redefined-builtin
|
||||
@@ -97,9 +102,41 @@ def _make_repo(repo_url=None, rev=None):
|
||||
repo_url = repo_url or os.getcwd()
|
||||
if rev is None and os.path.exists(repo_url):
|
||||
try:
|
||||
yield Repo(repo_url)
|
||||
yield Repo(repo_url, subrepos=True)
|
||||
return
|
||||
except NotDvcRepoError:
|
||||
pass # fallthrough to external_repo
|
||||
with external_repo(url=repo_url, rev=rev) as repo:
|
||||
yield repo
|
||||
|
||||
|
||||
def make_checkpoint():
|
||||
"""
|
||||
Signal DVC to create a checkpoint experiment.
|
||||
|
||||
If the current process is being run from DVC, this function will block
|
||||
until DVC has finished creating the checkpoint. Otherwise, this function
|
||||
will return immediately.
|
||||
"""
|
||||
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:
|
||||
return
|
||||
|
||||
root_dir = os.getenv(DVC_ROOT, Repo.find_root())
|
||||
signal_file = os.path.join(
|
||||
root_dir, Repo.DVC_DIR, "tmp", CHECKPOINT_SIGNAL_FILE
|
||||
)
|
||||
|
||||
with builtins.open(signal_file, "w") as fobj:
|
||||
# NOTE: force flushing/writing empty file to disk, otherwise when
|
||||
# run in certain contexts (pytest) file may not actually be written
|
||||
fobj.write("")
|
||||
fobj.flush()
|
||||
os.fsync(fobj.fileno())
|
||||
while os.path.exists(signal_file):
|
||||
sleep(1)
|
||||
|
||||
Vendored
+46
-41
@@ -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,29 +36,48 @@ 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:
|
||||
settings = None
|
||||
else:
|
||||
settings = {**config, "url": config["dir"]}
|
||||
from ..config import LOCAL_COMMON
|
||||
|
||||
tree = get_cloud_tree(repo, **settings)
|
||||
self.local = LocalCache(tree)
|
||||
settings = {"url": config["dir"]}
|
||||
for opt in LOCAL_COMMON.keys():
|
||||
if opt in config:
|
||||
settings[str(opt)] = config.get(opt)
|
||||
|
||||
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")
|
||||
self._cache[Schemes.LOCAL] = _get_cache(repo, settings)
|
||||
|
||||
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
+509
-126
@@ -1,32 +1,30 @@
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from copy import copy
|
||||
from typing import Optional
|
||||
|
||||
from funcy import decorator
|
||||
from shortuuid import uuid
|
||||
|
||||
import dvc.prompt as prompt
|
||||
from dvc.exceptions import CheckoutError, ConfirmRemoveError, DvcException
|
||||
from dvc.path_info import WindowsPathInfo
|
||||
from dvc.dir_info import DirInfo
|
||||
from dvc.exceptions import (
|
||||
CacheLinkError,
|
||||
CheckoutError,
|
||||
ConfirmRemoveError,
|
||||
DvcException,
|
||||
)
|
||||
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_):
|
||||
@@ -35,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
|
||||
@@ -51,47 +56,38 @@ class CloudCache:
|
||||
self.cache_type_confirmed = False
|
||||
self._dir_info = {}
|
||||
|
||||
def get_dir_cache(self, hash_):
|
||||
assert hash_
|
||||
def get_dir_cache(self, hash_info):
|
||||
assert hash_info
|
||||
|
||||
dir_info = self._dir_info.get(hash_)
|
||||
dir_info = self._dir_info.get(hash_info.value)
|
||||
if dir_info:
|
||||
return dir_info
|
||||
|
||||
try:
|
||||
dir_info = self.load_dir_cache(hash_)
|
||||
dir_info = self.load_dir_cache(hash_info)
|
||||
except DirCacheError:
|
||||
dir_info = []
|
||||
dir_info = DirInfo()
|
||||
|
||||
self._dir_info[hash_] = dir_info
|
||||
self._dir_info[hash_info.value] = dir_info
|
||||
return dir_info
|
||||
|
||||
def load_dir_cache(self, hash_):
|
||||
path_info = self.tree.hash_to_path_info(hash_)
|
||||
def load_dir_cache(self, hash_info):
|
||||
path_info = self.tree.hash_to_path_info(hash_info.value)
|
||||
|
||||
try:
|
||||
with self.tree.open(path_info, "r") as fobj:
|
||||
d = json.load(fobj)
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
raise DirCacheError(hash_) from exc
|
||||
raise DirCacheError(hash_info) from exc
|
||||
|
||||
if not isinstance(d, list):
|
||||
logger.error(
|
||||
"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.
|
||||
@@ -118,20 +114,21 @@ class CloudCache:
|
||||
logger.debug("'%s' doesn't exist.", path_info)
|
||||
return True
|
||||
|
||||
hash_ = hash_info.get(self.tree.PARAM_CHECKSUM)
|
||||
if hash_ is None:
|
||||
if not hash_info:
|
||||
logger.debug("hash value for '%s' is missing.", path_info)
|
||||
return True
|
||||
|
||||
if self.changed_cache(hash_):
|
||||
logger.debug("cache for '%s'('%s') has changed.", path_info, hash_)
|
||||
if self.changed_cache(hash_info):
|
||||
logger.debug(
|
||||
"cache for '%s'('%s') has changed.", path_info, hash_info
|
||||
)
|
||||
return True
|
||||
|
||||
actual = self.tree.get_hash(path_info)
|
||||
if hash_ != actual:
|
||||
if hash_info != actual:
|
||||
logger.debug(
|
||||
"hash value '%s' for '%s' has changed (actual '%s').",
|
||||
hash_,
|
||||
hash_info,
|
||||
actual,
|
||||
path_info,
|
||||
)
|
||||
@@ -176,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):
|
||||
@@ -188,12 +185,12 @@ class CloudCache:
|
||||
"Created '%s': %s -> %s", self.cache_types[0], from_info, to_info,
|
||||
)
|
||||
|
||||
def _save_file(self, path_info, tree, hash_, save_link=True, **kwargs):
|
||||
assert hash_
|
||||
def _save_file(self, path_info, tree, hash_info, save_link=True, **kwargs):
|
||||
assert hash_info
|
||||
|
||||
cache_info = self.tree.hash_to_path_info(hash_)
|
||||
cache_info = self.tree.hash_to_path_info(hash_info.value)
|
||||
if tree == self.tree:
|
||||
if self.changed_cache(hash_):
|
||||
if self.changed_cache(hash_info):
|
||||
self.tree.move(path_info, cache_info, mode=self.CACHE_MODE)
|
||||
self.link(cache_info, path_info)
|
||||
elif self.tree.iscopy(path_info) and self._cache_is_copy(
|
||||
@@ -210,9 +207,9 @@ 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_)
|
||||
self.tree.state.save(path_info, hash_info)
|
||||
else:
|
||||
if self.changed_cache(hash_):
|
||||
if self.changed_cache(hash_info):
|
||||
with tree.open(path_info, mode="rb") as fobj:
|
||||
# if tree has fetch enabled, DVC out will be fetched on
|
||||
# open and we do not need to read/copy any data
|
||||
@@ -224,8 +221,7 @@ class CloudCache:
|
||||
if callback:
|
||||
callback(1)
|
||||
|
||||
self.tree.state.save(cache_info, hash_)
|
||||
return {self.tree.PARAM_CHECKSUM: hash_}
|
||||
self.tree.state.save(cache_info, hash_info)
|
||||
|
||||
def _cache_is_copy(self, path_info):
|
||||
"""Checks whether cache uses copies."""
|
||||
@@ -249,13 +245,55 @@ class CloudCache:
|
||||
self.cache_type_confirmed = True
|
||||
return self.cache_types[0] == "copy"
|
||||
|
||||
def _save_dir(self, path_info, tree, hash_, save_link=True, **kwargs):
|
||||
dir_info = self.get_dir_cache(hash_)
|
||||
for entry in Tqdm(
|
||||
dir_info, desc="Saving " + path_info.name, unit="file"
|
||||
def _get_dir_info_hash(self, dir_info):
|
||||
import tempfile
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False).name
|
||||
with open(tmp, "w+") as fobj:
|
||||
json.dump(dir_info.to_list(), fobj, sort_keys=True)
|
||||
|
||||
from_info = PathInfo(tmp)
|
||||
to_info = self.tree.path_info / tmp_fname("")
|
||||
self.tree.upload(from_info, to_info, no_progress_bar=True)
|
||||
|
||||
hash_info = self.tree.get_file_hash(to_info)
|
||||
hash_info.value += self.tree.CHECKSUM_DIR_SUFFIX
|
||||
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
|
||||
and hash_info.name == self.tree.PARAM_CHECKSUM
|
||||
and not self.changed_cache_file(hash_info)
|
||||
):
|
||||
return hash_info
|
||||
|
||||
hi, tmp_info = self._get_dir_info_hash(dir_info)
|
||||
new_info = self.tree.hash_to_path_info(hi.value)
|
||||
if self.changed_cache_file(hi):
|
||||
self.tree.makedirs(new_info.parent)
|
||||
self.tree.move(tmp_info, new_info, mode=self.CACHE_MODE)
|
||||
|
||||
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 = tree.cache.get_dir_cache(hash_info)
|
||||
hi = self.save_dir_info(hash_info.dir_info, hash_info)
|
||||
for entry_info, entry_hash in Tqdm(
|
||||
hi.dir_info.items(path_info),
|
||||
desc="Saving " + path_info.name,
|
||||
unit="file",
|
||||
):
|
||||
entry_info = path_info / entry[self.tree.PARAM_RELPATH]
|
||||
entry_hash = entry[self.tree.PARAM_CHECKSUM]
|
||||
self._save_file(
|
||||
entry_info, tree, entry_hash, save_link=False, **kwargs
|
||||
)
|
||||
@@ -263,12 +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, hash_)
|
||||
self.tree.state.save(path_info, hi)
|
||||
|
||||
cache_info = self.tree.hash_to_path_info(hash_)
|
||||
self.tree.state.save(cache_info, hash_)
|
||||
return {self.tree.PARAM_CHECKSUM: hash_}
|
||||
cache_info = self.tree.hash_to_path_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(
|
||||
@@ -276,25 +314,23 @@ class CloudCache:
|
||||
self.tree.scheme,
|
||||
)
|
||||
|
||||
if not hash_info:
|
||||
hash_info = self.tree.save_info(path_info, tree=tree, **kwargs)
|
||||
hash_ = hash_info[self.tree.PARAM_CHECKSUM]
|
||||
return self._save(path_info, tree, hash_, save_link, **kwargs)
|
||||
self._save(path_info, tree, hash_info, save_link, **kwargs)
|
||||
|
||||
def _save(self, path_info, tree, hash_, save_link=True, **kwargs):
|
||||
to_info = self.tree.hash_to_path_info(hash_)
|
||||
def _save(self, path_info, tree, hash_info, save_link=True, **kwargs):
|
||||
to_info = self.tree.hash_to_path_info(hash_info.value)
|
||||
logger.debug("Saving '%s' to '%s'.", path_info, to_info)
|
||||
|
||||
if tree.isdir(path_info):
|
||||
return self._save_dir(path_info, tree, hash_, save_link, **kwargs)
|
||||
return self._save_file(path_info, tree, hash_, save_link, **kwargs)
|
||||
self._save_dir(path_info, tree, hash_info, save_link, **kwargs)
|
||||
else:
|
||||
self._save_file(path_info, tree, hash_info, save_link, **kwargs)
|
||||
|
||||
# Override to return path as a string instead of PathInfo for clouds
|
||||
# which support string paths (see local)
|
||||
def hash_to_path(self, hash_):
|
||||
return self.tree.hash_to_path_info(hash_)
|
||||
|
||||
def changed_cache_file(self, hash_):
|
||||
def changed_cache_file(self, hash_info):
|
||||
"""Compare the given hash with the (corresponding) actual one.
|
||||
|
||||
- Use `State` as a cache for computed hashes
|
||||
@@ -307,7 +343,7 @@ class CloudCache:
|
||||
- Remove the file from cache if it doesn't match the actual hash
|
||||
"""
|
||||
# Prefer string path over PathInfo when possible due to performance
|
||||
cache_info = self.hash_to_path(hash_)
|
||||
cache_info = self.hash_to_path(hash_info.value)
|
||||
if self.tree.is_protected(cache_info):
|
||||
logger.debug(
|
||||
"Assuming '%s' is unchanged since it is read-only", cache_info
|
||||
@@ -317,13 +353,16 @@ class CloudCache:
|
||||
actual = self.tree.get_hash(cache_info)
|
||||
|
||||
logger.debug(
|
||||
"cache '%s' expected '%s' actual '%s'", cache_info, hash_, actual,
|
||||
"cache '%s' expected '%s' actual '%s'",
|
||||
cache_info,
|
||||
hash_info,
|
||||
actual,
|
||||
)
|
||||
|
||||
if not hash_ or not actual:
|
||||
if not hash_info or not actual:
|
||||
return True
|
||||
|
||||
if actual.split(".")[0] == hash_.split(".")[0]:
|
||||
if actual.value.split(".")[0] == hash_info.value.split(".")[0]:
|
||||
# making cache file read-only so we don't need to check it
|
||||
# next time
|
||||
self.tree.protect(cache_info)
|
||||
@@ -335,15 +374,13 @@ class CloudCache:
|
||||
|
||||
return True
|
||||
|
||||
def _changed_dir_cache(self, hash_, path_info=None, filter_info=None):
|
||||
if self.changed_cache_file(hash_):
|
||||
def _changed_dir_cache(self, hash_info, path_info=None, filter_info=None):
|
||||
if self.changed_cache_file(hash_info):
|
||||
return True
|
||||
|
||||
for entry in self.get_dir_cache(hash_):
|
||||
entry_hash = 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
|
||||
|
||||
@@ -352,12 +389,12 @@ class CloudCache:
|
||||
|
||||
return False
|
||||
|
||||
def changed_cache(self, hash_, path_info=None, filter_info=None):
|
||||
if self.tree.is_dir_hash(hash_):
|
||||
def changed_cache(self, hash_info, path_info=None, filter_info=None):
|
||||
if hash_info.isdir:
|
||||
return self._changed_dir_cache(
|
||||
hash_, path_info=path_info, filter_info=filter_info
|
||||
hash_info, path_info=path_info, filter_info=filter_info
|
||||
)
|
||||
return self.changed_cache_file(hash_)
|
||||
return self.changed_cache_file(hash_info)
|
||||
|
||||
def already_cached(self, path_info):
|
||||
current = self.tree.get_hash(path_info)
|
||||
@@ -383,11 +420,11 @@ class CloudCache:
|
||||
self.tree.remove(path_info)
|
||||
|
||||
def _checkout_file(
|
||||
self, path_info, hash_, force, progress_callback=None, relink=False
|
||||
self, path_info, hash_info, force, progress_callback=None, relink=False
|
||||
):
|
||||
"""The file is changed we need to checkout a new copy"""
|
||||
added, modified = True, False
|
||||
cache_info = self.tree.hash_to_path_info(hash_)
|
||||
cache_info = self.tree.hash_to_path_info(hash_info.value)
|
||||
if self.tree.exists(path_info):
|
||||
logger.debug("data '%s' will be replaced.", path_info)
|
||||
self.safe_remove(path_info, force=force)
|
||||
@@ -395,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_)
|
||||
self.tree.state.save(path_info, hash_info)
|
||||
if progress_callback:
|
||||
progress_callback(str(path_info))
|
||||
|
||||
@@ -404,7 +441,7 @@ class CloudCache:
|
||||
def _checkout_dir(
|
||||
self,
|
||||
path_info,
|
||||
hash_,
|
||||
hash_info,
|
||||
force,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
@@ -417,25 +454,23 @@ class CloudCache:
|
||||
added = True
|
||||
self.tree.makedirs(path_info)
|
||||
|
||||
dir_info = self.get_dir_cache(hash_)
|
||||
dir_info = self.get_dir_cache(hash_info)
|
||||
|
||||
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 = {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))
|
||||
|
||||
@@ -445,7 +480,7 @@ class CloudCache:
|
||||
)
|
||||
|
||||
self.tree.state.save_link(path_info)
|
||||
self.tree.state.save(path_info, hash_)
|
||||
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
|
||||
@@ -453,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,
|
||||
@@ -470,18 +504,19 @@ class CloudCache:
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
quiet=False,
|
||||
):
|
||||
if path_info.scheme not in ["local", self.tree.scheme]:
|
||||
raise NotImplementedError
|
||||
|
||||
hash_ = hash_info.get(self.tree.PARAM_CHECKSUM)
|
||||
failed = None
|
||||
skip = False
|
||||
if not hash_:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. " "It won't be created.",
|
||||
path_info,
|
||||
)
|
||||
if not hash_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
|
||||
|
||||
@@ -490,13 +525,14 @@ class CloudCache:
|
||||
skip = True
|
||||
|
||||
elif self.changed_cache(
|
||||
hash_, path_info=path_info, filter_info=filter_info
|
||||
hash_info, path_info=path_info, filter_info=filter_info
|
||||
):
|
||||
logger.warning(
|
||||
"Cache '%s' not found. File '%s' won't be created.",
|
||||
hash_,
|
||||
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
|
||||
|
||||
@@ -505,50 +541,397 @@ class CloudCache:
|
||||
progress_callback(
|
||||
str(path_info),
|
||||
self.get_files_number(
|
||||
self.tree.path_info, hash_, filter_info
|
||||
self.tree.path_info, hash_info, filter_info
|
||||
),
|
||||
)
|
||||
if failed:
|
||||
raise CheckoutError([failed])
|
||||
return
|
||||
|
||||
logger.debug("Checking out '%s' with cache '%s'.", path_info, hash_)
|
||||
logger.debug(
|
||||
"Checking out '%s' with cache '%s'.", path_info, hash_info
|
||||
)
|
||||
|
||||
return self._checkout(
|
||||
path_info, hash_, force, progress_callback, relink, filter_info,
|
||||
path_info,
|
||||
hash_info,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
filter_info,
|
||||
)
|
||||
|
||||
def _checkout(
|
||||
self,
|
||||
path_info,
|
||||
hash_,
|
||||
hash_info,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
):
|
||||
if not self.tree.is_dir_hash(hash_):
|
||||
if not hash_info.isdir:
|
||||
return self._checkout_file(
|
||||
path_info, hash_, force, progress_callback, relink
|
||||
path_info, hash_info, force, progress_callback, relink
|
||||
)
|
||||
|
||||
return self._checkout_dir(
|
||||
path_info, hash_, force, progress_callback, relink, filter_info
|
||||
path_info, hash_info, force, progress_callback, relink, filter_info
|
||||
)
|
||||
|
||||
def get_files_number(self, path_info, hash_, filter_info):
|
||||
def get_files_number(self, path_info, hash_info, filter_info):
|
||||
from funcy.py3 import ilen
|
||||
|
||||
if not hash_:
|
||||
if not hash_info:
|
||||
return 0
|
||||
|
||||
if not self.tree.is_dir_hash(hash_):
|
||||
if not hash_info.isdir:
|
||||
return 1
|
||||
|
||||
if not filter_info:
|
||||
return len(self.get_dir_cache(hash_))
|
||||
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_)
|
||||
filter_info.isin_or_eq(path_info / relpath)
|
||||
for relpath, _ in self.get_dir_cache(hash_info).items()
|
||||
)
|
||||
|
||||
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
|
||||
assert their_info
|
||||
|
||||
if ancestor_info:
|
||||
ancestor = self.get_dir_cache(ancestor_info)
|
||||
else:
|
||||
ancestor = DirInfo()
|
||||
|
||||
our = self.get_dir_cache(our_info)
|
||||
their = self.get_dir_cache(their_info)
|
||||
|
||||
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
|
||||
|
||||
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.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
+27
-367
@@ -1,52 +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)
|
||||
@@ -60,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)
|
||||
@@ -85,18 +52,15 @@ class LocalCache(CloudCache):
|
||||
desc="Querying "
|
||||
+ ("cache in " + name if name else "local cache"),
|
||||
)
|
||||
if not self.changed_cache_file(hash_)
|
||||
if not self.changed_cache_file(
|
||||
HashInfo(self.tree.PARAM_CHECKSUM, hash_)
|
||||
)
|
||||
]
|
||||
|
||||
def already_cached(self, path_info):
|
||||
assert path_info.scheme in ["", "local"]
|
||||
|
||||
current_md5 = self.tree.get_hash(path_info)
|
||||
|
||||
if not current_md5:
|
||||
return False
|
||||
|
||||
return not self.changed_cache(current_md5)
|
||||
return super().already_cached(path_info)
|
||||
|
||||
def _verify_link(self, path_info, link_type):
|
||||
if link_type == "hardlink" and self.tree.getsize(path_info) == 0:
|
||||
@@ -104,328 +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,
|
||||
):
|
||||
# Return flattened dict containing all status info
|
||||
dir_status, file_status, _ = self._status(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=download,
|
||||
)
|
||||
return dict(dir_status, **file_status)
|
||||
|
||||
def _status(
|
||||
self,
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=None,
|
||||
show_checksums=False,
|
||||
download=False,
|
||||
):
|
||||
"""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
|
||||
)
|
||||
|
||||
def _make_status(
|
||||
self, named_cache, show_checksums, local_exists, remote_exists
|
||||
):
|
||||
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)
|
||||
|
||||
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 = []
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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 = 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):
|
||||
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):
|
||||
return self._process(
|
||||
named_cache,
|
||||
remote,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
download=True,
|
||||
)
|
||||
|
||||
@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)
|
||||
+23
@@ -1,11 +1,13 @@
|
||||
"""DVC command line interface"""
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .command import (
|
||||
add,
|
||||
cache,
|
||||
check_ignore,
|
||||
checkout,
|
||||
commit,
|
||||
completion,
|
||||
@@ -79,6 +81,7 @@ COMMANDS = [
|
||||
git_hook,
|
||||
plots,
|
||||
experiments,
|
||||
check_ignore,
|
||||
]
|
||||
|
||||
|
||||
@@ -135,6 +138,18 @@ def get_parent_parser():
|
||||
"""
|
||||
parent_parser = argparse.ArgumentParser(add_help=False)
|
||||
|
||||
parent_parser.add_argument(
|
||||
"--cprofile",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
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."
|
||||
@@ -181,6 +196,14 @@ def get_main_parser():
|
||||
help="Show program's version.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cd",
|
||||
default=os.path.curdir,
|
||||
metavar="<path>",
|
||||
help="Change to directory before executing.",
|
||||
type=str,
|
||||
)
|
||||
|
||||
# Sub commands
|
||||
subparsers = parser.add_subparsers(
|
||||
title="Available Commands",
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-4
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,16 +29,21 @@ def append_doc_link(help_message, path):
|
||||
|
||||
|
||||
class CmdBase(ABC):
|
||||
UNINITIALIZED = False
|
||||
|
||||
def __init__(self, args):
|
||||
from dvc.repo import Repo
|
||||
from dvc.updater import Updater
|
||||
|
||||
self.repo = Repo()
|
||||
os.chdir(args.cd)
|
||||
|
||||
self.repo = Repo(uninitialized=self.UNINITIALIZED)
|
||||
self.config = self.repo.config
|
||||
self.args = args
|
||||
hardlink_lock = self.config["core"].get("hardlink_lock", False)
|
||||
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
|
||||
updater.check()
|
||||
if self.repo.tmp_dir:
|
||||
hardlink_lock = self.config["core"].get("hardlink_lock", False)
|
||||
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
|
||||
updater.check()
|
||||
|
||||
@property
|
||||
def default_targets(self):
|
||||
@@ -55,3 +61,5 @@ class CmdBase(ABC):
|
||||
class CmdBaseNoRepo(CmdBase):
|
||||
def __init__(self, args): # pylint: disable=super-init-not-called
|
||||
self.args = args
|
||||
|
||||
os.chdir(args.cd)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdCheckIgnore(CmdBase):
|
||||
def __init__(self, args):
|
||||
super().__init__(args)
|
||||
self.ignore_filter = self.repo.tree.dvcignore
|
||||
|
||||
def _show_results(self, result):
|
||||
if not result.match and not self.args.non_matching:
|
||||
return
|
||||
|
||||
if self.args.details:
|
||||
patterns = result.patterns
|
||||
if not self.args.all:
|
||||
patterns = patterns[-1:]
|
||||
|
||||
for pattern in patterns:
|
||||
logger.info("{}\t{}".format(pattern, result.file))
|
||||
else:
|
||||
logger.info(result.file)
|
||||
|
||||
def _check_one_file(self, target):
|
||||
result = self.ignore_filter.check_ignore(target)
|
||||
self._show_results(result)
|
||||
if result.match:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
def _interactive_mode(self):
|
||||
ret = 1
|
||||
while True:
|
||||
try:
|
||||
target = input()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
break
|
||||
if target == "":
|
||||
break
|
||||
if not self._check_one_file(target):
|
||||
ret = 0
|
||||
return ret
|
||||
|
||||
def _normal_mode(self):
|
||||
ret = 1
|
||||
for target in self.args.targets:
|
||||
if not self._check_one_file(target):
|
||||
ret = 0
|
||||
return ret
|
||||
|
||||
def _check_args(self):
|
||||
if not self.args.stdin and not self.args.targets:
|
||||
raise DvcException("`targets` or `--stdin` needed")
|
||||
|
||||
if self.args.stdin and self.args.targets:
|
||||
raise DvcException("cannot have both `targets` and `--stdin`")
|
||||
|
||||
if self.args.non_matching and not self.args.details:
|
||||
raise DvcException(
|
||||
"`--non-matching` is only valid with `--details`"
|
||||
)
|
||||
|
||||
if self.args.all and not self.args.details:
|
||||
raise DvcException("`--all` is only valid with `--details`")
|
||||
|
||||
if self.args.quiet and self.args.details:
|
||||
raise DvcException("cannot use both `--details` and `--quiet`")
|
||||
|
||||
def run(self):
|
||||
self._check_args()
|
||||
if self.args.stdin:
|
||||
return self._interactive_mode()
|
||||
return self._normal_mode()
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
ADD_HELP = (
|
||||
"Check whether files or directories are excluded due to `.dvcignore`."
|
||||
)
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
"check-ignore",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(ADD_HELP, "check-ignore"),
|
||||
help=ADD_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--details",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show the exclude patterns along with each target path.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
"--all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the target paths which don’t match any pattern "
|
||||
"in the `--details` list.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--non-matching",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Include the target paths which don’t match any pattern "
|
||||
"in the `--details` list.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stdin",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Read paths from standard input instead of providing `targets`.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"targets", nargs="*", help="File or directory paths to check",
|
||||
).complete = completion.FILE
|
||||
parser.set_defaults(func=CmdCheckIgnore)
|
||||
@@ -9,8 +9,9 @@ class CmdDaemonBase(CmdBaseNoRepo):
|
||||
class CmdDaemonUpdater(CmdDaemonBase):
|
||||
def run(self):
|
||||
import os
|
||||
from dvc.repo import Repo
|
||||
|
||||
from dvc.config import Config
|
||||
from dvc.repo import Repo
|
||||
from dvc.updater import Updater
|
||||
|
||||
root_dir = Repo.find_root()
|
||||
|
||||
+75
-41
@@ -2,14 +2,14 @@ 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__)
|
||||
|
||||
|
||||
def _show_ascii(G):
|
||||
from dvc.repo.graph import get_pipelines
|
||||
from dvc.dagascii import draw
|
||||
from dvc.repo.graph import get_pipelines
|
||||
|
||||
pipelines = get_pipelines(G)
|
||||
|
||||
@@ -22,6 +22,7 @@ def _show_ascii(G):
|
||||
|
||||
def _show_dot(G):
|
||||
import io
|
||||
|
||||
from networkx.drawing.nx_pydot import write_dot
|
||||
|
||||
dot_file = io.StringIO()
|
||||
@@ -29,57 +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.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
|
||||
|
||||
if target:
|
||||
H = get_pipeline(get_pipelines(G), target)
|
||||
if not full:
|
||||
descendants = nx.descendants(G, target)
|
||||
def _relabel(node) -> str:
|
||||
return node.addressing if isinstance(node, Stage) else str(node)
|
||||
|
||||
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):
|
||||
@@ -106,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="?",
|
||||
|
||||
@@ -97,7 +97,11 @@ def shared_parent_parser():
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
help="Number of jobs to run simultaneously.",
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
parent_parser.add_argument(
|
||||
|
||||
@@ -25,7 +25,7 @@ class CmdDestroy(CmdBase):
|
||||
)
|
||||
|
||||
self.repo.destroy()
|
||||
except Exception: # noqa, pylint: disable=broad-except
|
||||
except DvcException:
|
||||
logger.exception("failed to destroy DVC")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
+52
-21
@@ -11,30 +11,43 @@ from dvc.exceptions import DvcException
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _show_md(diff):
|
||||
def _digest(checksum):
|
||||
if isinstance(checksum, str):
|
||||
return checksum[0:8]
|
||||
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
|
||||
|
||||
|
||||
def _show_md(diff, show_hash=False, hide_missing=False):
|
||||
from dvc.utils.diff import table
|
||||
|
||||
header = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
|
||||
rows = []
|
||||
for status in ["added", "deleted", "modified"]:
|
||||
statuses = ["added", "deleted", "modified"]
|
||||
if not hide_missing:
|
||||
statuses.append("not in cache")
|
||||
for status in statuses:
|
||||
entries = diff.get(status, [])
|
||||
if not entries:
|
||||
continue
|
||||
paths = sorted(entry["path"] for entry in entries)
|
||||
for path in paths:
|
||||
rows.append([status, path])
|
||||
for entry in entries:
|
||||
path = entry["path"]
|
||||
if show_hash:
|
||||
check_sum = _digest(entry.get("hash", ""))
|
||||
rows.append([status, check_sum, path])
|
||||
else:
|
||||
rows.append([status, path])
|
||||
|
||||
return table(["Status", "Path"], rows, True)
|
||||
return table(header, rows, True)
|
||||
|
||||
|
||||
class CmdDiff(CmdBase):
|
||||
@staticmethod
|
||||
def _format(diff):
|
||||
def _format(diff, hide_missing=False):
|
||||
"""
|
||||
Given a diff structure, generate a string of paths separated
|
||||
by new lines and grouped together by their state.
|
||||
|
||||
A group's header is colored and its entries are sorted to enhance
|
||||
readability, for example:
|
||||
A group's header is colored to enhance readability, for example:
|
||||
|
||||
Added:
|
||||
another_file.txt
|
||||
@@ -55,21 +68,20 @@ class CmdDiff(CmdBase):
|
||||
At the bottom, include a summary with the number of files per state.
|
||||
"""
|
||||
|
||||
def _digest(checksum):
|
||||
if isinstance(checksum, str):
|
||||
return checksum[0:8]
|
||||
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
|
||||
|
||||
colors = {
|
||||
"added": colorama.Fore.GREEN,
|
||||
"modified": colorama.Fore.YELLOW,
|
||||
"deleted": colorama.Fore.RED,
|
||||
"not in cache": colorama.Fore.YELLOW,
|
||||
}
|
||||
|
||||
summary = {}
|
||||
groups = []
|
||||
|
||||
for state in ["added", "deleted", "modified"]:
|
||||
states = ["added", "deleted", "modified"]
|
||||
if not hide_missing:
|
||||
states.append("not in cache")
|
||||
for state in states:
|
||||
summary[state] = 0
|
||||
entries = diff[state]
|
||||
|
||||
@@ -100,28 +112,42 @@ class CmdDiff(CmdBase):
|
||||
)
|
||||
)
|
||||
|
||||
groups.append(
|
||||
if not sum(summary.values()):
|
||||
return None
|
||||
|
||||
fmt = (
|
||||
"files summary: {added} added, {deleted} deleted,"
|
||||
" {modified} modified".format_map(summary)
|
||||
" {modified} modified"
|
||||
)
|
||||
if not hide_missing:
|
||||
fmt += ", {not in cache} not in cache"
|
||||
groups.append(fmt.format_map(summary))
|
||||
|
||||
return "\n\n".join(groups)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
diff = self.repo.diff(self.args.a_rev, self.args.b_rev)
|
||||
show_hash = self.args.show_hash
|
||||
hide_missing = self.args.b_rev or self.args.hide_missing
|
||||
if hide_missing:
|
||||
del diff["not in cache"]
|
||||
|
||||
if not self.args.show_hash:
|
||||
for _, entries in diff.items():
|
||||
for key, entries in diff.items():
|
||||
entries = sorted(entries, key=lambda entry: entry["path"])
|
||||
if not show_hash:
|
||||
for entry in entries:
|
||||
del entry["hash"]
|
||||
diff[key] = entries
|
||||
|
||||
if self.args.show_json:
|
||||
logger.info(json.dumps(diff))
|
||||
elif self.args.show_md:
|
||||
logger.info(_show_md(diff))
|
||||
logger.info(_show_md(diff, show_hash, hide_missing))
|
||||
elif diff:
|
||||
logger.info(self._format(diff))
|
||||
output = self._format(diff, hide_missing)
|
||||
if output:
|
||||
logger.info(output)
|
||||
|
||||
except DvcException:
|
||||
logger.exception("failed to get diff")
|
||||
@@ -170,4 +196,9 @@ def add_parser(subparsers, parent_parser):
|
||||
action="store_true",
|
||||
default=False,
|
||||
)
|
||||
diff_parser.add_argument(
|
||||
"--hide-missing",
|
||||
help="Hide missing cache file status.",
|
||||
action="store_true",
|
||||
)
|
||||
diff_parser.set_defaults(func=CmdDiff)
|
||||
|
||||
+593
-85
@@ -1,27 +1,77 @@
|
||||
import argparse
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping
|
||||
from datetime import date, datetime
|
||||
from itertools import groupby
|
||||
from typing import Iterable, Optional
|
||||
|
||||
import dvc.prompt as prompt
|
||||
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
|
||||
from dvc.command.metrics import DEFAULT_PRECISION
|
||||
from dvc.exceptions import DvcException
|
||||
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__)
|
||||
|
||||
|
||||
def _update_names(names, items):
|
||||
from flatten_json import flatten
|
||||
def _filter_names(
|
||||
names: Iterable,
|
||||
label: str,
|
||||
include: Optional[Iterable],
|
||||
exclude: Optional[Iterable],
|
||||
):
|
||||
if include and exclude:
|
||||
intersection = set(include) & set(exclude)
|
||||
if intersection:
|
||||
values = ", ".join(intersection)
|
||||
raise InvalidArgumentError(
|
||||
f"'{values}' specified in both --include-{label} and"
|
||||
f" --exclude-{label}"
|
||||
)
|
||||
|
||||
names = [tuple(name.split(".")) for name in names]
|
||||
|
||||
def _filter(filters, update_func):
|
||||
filters = [tuple(name.split(".")) for name in filters]
|
||||
for length, groups in groupby(filters, len):
|
||||
for group in groups:
|
||||
matches = [name for name in names if name[:length] == group]
|
||||
if not matches:
|
||||
name = ".".join(group)
|
||||
raise InvalidArgumentError(
|
||||
f"'{name}' does not match any known {label}"
|
||||
)
|
||||
update_func({match: None for match in matches})
|
||||
|
||||
if include:
|
||||
ret: OrderedDict = OrderedDict()
|
||||
_filter(include, ret.update)
|
||||
else:
|
||||
ret = OrderedDict({name: None for name in names})
|
||||
|
||||
if exclude:
|
||||
_filter(exclude, ret.difference_update) # type: ignore[attr-defined]
|
||||
|
||||
return [".".join(name) for name in ret]
|
||||
|
||||
|
||||
def _update_names(names, items):
|
||||
for name, item in items:
|
||||
if isinstance(item, dict):
|
||||
item = flatten(item, ".")
|
||||
item = flatten(item)
|
||||
names.update(item.keys())
|
||||
else:
|
||||
names.add(name)
|
||||
names[name] = None
|
||||
|
||||
|
||||
def _collect_names(all_experiments):
|
||||
def _collect_names(all_experiments, **kwargs):
|
||||
metric_names = set()
|
||||
param_names = set()
|
||||
|
||||
@@ -30,84 +80,223 @@ def _collect_names(all_experiments):
|
||||
_update_names(metric_names, exp.get("metrics", {}).items())
|
||||
_update_names(param_names, exp.get("params", {}).items())
|
||||
|
||||
return sorted(metric_names), sorted(param_names)
|
||||
metric_names = _filter_names(
|
||||
sorted(metric_names),
|
||||
"metrics",
|
||||
kwargs.get("include_metrics"),
|
||||
kwargs.get("exclude_metrics"),
|
||||
)
|
||||
param_names = _filter_names(
|
||||
sorted(param_names),
|
||||
"params",
|
||||
kwargs.get("include_params"),
|
||||
kwargs.get("exclude_params"),
|
||||
)
|
||||
|
||||
return metric_names, param_names
|
||||
|
||||
|
||||
def _collect_rows(
|
||||
base_rev, experiments, metric_names, param_names, precision=None
|
||||
base_rev,
|
||||
experiments,
|
||||
metric_names,
|
||||
param_names,
|
||||
precision=DEFAULT_PRECISION,
|
||||
no_timestamp=False,
|
||||
sort_by=None,
|
||||
sort_order=None,
|
||||
):
|
||||
from flatten_json import flatten
|
||||
|
||||
if precision is None:
|
||||
precision = DEFAULT_PRECISION
|
||||
|
||||
def _round(val):
|
||||
if isinstance(val, float):
|
||||
return round(val, precision)
|
||||
|
||||
return val
|
||||
|
||||
def _extend(row, names, items):
|
||||
for fname, item in items:
|
||||
if isinstance(item, dict):
|
||||
item = flatten(item, ".")
|
||||
else:
|
||||
item = {fname: item}
|
||||
for name in names:
|
||||
if name in item:
|
||||
row.append(str(_round(item[name])))
|
||||
else:
|
||||
row.append("-")
|
||||
if sort_by:
|
||||
if sort_by in metric_names:
|
||||
sort_type = "metrics"
|
||||
elif sort_by in param_names:
|
||||
sort_type = "params"
|
||||
else:
|
||||
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
|
||||
reverse = sort_order == "desc"
|
||||
experiments = _sort_exp(experiments, sort_by, sort_type, reverse)
|
||||
|
||||
new_checkpoint = True
|
||||
for i, (rev, exp) in enumerate(experiments.items()):
|
||||
row = []
|
||||
style = None
|
||||
if rev == "baseline":
|
||||
row.append(f"{base_rev}")
|
||||
style = "bold"
|
||||
elif i < len(experiments) - 1:
|
||||
row.append(f"├── {rev[:7]}")
|
||||
else:
|
||||
row.append(f"└── {rev[:7]}")
|
||||
queued = "*" if exp.get("queued", False) else ""
|
||||
|
||||
_extend(row, metric_names, exp.get("metrics", {}).items())
|
||||
_extend(row, param_names, exp.get("params", {}).items())
|
||||
tip = exp.get("checkpoint_tip")
|
||||
parent = ""
|
||||
if rev == "baseline":
|
||||
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:
|
||||
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:
|
||||
tree = "├──"
|
||||
else:
|
||||
tree = "└──"
|
||||
new_checkpoint = True
|
||||
row.append(f"{tree} {queued}{rev[:7]}{parent}")
|
||||
|
||||
if not no_timestamp:
|
||||
row.append(_format_time(exp.get("timestamp")))
|
||||
|
||||
_extend_row(
|
||||
row, metric_names, exp.get("metrics", {}).items(), precision
|
||||
)
|
||||
_extend_row(row, param_names, exp.get("params", {}).items(), precision)
|
||||
|
||||
yield row, style
|
||||
|
||||
|
||||
def _show_experiments(all_experiments, console, precision=None):
|
||||
from rich.table import Table
|
||||
from dvc.scm.git import Git
|
||||
def _sort_exp(experiments, sort_by, typ, reverse):
|
||||
def _sort(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)
|
||||
else:
|
||||
item = {fname: item}
|
||||
if sort_by in item:
|
||||
val = item[sort_by]
|
||||
return (val is None, val)
|
||||
return (True, None)
|
||||
|
||||
metric_names, param_names = _collect_names(all_experiments)
|
||||
ret = OrderedDict()
|
||||
if "baseline" in experiments:
|
||||
ret["baseline"] = experiments.pop("baseline")
|
||||
|
||||
ret.update(sorted(experiments.items(), key=_sort, reverse=reverse))
|
||||
return ret
|
||||
|
||||
|
||||
def _format_time(timestamp):
|
||||
if timestamp is None:
|
||||
return "-"
|
||||
if timestamp.date() == date.today():
|
||||
fmt = "%I:%M %p"
|
||||
else:
|
||||
fmt = "%b %d, %Y"
|
||||
return timestamp.strftime(fmt)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _extend_row(row, names, items, precision):
|
||||
from rich.text import Text
|
||||
|
||||
if not items:
|
||||
row.extend(["-"] * len(names))
|
||||
return
|
||||
|
||||
for fname, item in items:
|
||||
if isinstance(item, dict):
|
||||
item = flatten(item)
|
||||
else:
|
||||
item = {fname: item}
|
||||
for name in names:
|
||||
if name in item:
|
||||
value = item[name]
|
||||
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("-")
|
||||
|
||||
|
||||
def _parse_list(param_list):
|
||||
ret = []
|
||||
for param_str in param_list:
|
||||
# we don't care about filename prefixes for show, silently
|
||||
# ignore it if provided to keep usage consistent with other
|
||||
# metric/param list command options
|
||||
_, _, param_str = param_str.rpartition(":")
|
||||
ret.extend(param_str.split(","))
|
||||
return ret
|
||||
|
||||
|
||||
def _show_experiments(all_experiments, console, **kwargs):
|
||||
from rich.table import Table
|
||||
|
||||
include_metrics = _parse_list(kwargs.pop("include_metrics", []))
|
||||
exclude_metrics = _parse_list(kwargs.pop("exclude_metrics", []))
|
||||
include_params = _parse_list(kwargs.pop("include_params", []))
|
||||
exclude_params = _parse_list(kwargs.pop("exclude_params", []))
|
||||
|
||||
metric_names, param_names = _collect_names(
|
||||
all_experiments,
|
||||
include_metrics=include_metrics,
|
||||
exclude_metrics=exclude_metrics,
|
||||
include_params=include_params,
|
||||
exclude_params=exclude_params,
|
||||
)
|
||||
|
||||
table = Table()
|
||||
table.add_column("Experiment")
|
||||
table.add_column("Experiment", no_wrap=True)
|
||||
if not kwargs.get("no_timestamp", False):
|
||||
table.add_column("Created")
|
||||
for name in metric_names:
|
||||
table.add_column(name, justify="right")
|
||||
table.add_column(name, justify="right", no_wrap=True)
|
||||
for name in param_names:
|
||||
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,
|
||||
precision=precision,
|
||||
base_rev, experiments, metric_names, param_names, **kwargs,
|
||||
):
|
||||
table.add_row(*row)
|
||||
|
||||
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
|
||||
|
||||
from dvc.utils.pager import pager
|
||||
|
||||
if not self.repo.experiments:
|
||||
@@ -118,17 +307,44 @@ class CmdExperimentsShow(CmdBase):
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
sha_only=self.args.sha,
|
||||
)
|
||||
|
||||
# Note: rich does not currently include a native way to force
|
||||
# infinite width for use with a pager
|
||||
console = Console(
|
||||
file=io.StringIO(), force_terminal=True, width=9999
|
||||
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:
|
||||
# Note: rich does not currently include a native way to force
|
||||
# infinite width for use with a pager
|
||||
console = Console(
|
||||
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,
|
||||
include_metrics=self.args.include_metrics,
|
||||
exclude_metrics=self.args.exclude_metrics,
|
||||
include_params=self.args.include_params,
|
||||
exclude_params=self.args.exclude_params,
|
||||
no_timestamp=self.args.no_timestamp,
|
||||
sort_by=self.args.sort_by,
|
||||
sort_order=self.args.sort_order,
|
||||
precision=precision,
|
||||
)
|
||||
|
||||
_show_experiments(all_experiments, console)
|
||||
|
||||
pager(console.file.getvalue())
|
||||
if not self.args.no_pager:
|
||||
pager(console.file.getvalue())
|
||||
except DvcException:
|
||||
logger.exception("failed to show experiments")
|
||||
return 1
|
||||
@@ -141,27 +357,21 @@ class CmdExperimentsCheckout(CmdBase):
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
|
||||
self.repo.experiments.checkout(
|
||||
self.args.experiment, force=self.args.force
|
||||
)
|
||||
self.repo.experiments.checkout(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()))
|
||||
@@ -169,9 +379,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"]
|
||||
@@ -202,6 +416,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(
|
||||
@@ -210,7 +429,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)
|
||||
@@ -223,12 +442,103 @@ class CmdExperimentsDiff(CmdBase):
|
||||
return 0
|
||||
|
||||
|
||||
class CmdExperimentsRun(CmdRepro):
|
||||
def run(self):
|
||||
if not self.repo.experiments:
|
||||
return 0
|
||||
|
||||
saved_dir = os.path.realpath(os.curdir)
|
||||
os.chdir(self.args.cwd)
|
||||
|
||||
# Dirty hack so the for loop below can at least enter once
|
||||
if self.args.all_pipelines:
|
||||
self.args.targets = [None]
|
||||
elif not self.args.targets:
|
||||
self.args.targets = self.default_targets
|
||||
|
||||
ret = 0
|
||||
for target in self.args.targets:
|
||||
try:
|
||||
self.repo.experiments.run(
|
||||
target,
|
||||
queue=self.args.queue,
|
||||
run_all=self.args.run_all,
|
||||
jobs=self.args.jobs,
|
||||
params=self.args.params,
|
||||
checkpoint_resume=self.args.checkpoint_resume,
|
||||
**self._repro_kwargs,
|
||||
)
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
ret = 1
|
||||
break
|
||||
|
||||
os.chdir(saved_dir)
|
||||
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
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
EXPERIMENTS_HELP = "Commands to display and compare experiments."
|
||||
|
||||
experiments_parser = subparsers.add_parser(
|
||||
"experiments",
|
||||
parents=[parent_parser],
|
||||
aliases=["exp"],
|
||||
description=append_doc_link(EXPERIMENTS_HELP, "experiments"),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
@@ -269,6 +579,78 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Show metrics for all commits.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--no-pager",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Do not pipe output into a pager.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--include-metrics",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Include the specified metrics in output table.",
|
||||
metavar="<metrics_list>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--exclude-metrics",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Exclude the specified metrics from output table.",
|
||||
metavar="<metrics_list>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--include-params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Include the specified params in output table.",
|
||||
metavar="<params_list>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--exclude-params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Exclude the specified params from output table.",
|
||||
metavar="<params_list>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--sort-by",
|
||||
help="Sort related experiments by the specified metric or param.",
|
||||
metavar="<metric/param>",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--sort-order",
|
||||
help="Sort order to use with --sort-by.",
|
||||
choices=("asc", "desc"),
|
||||
default="asc",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--no-timestamp",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Do not show experiment timestamps.",
|
||||
)
|
||||
experiments_show_parser.add_argument(
|
||||
"--sha",
|
||||
action="store_true",
|
||||
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."
|
||||
@@ -281,14 +663,6 @@ def add_parser(subparsers, parent_parser):
|
||||
help=EXPERIMENTS_CHECKOUT_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
experiments_checkout_parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite your current workspace with changes from the "
|
||||
"experiment.",
|
||||
)
|
||||
experiments_checkout_parser.add_argument(
|
||||
"experiment", help="Checkout this experiment.",
|
||||
)
|
||||
@@ -352,3 +726,137 @@ def add_parser(subparsers, parent_parser):
|
||||
metavar="<n>",
|
||||
)
|
||||
experiments_diff_parser.set_defaults(func=CmdExperimentsDiff)
|
||||
|
||||
EXPERIMENTS_RUN_HELP = (
|
||||
"Reproduce complete or partial experiment pipelines."
|
||||
)
|
||||
experiments_run_parser = experiments_subparsers.add_parser(
|
||||
"run",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(EXPERIMENTS_RUN_HELP, "experiments/run"),
|
||||
help=EXPERIMENTS_RUN_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
_add_run_common(experiments_run_parser)
|
||||
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="Force garbage collection - automatically agree to all prompts.",
|
||||
)
|
||||
experiments_gc_parser.set_defaults(func=CmdExperimentsGC)
|
||||
|
||||
|
||||
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(
|
||||
"--params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Use the specified param values when reproducing pipelines.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--queue",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Stage this experiment in the run queue for future execution.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run-all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Execute all experiments in the run queue.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
help="Run the specified number of experiments at a time in parallel.",
|
||||
metavar="<number>",
|
||||
)
|
||||
|
||||
+5
-1
@@ -123,7 +123,11 @@ def add_parser(subparsers, parent_parser):
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
help="Number of jobs to run simultaneously.",
|
||||
help=(
|
||||
"Number of jobs to run simultaneously. "
|
||||
"The default value is 4 * cpu_count(). "
|
||||
"For SSH remotes, the default is 4. "
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
|
||||
@@ -61,6 +61,26 @@ class CmdPrePush(CmdHookBase):
|
||||
return main(["push"])
|
||||
|
||||
|
||||
class CmdMergeDriver(CmdHookBase):
|
||||
def _run(self):
|
||||
from dvc.dvcfile import Dvcfile
|
||||
from dvc.repo import Repo
|
||||
|
||||
dvc = Repo()
|
||||
|
||||
try:
|
||||
with dvc.state:
|
||||
ancestor = Dvcfile(dvc, self.args.ancestor, verify=False)
|
||||
our = Dvcfile(dvc, self.args.our, verify=False)
|
||||
their = Dvcfile(dvc, self.args.their, verify=False)
|
||||
|
||||
our.merge(ancestor, their)
|
||||
|
||||
return 0
|
||||
finally:
|
||||
dvc.close()
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
GIT_HOOK_HELP = "Run GIT hook."
|
||||
|
||||
@@ -113,3 +133,27 @@ def add_parser(subparsers, parent_parser):
|
||||
"args", nargs="*", help="Arguments passed by GIT or pre-commit tool.",
|
||||
)
|
||||
pre_push_parser.set_defaults(func=CmdPrePush)
|
||||
|
||||
MERGE_DRIVER_HELP = "Run GIT merge driver."
|
||||
merge_driver_parser = git_hook_subparsers.add_parser(
|
||||
"merge-driver",
|
||||
parents=[parent_parser],
|
||||
description=MERGE_DRIVER_HELP,
|
||||
help=MERGE_DRIVER_HELP,
|
||||
)
|
||||
merge_driver_parser.add_argument(
|
||||
"--ancestor",
|
||||
required=True,
|
||||
help="Ancestor's version of the conflicting file.",
|
||||
)
|
||||
merge_driver_parser.add_argument(
|
||||
"--our",
|
||||
required=True,
|
||||
help="Current version of the conflicting file.",
|
||||
)
|
||||
merge_driver_parser.add_argument(
|
||||
"--their",
|
||||
required=True,
|
||||
help="Other branch's version of the conflicting file.",
|
||||
)
|
||||
merge_driver_parser.set_defaults(func=CmdMergeDriver)
|
||||
|
||||
@@ -15,7 +15,10 @@ class CmdImport(CmdBase):
|
||||
self.args.url,
|
||||
self.args.path,
|
||||
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(
|
||||
@@ -59,4 +62,24 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Git revision (e.g. SHA, branch, tag)",
|
||||
metavar="<commit>",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--file",
|
||||
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)
|
||||
|
||||
+18
-12
@@ -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(
|
||||
@@ -42,20 +43,16 @@ def add_parser(subparsers, parent_parser):
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"url",
|
||||
help="Supported urls:\n"
|
||||
"/path/to/file\n"
|
||||
"/path/to/directory\n"
|
||||
"C:\\\\path\\to\\file\n"
|
||||
"C:\\\\path\\to\\directory\n"
|
||||
help="Location of the data to download. Supported URLs:\n"
|
||||
"/absolute/path/to/file/or/dir\n"
|
||||
"relative/path/to/file/or/dir\n"
|
||||
"C:\\\\path\\to\\file\\or\\dir\n"
|
||||
"https://example.com/path/to/file\n"
|
||||
"s3://bucket/path/to/file\n"
|
||||
"s3://bucket/path/to/directory\n"
|
||||
"gs://bucket/path/to/file\n"
|
||||
"gs://bucket/path/to/directory\n"
|
||||
"s3://bucket/key/path\n"
|
||||
"gs://bucket/path/to/file/or/dir\n"
|
||||
"hdfs://example.com/path/to/file\n"
|
||||
"ssh://example.com:/path/to/file\n"
|
||||
"ssh://example.com:/path/to/directory\n"
|
||||
"remote://myremote/path/to/file (see `dvc remote`)",
|
||||
"ssh://example.com/absolute/path/to/file/or/dir\n"
|
||||
"remote://remote_name/path/to/file/or/dir (see `dvc remote`)",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"out", nargs="?", help="Destination path to put files to.",
|
||||
@@ -71,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)
|
||||
|
||||
+29
-1
@@ -1,15 +1,42 @@
|
||||
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.repo import Repo
|
||||
from dvc.exceptions import InitError
|
||||
from dvc.repo import Repo
|
||||
|
||||
try:
|
||||
self.repo = Repo.init(
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,7 +11,7 @@ class CmdInstall(CmdBase):
|
||||
def run(self):
|
||||
try:
|
||||
self.repo.install(self.args.use_pre_commit_tool)
|
||||
except Exception: # noqa, pylint:disable=broad-except
|
||||
except DvcException:
|
||||
logger.exception("failed to install DVC Git hooks")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
+30
-32
@@ -11,34 +11,41 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_PRECISION = 5
|
||||
|
||||
|
||||
def show_metrics(
|
||||
def _show_metrics(
|
||||
metrics, all_branches=False, all_tags=False, all_commits=False
|
||||
):
|
||||
from flatten_json import flatten
|
||||
from dvc.utils.diff import format_dict
|
||||
from dvc.utils.flatten import flatten
|
||||
|
||||
# When `metrics` contains a `None` key, it means that some files
|
||||
# specified as `targets` in `repo.metrics.show` didn't contain any metrics.
|
||||
missing = metrics.pop(None, None)
|
||||
|
||||
lines = []
|
||||
for branch, val in metrics.items():
|
||||
if all_branches or all_tags or all_commits:
|
||||
logger.info(f"{branch}:")
|
||||
lines.append(f"{branch}:")
|
||||
|
||||
for fname, metric in val.items():
|
||||
if not isinstance(metric, dict):
|
||||
logger.info("\t{}: {}".format(fname, str(metric)))
|
||||
lines.append("\t{}: {}".format(fname, str(metric)))
|
||||
continue
|
||||
|
||||
logger.info(f"\t{fname}:")
|
||||
for key, value in flatten(format_dict(metric), ".").items():
|
||||
logger.info(f"\t\t{key}: {value}")
|
||||
lines.append(f"\t{fname}:")
|
||||
for key, value in flatten(format_dict(metric)).items():
|
||||
lines.append(f"\t\t{key}: {value}")
|
||||
|
||||
if missing:
|
||||
raise BadMetricError(missing)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
class CmdMetricsShow(CmdBase):
|
||||
|
||||
class CmdMetricsBase(CmdBase):
|
||||
UNINITIALIZED = True
|
||||
|
||||
|
||||
class CmdMetricsShow(CmdMetricsBase):
|
||||
def run(self):
|
||||
try:
|
||||
metrics = self.repo.metrics.show(
|
||||
@@ -54,20 +61,22 @@ class CmdMetricsShow(CmdBase):
|
||||
|
||||
logger.info(json.dumps(metrics))
|
||||
else:
|
||||
show_metrics(
|
||||
table = _show_metrics(
|
||||
metrics,
|
||||
self.args.all_branches,
|
||||
self.args.all_tags,
|
||||
self.args.all_commits,
|
||||
)
|
||||
if table:
|
||||
logger.info(table)
|
||||
except DvcException:
|
||||
logger.exception("failed to show metrics")
|
||||
logger.exception("")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _show_diff(diff, markdown=False, no_path=False, old=False, precision=None):
|
||||
def _show_diff(diff, markdown=False, no_path=False, precision=None):
|
||||
from collections import OrderedDict
|
||||
|
||||
from dvc.utils.diff import table
|
||||
@@ -87,24 +96,20 @@ def _show_diff(diff, markdown=False, no_path=False, old=False, precision=None):
|
||||
for metric, change in sorted_mdiff.items():
|
||||
row = [] if no_path else [fname]
|
||||
row.append(metric)
|
||||
if old:
|
||||
row.append(_round(change.get("old")))
|
||||
row.append(_round(change.get("old")))
|
||||
row.append(_round(change["new"]))
|
||||
row.append(_round(change.get("diff", "diff not supported")))
|
||||
row.append(_round(change.get("diff")))
|
||||
rows.append(row)
|
||||
|
||||
header = [] if no_path else ["Path"]
|
||||
header.append("Metric")
|
||||
if old:
|
||||
header.extend(["Old", "New"])
|
||||
else:
|
||||
header.append("Value")
|
||||
header.extend(["Old", "New"])
|
||||
header.append("Change")
|
||||
|
||||
return table(header, rows, markdown)
|
||||
|
||||
|
||||
class CmdMetricsDiff(CmdBase):
|
||||
class CmdMetricsDiff(CmdMetricsBase):
|
||||
def run(self):
|
||||
try:
|
||||
diff = self.repo.metrics.diff(
|
||||
@@ -124,7 +129,6 @@ class CmdMetricsDiff(CmdBase):
|
||||
diff,
|
||||
self.args.show_md,
|
||||
self.args.no_path,
|
||||
self.args.old,
|
||||
precision=self.args.precision,
|
||||
)
|
||||
if table:
|
||||
@@ -167,8 +171,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."
|
||||
),
|
||||
).complete = completion.FILE
|
||||
metrics_show_parser.add_argument(
|
||||
@@ -204,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)
|
||||
@@ -232,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
|
||||
@@ -244,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(
|
||||
@@ -265,12 +269,6 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Show tabulated output in the Markdown format (GFM).",
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
"--old",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show old metric value.",
|
||||
)
|
||||
metrics_diff_parser.add_argument(
|
||||
"--no-path",
|
||||
action="store_true",
|
||||
|
||||
+27
-8
@@ -65,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("")
|
||||
@@ -80,13 +79,22 @@ 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, revs=self.args.revisions, **kwargs)
|
||||
return self.repo.plots.diff(
|
||||
*args,
|
||||
revs=self.args.revisions,
|
||||
experiment=self.args.experiment,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CmdPlotsModify(CmdPlots):
|
||||
@@ -117,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],
|
||||
@@ -128,7 +136,9 @@ def add_parser(subparsers, parent_parser):
|
||||
plots_show_parser.add_argument(
|
||||
"targets",
|
||||
nargs="*",
|
||||
help="Plots files to visualize. Shows all plots by default.",
|
||||
help="Files to visualize (supports any file, "
|
||||
"even when not found as `plots` in `dvc.yaml`). "
|
||||
"Shows all plots by default.",
|
||||
).complete = completion.FILE
|
||||
_add_props_arguments(plots_show_parser)
|
||||
_add_output_arguments(plots_show_parser)
|
||||
@@ -148,9 +158,18 @@ def add_parser(subparsers, parent_parser):
|
||||
plots_diff_parser.add_argument(
|
||||
"--targets",
|
||||
nargs="*",
|
||||
help="Plots file to visualize. Shows all plots by default.",
|
||||
help="Files to visualize (supports any file, "
|
||||
"even when not found as `plots` in `dvc.yaml`). "
|
||||
"Shows all plots by default.",
|
||||
metavar="<path>",
|
||||
).complete = completion.FILE
|
||||
plots_diff_parser.add_argument(
|
||||
"-e",
|
||||
"--experiment",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
plots_diff_parser.add_argument(
|
||||
"revisions", nargs="*", default=None, help="Git commits to plot from",
|
||||
)
|
||||
@@ -158,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],
|
||||
|
||||
+11
-6
@@ -3,7 +3,7 @@ import logging
|
||||
|
||||
from dvc.command.base import append_doc_link, fix_subparsers
|
||||
from dvc.command.config import CmdConfig
|
||||
from dvc.config import ConfigError
|
||||
from dvc.config import ConfigError, merge
|
||||
from dvc.utils import format_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,7 +18,7 @@ class CmdRemote(CmdConfig):
|
||||
|
||||
def _check_exists(self, conf):
|
||||
if self.args.name not in conf["remote"]:
|
||||
raise ConfigError(f"remote '{self.args.name}' doesn't exists.")
|
||||
raise ConfigError(f"remote '{self.args.name}' doesn't exist.")
|
||||
|
||||
|
||||
class CmdRemoteAdd(CmdRemote):
|
||||
@@ -61,7 +61,12 @@ class CmdRemoteRemove(CmdRemote):
|
||||
class CmdRemoteModify(CmdRemote):
|
||||
def run(self):
|
||||
with self.config.edit(self.args.level) as conf:
|
||||
self._check_exists(conf)
|
||||
merged = self.config.load_config_to_level(self.args.level)
|
||||
merge(merged, conf)
|
||||
self._check_exists(merged)
|
||||
|
||||
if self.args.name not in conf["remote"]:
|
||||
conf["remote"][self.args.name] = {}
|
||||
section = conf["remote"][self.args.name]
|
||||
if self.args.unset:
|
||||
section.pop(self.args.option, None)
|
||||
@@ -167,7 +172,7 @@ def add_parser(subparsers, parent_parser):
|
||||
remote_add_parser.add_argument("name", help="Name of the remote")
|
||||
remote_add_parser.add_argument(
|
||||
"url",
|
||||
help="URL. See full list of supported urls at {}".format(
|
||||
help="Remote location. See full list of supported URLs at {}".format(
|
||||
format_link("https://man.dvc.org/remote")
|
||||
),
|
||||
)
|
||||
@@ -196,7 +201,7 @@ def add_parser(subparsers, parent_parser):
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
remote_default_parser.add_argument(
|
||||
"name", nargs="?", help="Name of the remote."
|
||||
"name", nargs="?", help="Name of the remote"
|
||||
)
|
||||
remote_default_parser.add_argument(
|
||||
"-u",
|
||||
@@ -215,7 +220,7 @@ def add_parser(subparsers, parent_parser):
|
||||
help=REMOTE_MODIFY_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
remote_modify_parser.add_argument("name", help="Name of the remote.")
|
||||
remote_modify_parser.add_argument("name", help="Name of the remote")
|
||||
remote_modify_parser.add_argument(
|
||||
"option", help="Name of the option to modify."
|
||||
)
|
||||
|
||||
+49
-34
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.command.metrics import show_metrics
|
||||
from dvc.command.metrics import _show_metrics
|
||||
from dvc.command.status import CmdDataStatus
|
||||
from dvc.dvcfile import PIPELINE_FILE
|
||||
from dvc.exceptions import DvcException
|
||||
@@ -15,8 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
class CmdRepro(CmdBase):
|
||||
def run(self):
|
||||
saved_dir = os.path.realpath(os.curdir)
|
||||
if self.args.cwd:
|
||||
os.chdir(self.args.cwd)
|
||||
os.chdir(self.args.cwd)
|
||||
|
||||
# Dirty hack so the for loop below can at least enter once
|
||||
if self.args.all_pipelines:
|
||||
@@ -27,28 +26,20 @@ 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,
|
||||
)
|
||||
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()
|
||||
show_metrics(metrics)
|
||||
logger.info(_show_metrics(metrics))
|
||||
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
ret = 1
|
||||
@@ -57,18 +48,25 @@ 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,
|
||||
}
|
||||
|
||||
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="*",
|
||||
@@ -93,7 +91,8 @@ def add_parser(subparsers, parent_parser):
|
||||
"-c",
|
||||
"--cwd",
|
||||
default=os.path.curdir,
|
||||
help="Directory within your repo to reproduce from.",
|
||||
help="Directory within your repo to reproduce from. Note: deprecated "
|
||||
"by `dvc --cd <path>`.",
|
||||
metavar="<path>",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
@@ -168,10 +167,26 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-e",
|
||||
"--experiment",
|
||||
"--pull",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
help=(
|
||||
"Try automatically pulling missing cache for outputs restored "
|
||||
"from the run-cache."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
+28
-7
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.command import completion
|
||||
from dvc.command.base import CmdBase, append_doc_link
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
@@ -20,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,
|
||||
]
|
||||
@@ -44,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("")
|
||||
@@ -99,7 +103,7 @@ def add_parser(subparsers, parent_parser):
|
||||
default=[],
|
||||
help="Declare dependencies for reproducible cmd.",
|
||||
metavar="<path>",
|
||||
)
|
||||
).complete = completion.FILE
|
||||
run_parser.add_argument(
|
||||
"-n", "--name", help="Stage name.",
|
||||
)
|
||||
@@ -110,7 +114,7 @@ def add_parser(subparsers, parent_parser):
|
||||
default=[],
|
||||
help="Declare output file or directory.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
).complete = completion.FILE
|
||||
run_parser.add_argument(
|
||||
"-O",
|
||||
"--outs-no-cache",
|
||||
@@ -119,7 +123,7 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Declare output file or directory "
|
||||
"(do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
).complete = completion.FILE
|
||||
run_parser.add_argument(
|
||||
"-p",
|
||||
"--params",
|
||||
@@ -127,13 +131,13 @@ def add_parser(subparsers, parent_parser):
|
||||
default=[],
|
||||
help="Declare parameter to use as additional dependency.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
)
|
||||
).complete = completion.FILE
|
||||
run_parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output metric file.",
|
||||
help="Declare output metrics file.",
|
||||
metavar="<path>",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
@@ -141,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(
|
||||
@@ -211,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",
|
||||
@@ -229,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."
|
||||
)
|
||||
|
||||
+16
-2
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
|
||||
from dvc.command.data_sync import CmdDataBase
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils import format_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -9,6 +11,11 @@ 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!"
|
||||
).format(link=format_link("https://dvc.org/doc/start"))
|
||||
|
||||
def _normalize(self, s):
|
||||
s += ":"
|
||||
@@ -60,10 +67,17 @@ class CmdDataStatus(CmdDataBase):
|
||||
logger.info(json.dumps(st))
|
||||
elif st:
|
||||
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)
|
||||
|
||||
except Exception: # noqa, pylint: disable=broad-except
|
||||
logger.exception("failed to obtain data status")
|
||||
except DvcException:
|
||||
logger.exception("")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
+4
-144
@@ -1,159 +1,18 @@
|
||||
import argparse
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import uuid
|
||||
|
||||
from dvc.command.base import CmdBaseNoRepo, append_doc_link
|
||||
from dvc.exceptions import DvcException, NotDvcRepoError
|
||||
from dvc.scm.base import SCMError
|
||||
from dvc.system import System
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.pkg import PKG
|
||||
from dvc.version import __version__
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
psutil = None
|
||||
|
||||
from dvc.info import get_dvc_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdVersion(CmdBaseNoRepo):
|
||||
def run(self):
|
||||
from dvc.repo import Repo
|
||||
|
||||
package = PKG
|
||||
if PKG is None:
|
||||
package = ""
|
||||
else:
|
||||
package = f"({PKG})"
|
||||
|
||||
info = [
|
||||
f"DVC version: {__version__} {package}",
|
||||
"---------------------------------",
|
||||
f"Platform: Python {platform.python_version()} on "
|
||||
f"{platform.platform()}",
|
||||
f"Supports: {self.get_supported_remotes()}",
|
||||
]
|
||||
|
||||
try:
|
||||
repo = Repo()
|
||||
root_directory = repo.root_dir
|
||||
|
||||
# cache_dir might not exist yet (e.g. after `dvc init`), and we
|
||||
# can't auto-create it, as it might cause issues if the user
|
||||
# later decides to enable shared cache mode with
|
||||
# `dvc config cache.shared group`.
|
||||
if os.path.exists(repo.cache.local.cache_dir):
|
||||
info.append(
|
||||
"Cache types: {}".format(
|
||||
self.get_linktype_support_info(repo)
|
||||
)
|
||||
)
|
||||
if psutil:
|
||||
fs_type = self.get_fs_type(repo.cache.local.cache_dir)
|
||||
info.append(f"Cache directory: {fs_type}")
|
||||
else:
|
||||
logger.warning(
|
||||
"Unable to detect supported link types, as cache "
|
||||
"directory '{}' doesn't exist. It is usually auto-created "
|
||||
"by commands such as `dvc add/fetch/pull/run/import`, "
|
||||
"but you could create it manually to enable this "
|
||||
"check.".format(relpath(repo.cache.local.cache_dir))
|
||||
)
|
||||
|
||||
except NotDvcRepoError:
|
||||
root_directory = os.getcwd()
|
||||
except SCMError:
|
||||
root_directory = os.getcwd()
|
||||
info.append("Repo: dvc, git (broken)")
|
||||
else:
|
||||
if psutil:
|
||||
fs_root = self.get_fs_type(os.path.abspath(root_directory))
|
||||
info.append(f"Workspace directory: {fs_root}")
|
||||
|
||||
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
|
||||
|
||||
logger.info("\n".join(info))
|
||||
dvc_info = get_dvc_info()
|
||||
logger.info(dvc_info)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_fs_type(path):
|
||||
partition = {
|
||||
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
|
||||
for part in psutil.disk_partitions(all=True)
|
||||
}
|
||||
|
||||
path = pathlib.Path(path)
|
||||
|
||||
for parent in itertools.chain([path], path.parents):
|
||||
if parent in partition:
|
||||
return partition[parent]
|
||||
return ("unknown", "none")
|
||||
|
||||
@staticmethod
|
||||
def get_linktype_support_info(repo):
|
||||
links = {
|
||||
"reflink": (System.reflink, None),
|
||||
"hardlink": (System.hardlink, System.is_hardlink),
|
||||
"symlink": (System.symlink, System.is_symlink),
|
||||
}
|
||||
|
||||
fname = "." + str(uuid.uuid4())
|
||||
src = os.path.join(repo.cache.local.cache_dir, fname)
|
||||
open(src, "w").close()
|
||||
dst = os.path.join(repo.root_dir, fname)
|
||||
|
||||
cache = []
|
||||
|
||||
for name, (link, is_link) in links.items():
|
||||
try:
|
||||
link(src, dst)
|
||||
status = "supported"
|
||||
if is_link and not is_link(dst):
|
||||
status = "broken"
|
||||
os.unlink(dst)
|
||||
except DvcException:
|
||||
status = "not supported"
|
||||
|
||||
if status == "supported":
|
||||
cache.append(name)
|
||||
os.remove(src)
|
||||
|
||||
return ", ".join(cache)
|
||||
|
||||
@staticmethod
|
||||
def get_supported_remotes():
|
||||
from dvc.tree import TREES
|
||||
|
||||
supported_remotes = []
|
||||
for tree_cls in TREES:
|
||||
if not tree_cls.get_missing_deps():
|
||||
supported_remotes.append(tree_cls.scheme)
|
||||
|
||||
if len(supported_remotes) == len(TREES):
|
||||
return "All remotes"
|
||||
|
||||
if len(supported_remotes) == 1:
|
||||
return supported_remotes
|
||||
|
||||
return ", ".join(supported_remotes)
|
||||
|
||||
|
||||
def _get_dvc_repo_info(repo):
|
||||
if repo.config.get("core", {}).get("no_scm", False):
|
||||
return "dvc (no_scm)"
|
||||
|
||||
if repo.root_dir != repo.scm.root_dir:
|
||||
return "dvc (subdir), git"
|
||||
|
||||
return "dvc, git"
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
VERSION_HELP = (
|
||||
@@ -165,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)
|
||||
|
||||
+60
-11
@@ -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,
|
||||
}
|
||||
@@ -118,7 +119,19 @@ HTTP_COMMON = {
|
||||
"user": str,
|
||||
"password": str,
|
||||
"ask_password": Bool,
|
||||
"ssl_verify": Bool,
|
||||
"method": str,
|
||||
}
|
||||
WEBDAV_COMMON = {
|
||||
"user": str,
|
||||
"password": str,
|
||||
"ask_password": Bool,
|
||||
"token": str,
|
||||
"cert_path": str,
|
||||
"key_path": str,
|
||||
"timeout": Coerce(int),
|
||||
}
|
||||
|
||||
SCHEMA = {
|
||||
"core": {
|
||||
"remote": Lower,
|
||||
@@ -127,13 +140,16 @@ SCHEMA = {
|
||||
Optional("analytics", default=True): Bool,
|
||||
Optional("hardlink_lock", default=False): Bool,
|
||||
Optional("no_scm", default=False): Bool,
|
||||
Optional("autostage", default=False): Bool,
|
||||
Optional("experiments", default=False): Bool,
|
||||
Optional("check_update", default=True): Bool,
|
||||
},
|
||||
"cache": {
|
||||
"local": str,
|
||||
"s3": str,
|
||||
"gs": str,
|
||||
"hdfs": str,
|
||||
"webhdfs": str,
|
||||
"ssh": str,
|
||||
"azure": str,
|
||||
# This is for default local cache
|
||||
@@ -151,7 +167,7 @@ SCHEMA = {
|
||||
"endpointurl": str,
|
||||
"access_key_id": str,
|
||||
"secret_access_key": str,
|
||||
Optional("listobjects", default=False): Bool,
|
||||
Optional("listobjects", default=False): Bool, # obsoleted
|
||||
Optional("use_ssl", default=True): Bool,
|
||||
"sse": str,
|
||||
"sse_kms_key_id": str,
|
||||
@@ -176,9 +192,17 @@ SCHEMA = {
|
||||
"keyfile": str,
|
||||
"timeout": Coerce(int),
|
||||
"gss_auth": Bool,
|
||||
"allow_agent": Bool,
|
||||
**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,
|
||||
@@ -199,6 +223,8 @@ SCHEMA = {
|
||||
},
|
||||
"http": {**HTTP_COMMON, **REMOTE_COMMON},
|
||||
"https": {**HTTP_COMMON, **REMOTE_COMMON},
|
||||
"webdav": {**WEBDAV_COMMON, **REMOTE_COMMON},
|
||||
"webdavs": {**WEBDAV_COMMON, **REMOTE_COMMON},
|
||||
"remote": {str: object}, # Any of the above options are valid
|
||||
}
|
||||
)
|
||||
@@ -207,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)
|
||||
|
||||
@@ -258,7 +286,7 @@ class Config(dict):
|
||||
|
||||
@classmethod
|
||||
def get_dir(cls, level):
|
||||
from appdirs import user_config_dir, site_config_dir
|
||||
from appdirs import site_config_dir, user_config_dir
|
||||
|
||||
assert level in ("global", "system")
|
||||
|
||||
@@ -312,11 +340,17 @@ class Config(dict):
|
||||
if not self["cache"].get("dir") and self.dvc_dir:
|
||||
self["cache"]["dir"] = os.path.join(self.dvc_dir, "cache")
|
||||
|
||||
def _get_tree(self, level):
|
||||
# NOTE: this might be a GitTree, which doesn't see things outside of
|
||||
# the repo.
|
||||
return self.tree if level == "repo" else self.wtree
|
||||
|
||||
def _load_config(self, level):
|
||||
filename = self.files[level]
|
||||
tree = self._get_tree(level)
|
||||
|
||||
if self.tree.exists(filename, use_dvcignore=False):
|
||||
with self.tree.open(filename) as fobj:
|
||||
if tree.exists(filename, use_dvcignore=False):
|
||||
with tree.open(filename) as fobj:
|
||||
conf_obj = configobj.ConfigObj(fobj)
|
||||
else:
|
||||
conf_obj = configobj.ConfigObj()
|
||||
@@ -324,13 +358,14 @@ class Config(dict):
|
||||
|
||||
def _save_config(self, level, conf_dict):
|
||||
filename = self.files[level]
|
||||
tree = self._get_tree(level)
|
||||
|
||||
logger.debug(f"Writing '{filename}'.")
|
||||
|
||||
self.tree.makedirs(os.path.dirname(filename))
|
||||
tree.makedirs(os.path.dirname(filename))
|
||||
|
||||
config = configobj.ConfigObj(_pack_remotes(conf_dict))
|
||||
with self.tree.open(filename, "wb") as fobj:
|
||||
with tree.open(filename, "wb") as fobj:
|
||||
config.write(fobj)
|
||||
config.filename = filename
|
||||
|
||||
@@ -351,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)
|
||||
@@ -377,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)
|
||||
@@ -388,7 +437,7 @@ class Config(dict):
|
||||
if merge_level == level:
|
||||
break
|
||||
if merge_level in self.files:
|
||||
_merge(merged_conf, self.load_one(merge_level))
|
||||
merge(merged_conf, self.load_one(merge_level))
|
||||
return merged_conf
|
||||
|
||||
@contextmanager
|
||||
@@ -402,7 +451,7 @@ class Config(dict):
|
||||
conf = self._save_paths(conf, self.files[level])
|
||||
|
||||
merged_conf = self.load_config_to_level(level)
|
||||
_merge(merged_conf, conf)
|
||||
merge(merged_conf, conf)
|
||||
self.validate(merged_conf)
|
||||
|
||||
self._save_config(level, conf)
|
||||
@@ -441,11 +490,11 @@ def _pack_remotes(conf):
|
||||
return result
|
||||
|
||||
|
||||
def _merge(into, update):
|
||||
def merge(into, update):
|
||||
"""Merges second dict into first recursively"""
|
||||
for key, val in update.items():
|
||||
if isinstance(into.get(key), dict) and isinstance(val, dict):
|
||||
_merge(into[key], val)
|
||||
merge(into[key], val)
|
||||
else:
|
||||
into[key] = val
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ def _popen(cmd, **kwargs):
|
||||
|
||||
|
||||
def _spawn_windows(cmd, env):
|
||||
from subprocess import STARTUPINFO, STARTF_USESHOWWINDOW
|
||||
from subprocess import STARTF_USESHOWWINDOW, STARTUPINFO
|
||||
|
||||
creationflags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
|
||||
|
||||
|
||||
+30
-22
@@ -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,26 +84,21 @@ class DataCloud:
|
||||
"""
|
||||
remote = self.get_remote(remote, "pull")
|
||||
|
||||
downloaded_items_num = 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,
|
||||
)
|
||||
|
||||
if not remote.tree.verify:
|
||||
self._save_pulled_checksums(cache)
|
||||
|
||||
return downloaded_items_num
|
||||
|
||||
def _save_pulled_checksums(self, cache):
|
||||
for checksum in cache.scheme_keys("local"):
|
||||
cache_file = self.repo.cache.local.tree.hash_to_path_info(checksum)
|
||||
if self.repo.cache.local.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 `RemoteBASE.download()`)
|
||||
self.repo.state.save(cache_file, checksum)
|
||||
|
||||
def status(self, cache, jobs=None, remote=None, show_checksums=False):
|
||||
def status(
|
||||
self,
|
||||
cache,
|
||||
jobs=None,
|
||||
remote=None,
|
||||
show_checksums=False,
|
||||
log_missing=True,
|
||||
):
|
||||
"""Check status of data items in a cloud-agnostic way.
|
||||
|
||||
Args:
|
||||
@@ -111,8 +109,18 @@ class DataCloud:
|
||||
is used.
|
||||
show_checksums (bool): show checksums instead of file names in
|
||||
information messages.
|
||||
log_missing (bool): log warning messages if file doesn't exist
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
remote = self.get_remote(remote, "status")
|
||||
return self.repo.cache.local.status(
|
||||
cache, jobs=jobs, remote=remote, show_checksums=show_checksums
|
||||
return remote.status(
|
||||
self.repo.cache.local,
|
||||
cache,
|
||||
jobs=jobs,
|
||||
show_checksums=show_checksums,
|
||||
log_missing=log_missing,
|
||||
)
|
||||
|
||||
def get_url_for(self, remote, checksum):
|
||||
remote = self.get_remote(remote)
|
||||
return str(remote.tree.hash_to_path_info(checksum))
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-34
@@ -2,12 +2,12 @@ import os
|
||||
from collections import defaultdict
|
||||
|
||||
import dpath.util
|
||||
import toml
|
||||
import yaml
|
||||
from voluptuous import Any
|
||||
|
||||
from dvc.dependency.local import LocalDependency
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.utils.serialize import LOADERS, ParseError
|
||||
|
||||
|
||||
class MissingParamsError(DvcException):
|
||||
@@ -22,8 +22,6 @@ class ParamsDependency(LocalDependency):
|
||||
PARAM_PARAMS = "params"
|
||||
PARAM_SCHEMA = {PARAM_PARAMS: Any(dict, list, None)}
|
||||
DEFAULT_PARAMS_FILE = "params.yaml"
|
||||
PARAMS_FILE_LOADERS = defaultdict(lambda: yaml.safe_load)
|
||||
PARAMS_FILE_LOADERS.update({".toml": toml.load})
|
||||
|
||||
def __init__(self, stage, path, params):
|
||||
info = {}
|
||||
@@ -34,7 +32,7 @@ class ParamsDependency(LocalDependency):
|
||||
else:
|
||||
assert isinstance(params, dict)
|
||||
self.params = list(params.keys())
|
||||
info = params
|
||||
info = {self.PARAM_PARAMS: params}
|
||||
|
||||
super().__init__(
|
||||
stage,
|
||||
@@ -43,60 +41,61 @@ class ParamsDependency(LocalDependency):
|
||||
info=info,
|
||||
)
|
||||
|
||||
def dumpd(self):
|
||||
ret = super().dumpd()
|
||||
if not self.hash_info:
|
||||
ret[self.PARAM_PARAMS] = self.params
|
||||
return ret
|
||||
|
||||
def fill_values(self, values=None):
|
||||
"""Load params values dynamically."""
|
||||
if not values:
|
||||
return
|
||||
info = {}
|
||||
for param in self.params:
|
||||
if param in values:
|
||||
self.info[param] = values[param]
|
||||
info[param] = values[param]
|
||||
self.hash_info = HashInfo(self.PARAM_PARAMS, info)
|
||||
|
||||
def save(self):
|
||||
super().save()
|
||||
self.info = self.save_info()
|
||||
def workspace_status(self):
|
||||
status = super().workspace_status()
|
||||
|
||||
def status(self):
|
||||
status = super().status()
|
||||
|
||||
if status[str(self)] == "deleted":
|
||||
if status.get(str(self)) == "deleted":
|
||||
return status
|
||||
|
||||
status = defaultdict(dict)
|
||||
info = self.read_params()
|
||||
info = self.hash_info.value if self.hash_info else {}
|
||||
actual = self.read_params()
|
||||
for param in self.params:
|
||||
if param not in info.keys():
|
||||
if param not in actual.keys():
|
||||
st = "deleted"
|
||||
elif param not in self.info:
|
||||
elif param not in info:
|
||||
st = "new"
|
||||
elif info[param] != self.info[param]:
|
||||
elif actual[param] != info[param]:
|
||||
st = "modified"
|
||||
else:
|
||||
assert info[param] == self.info[param]
|
||||
assert actual[param] == info[param]
|
||||
continue
|
||||
|
||||
status[str(self)][param] = st
|
||||
|
||||
return status
|
||||
|
||||
def dumpd(self):
|
||||
return {
|
||||
self.PARAM_PATH: self.def_path,
|
||||
self.PARAM_PARAMS: self.info or self.params,
|
||||
}
|
||||
def status(self):
|
||||
return self.workspace_status()
|
||||
|
||||
def read_params(self):
|
||||
if not self.exists:
|
||||
return {}
|
||||
|
||||
with self.repo.tree.open(self.path_info, "r") as fobj:
|
||||
try:
|
||||
config = self.PARAMS_FILE_LOADERS[
|
||||
self.path_info.suffix.lower()
|
||||
](fobj)
|
||||
except (yaml.YAMLError, toml.TomlDecodeError) as exc:
|
||||
raise BadParamFileError(
|
||||
f"Unable to read parameters from '{self}'"
|
||||
) from exc
|
||||
suffix = self.path_info.suffix.lower()
|
||||
loader = LOADERS[suffix]
|
||||
try:
|
||||
config = loader(self.path_info, tree=self.repo.tree)
|
||||
except ParseError as exc:
|
||||
raise BadParamFileError(
|
||||
f"Unable to read parameters from '{self}'"
|
||||
) from exc
|
||||
|
||||
ret = {}
|
||||
for param in self.params:
|
||||
@@ -106,7 +105,7 @@ class ParamsDependency(LocalDependency):
|
||||
pass
|
||||
return ret
|
||||
|
||||
def save_info(self):
|
||||
def get_hash(self):
|
||||
info = self.read_params()
|
||||
|
||||
missing_params = set(self.params) - set(info.keys())
|
||||
@@ -117,4 +116,4 @@ class ParamsDependency(LocalDependency):
|
||||
)
|
||||
)
|
||||
|
||||
return info
|
||||
return HashInfo(self.PARAM_PARAMS, info)
|
||||
|
||||
+20
-33
@@ -1,10 +1,5 @@
|
||||
import os
|
||||
|
||||
from voluptuous import Required
|
||||
|
||||
from dvc.exceptions import OutputNotFoundError
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
from .local import LocalDependency
|
||||
|
||||
|
||||
@@ -42,40 +37,31 @@ class RepoDependency(LocalDependency):
|
||||
def __str__(self):
|
||||
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
|
||||
|
||||
def _make_repo(self, *, locked=True):
|
||||
def _make_repo(self, *, locked=True, **kwargs):
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
d = self.def_repo
|
||||
rev = (d.get("rev_lock") if locked else None) or d.get("rev")
|
||||
return external_repo(d["url"], rev=rev)
|
||||
return external_repo(d["url"], rev=rev, **kwargs)
|
||||
|
||||
def _get_checksum(self, locked=True):
|
||||
from dvc.repo.tree import RepoTree
|
||||
def _get_hash(self, locked=True):
|
||||
# we want stream but not fetch, so DVC out directories are
|
||||
# walked, but dir contents is not fetched
|
||||
with self._make_repo(locked=locked, fetch=False, stream=True) as repo:
|
||||
return repo.get_checksum(self.def_path)
|
||||
|
||||
with self._make_repo(locked=locked) as repo:
|
||||
try:
|
||||
return repo.find_out_by_relpath(self.def_path).info["md5"]
|
||||
except OutputNotFoundError:
|
||||
path = PathInfo(os.path.join(repo.root_dir, self.def_path))
|
||||
def workspace_status(self):
|
||||
current = self._get_hash(locked=True)
|
||||
updated = self._get_hash(locked=False)
|
||||
|
||||
# we want stream but not fetch, so DVC out directories are
|
||||
# walked, but dir contents is not fetched
|
||||
tree = RepoTree(repo, stream=True)
|
||||
|
||||
# We are polluting our repo cache with some dir listing here
|
||||
if tree.isdir(path):
|
||||
return self.repo.cache.local.tree.get_hash(path, tree=tree)
|
||||
return tree.get_file_hash(path)
|
||||
|
||||
def status(self):
|
||||
current_checksum = self._get_checksum(locked=True)
|
||||
updated_checksum = self._get_checksum(locked=False)
|
||||
|
||||
if current_checksum != updated_checksum:
|
||||
if current != updated:
|
||||
return {str(self): "update available"}
|
||||
|
||||
return {}
|
||||
|
||||
def status(self):
|
||||
return self.workspace_status()
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
@@ -83,14 +69,15 @@ class RepoDependency(LocalDependency):
|
||||
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
|
||||
|
||||
def download(self, to):
|
||||
with self._make_repo() as repo:
|
||||
cache = self.repo.cache.local
|
||||
|
||||
with self._make_repo(cache_dir=cache.cache_dir) as repo:
|
||||
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
|
||||
|
||||
cache = self.repo.cache.local
|
||||
with repo.use_cache(cache):
|
||||
_, _, cache_infos = repo.fetch_external([self.def_path])
|
||||
cache.checkout(to.path_info, cache_infos[0])
|
||||
_, _, cache_infos = repo.fetch_external([self.def_path])
|
||||
|
||||
cache.checkout(to.path_info, cache_infos[0])
|
||||
|
||||
def update(self, rev=None):
|
||||
if rev:
|
||||
|
||||
@@ -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
|
||||
+75
-40
@@ -16,7 +16,13 @@ from dvc.stage.exceptions import (
|
||||
from dvc.stage.loader import SingleStageLoader, StageLoader
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.collections import apply_diff
|
||||
from dvc.utils.yaml import dump_yaml, parse_yaml, parse_yaml_for_update
|
||||
from dvc.utils.serialize import (
|
||||
dump_yaml,
|
||||
load_yaml,
|
||||
modify_yaml,
|
||||
parse_yaml,
|
||||
parse_yaml_for_update,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,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,
|
||||
@@ -39,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(
|
||||
@@ -56,9 +70,10 @@ def check_dvc_filename(path):
|
||||
class FileMixin:
|
||||
SCHEMA = None
|
||||
|
||||
def __init__(self, repo, path, **kwargs):
|
||||
def __init__(self, repo, path, verify=True, **kwargs):
|
||||
self.repo = repo
|
||||
self.path = path
|
||||
self.verify = verify
|
||||
|
||||
def __repr__(self):
|
||||
return "{}: {}".format(
|
||||
@@ -90,7 +105,8 @@ class FileMixin:
|
||||
# 3. path doesn't represent a regular file
|
||||
if not self.exists():
|
||||
raise StageFileDoesNotExistError(self.path)
|
||||
check_dvc_filename(self.path)
|
||||
if self.verify:
|
||||
check_dvc_filename(self.path)
|
||||
if not self.repo.tree.isfile(self.path):
|
||||
raise StageFileIsNotDvcFileError(self.path)
|
||||
|
||||
@@ -115,6 +131,9 @@ class FileMixin:
|
||||
def dump(self, stage, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def merge(self, ancestor, other):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SingleStageFile(FileMixin):
|
||||
from dvc.schema import COMPILED_SINGLE_STAGE_SCHEMA as SCHEMA
|
||||
@@ -134,7 +153,8 @@ class SingleStageFile(FileMixin):
|
||||
from dvc.stage import PipelineStage
|
||||
|
||||
assert not isinstance(stage, PipelineStage)
|
||||
check_dvc_filename(self.path)
|
||||
if self.verify:
|
||||
check_dvc_filename(self.path)
|
||||
logger.debug(
|
||||
"Saving information to '{file}'.".format(file=relpath(self.path))
|
||||
)
|
||||
@@ -144,6 +164,14 @@ class SingleStageFile(FileMixin):
|
||||
def remove_stage(self, stage): # pylint: disable=unused-argument
|
||||
self.remove()
|
||||
|
||||
def merge(self, ancestor, other):
|
||||
assert isinstance(ancestor, SingleStageFile)
|
||||
assert isinstance(other, SingleStageFile)
|
||||
|
||||
stage = self.stage
|
||||
stage.merge(ancestor.stage, other.stage)
|
||||
self.dump(stage)
|
||||
|
||||
|
||||
class PipelineFile(FileMixin):
|
||||
"""Abstraction for pipelines file, .yaml + .lock combined."""
|
||||
@@ -155,48 +183,52 @@ class PipelineFile(FileMixin):
|
||||
return Lockfile(self.repo, os.path.splitext(self.path)[0] + ".lock")
|
||||
|
||||
def dump(
|
||||
self, stage, update_pipeline=False, no_lock=False, **kwargs
|
||||
self, stage, update_pipeline=True, update_lock=True, **kwargs
|
||||
): # pylint: disable=arguments-differ
|
||||
"""Dumps given stage appropriately in the dvcfile."""
|
||||
from dvc.stage import PipelineStage
|
||||
|
||||
assert isinstance(stage, PipelineStage)
|
||||
check_dvc_filename(self.path)
|
||||
if self.verify:
|
||||
check_dvc_filename(self.path)
|
||||
|
||||
if update_pipeline and not stage.is_data_source:
|
||||
self._dump_pipeline_file(stage)
|
||||
|
||||
if not no_lock:
|
||||
if update_lock:
|
||||
self._dump_lockfile(stage)
|
||||
|
||||
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):
|
||||
data = {}
|
||||
if self.exists():
|
||||
with open(self.path) as fd:
|
||||
data = parse_yaml_for_update(fd.read(), self.path)
|
||||
else:
|
||||
logger.info("Creating '%s'", self.relpath)
|
||||
open(self.path, "w+").close()
|
||||
|
||||
data["stages"] = data.get("stages", {})
|
||||
self._check_if_parametrized(stage)
|
||||
stage_data = serialize.to_pipeline_file(stage)
|
||||
existing_entry = stage.name in data["stages"]
|
||||
|
||||
action = "Modifying" if existing_entry else "Adding"
|
||||
logger.info("%s stage '%s' in '%s'", action, stage.name, self.relpath)
|
||||
with modify_yaml(self.path, tree=self.repo.tree) as data:
|
||||
if not data:
|
||||
logger.info("Creating '%s'", self.relpath)
|
||||
|
||||
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)
|
||||
data["stages"] = data.get("stages", {})
|
||||
existing_entry = stage.name in data["stages"]
|
||||
action = "Modifying" if existing_entry else "Adding"
|
||||
logger.info(
|
||||
"%s stage '%s' in '%s'", action, stage.name, self.relpath
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
dump_yaml(self.path, data)
|
||||
self.repo.scm.track_file(self.relpath)
|
||||
|
||||
@property
|
||||
@@ -209,7 +241,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:
|
||||
@@ -239,6 +271,9 @@ class PipelineFile(FileMixin):
|
||||
else:
|
||||
super().remove()
|
||||
|
||||
def merge(self, ancestor, other):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Lockfile(FileMixin):
|
||||
from dvc.schema import COMPILED_LOCKFILE_SCHEMA as SCHEMA
|
||||
@@ -246,8 +281,8 @@ class Lockfile(FileMixin):
|
||||
def load(self):
|
||||
if not self.exists():
|
||||
return {}
|
||||
with self.repo.tree.open(self.path) as fd:
|
||||
data = parse_yaml(fd.read(), self.path)
|
||||
|
||||
data = load_yaml(self.path, tree=self.repo.tree)
|
||||
try:
|
||||
self.validate(data, fname=self.relpath)
|
||||
except StageFileFormatError:
|
||||
@@ -258,21 +293,18 @@ class Lockfile(FileMixin):
|
||||
|
||||
def dump(self, stage, **kwargs):
|
||||
stage_data = serialize.to_lockfile(stage)
|
||||
if not self.exists():
|
||||
modified = True
|
||||
logger.info("Generating lock file '%s'", self.relpath)
|
||||
data = stage_data
|
||||
open(self.path, "w+").close()
|
||||
else:
|
||||
with self.repo.tree.open(self.path, "r") as fd:
|
||||
data = parse_yaml_for_update(fd.read(), self.path)
|
||||
|
||||
with modify_yaml(self.path, tree=self.repo.tree) as data:
|
||||
if not data:
|
||||
logger.info("Generating lock file '%s'", self.relpath)
|
||||
|
||||
modified = data.get(stage.name, {}) != stage_data.get(
|
||||
stage.name, {}
|
||||
)
|
||||
if modified:
|
||||
logger.info("Updating lock file '%s'", self.relpath)
|
||||
data.update(stage_data)
|
||||
dump_yaml(self.path, data)
|
||||
|
||||
if modified:
|
||||
self.repo.scm.track_file(self.relpath)
|
||||
|
||||
@@ -295,6 +327,9 @@ class Lockfile(FileMixin):
|
||||
else:
|
||||
self.remove()
|
||||
|
||||
def merge(self, ancestor, other):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Dvcfile:
|
||||
def __new__(cls, repo, path, **kwargs):
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
DVC_CHECKPOINT = "DVC_CHECKPOINT"
|
||||
DVC_DAEMON = "DVC_DAEMON"
|
||||
DVC_PAGER = "DVC_PAGER"
|
||||
DVC_ROOT = "DVC_ROOT"
|
||||
|
||||
+44
-21
@@ -1,7 +1,7 @@
|
||||
"""Exceptions raised by the dvc."""
|
||||
from funcy import first
|
||||
|
||||
from dvc.utils import format_link, relpath
|
||||
from dvc.utils import error_link, format_link, relpath
|
||||
|
||||
|
||||
class DvcException(Exception):
|
||||
@@ -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,33 +164,30 @@ 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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class NoMetricsError(DvcException):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
class NoMetricsParsedError(NoMetricsError):
|
||||
def __init__(self, command):
|
||||
super().__init__(
|
||||
"no metric files in this repository. Use `-m/-M` options for "
|
||||
"`dvc run` to mark stage outputs as metrics."
|
||||
f"Could not parse {command} files. Use `-v` option to see more "
|
||||
"details."
|
||||
)
|
||||
|
||||
|
||||
class NoPlotsError(DvcException):
|
||||
def __init__(self):
|
||||
class NoMetricsFoundError(NoMetricsError):
|
||||
def __init__(self, command, run_options):
|
||||
super().__init__(
|
||||
"no plots in this repository. Use `--plots/--plots-no-cache` "
|
||||
"options for `dvc run` to mark stage outputs as plots."
|
||||
)
|
||||
|
||||
|
||||
class YAMLFileCorruptedError(DvcException):
|
||||
def __init__(self, path):
|
||||
path = relpath(path)
|
||||
super().__init__(
|
||||
f"unable to read: '{path}', YAML file structure is corrupted"
|
||||
f"No {command} files in this repository. "
|
||||
f"Use `{run_options}` options for "
|
||||
f"`dvc run` to mark stage outputs as {command}."
|
||||
)
|
||||
|
||||
|
||||
@@ -230,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__(
|
||||
@@ -268,8 +270,10 @@ class CheckoutError(DvcException):
|
||||
self.stats = stats
|
||||
targets = [str(t) for t in target_infos]
|
||||
m = (
|
||||
"Checkout failed for following targets:\n{}\nDid you "
|
||||
"forget to fetch?".format("\n".join(targets))
|
||||
"Checkout failed for following targets:\n{}\nIs your "
|
||||
"cache up to date?\n{}".format(
|
||||
"\n".join(targets), error_link("missing-files"),
|
||||
)
|
||||
)
|
||||
super().__init__(m)
|
||||
|
||||
@@ -344,3 +348,22 @@ class NoOutputOrStageError(DvcException):
|
||||
f"'{target}' "
|
||||
f"does not exist as an output or a stage name in '{file}'"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
+184
-136
@@ -4,12 +4,14 @@ 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, retry, wrap_with
|
||||
from funcy import cached_property, reraise, retry, wrap_with
|
||||
|
||||
from dvc.cache import Cache
|
||||
from dvc.config import NoRemoteError, NotDvcRepoError
|
||||
from dvc.exceptions import (
|
||||
DvcException,
|
||||
FileMissingError,
|
||||
NoOutputInExternalRepoError,
|
||||
NoRemoteInExternalRepoError,
|
||||
@@ -18,47 +20,61 @@ from dvc.exceptions import (
|
||||
)
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.repo.tree import RepoTree
|
||||
from dvc.scm.base import CloneError
|
||||
from dvc.scm.git import Git
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.tree.repo import RepoTree
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import remove
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IsADVCRepoError(DvcException):
|
||||
"""Raised when it is not expected to be a dvc repo."""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def external_repo(url, rev=None, for_write=False):
|
||||
def external_repo(url, rev=None, for_write=False, **kwargs):
|
||||
logger.debug("Creating external repo %s@%s", url, rev)
|
||||
path = _cached_clone(url, rev, for_write=for_write)
|
||||
if not rev:
|
||||
# Local HEAD points to the tip of whatever branch we first cloned from
|
||||
# (which may not be the default branch), use origin/HEAD here to get
|
||||
# the tip of the default branch
|
||||
rev = "refs/remotes/origin/HEAD"
|
||||
try:
|
||||
repo = ExternalRepo(path, url, rev, for_write=for_write)
|
||||
except NotDvcRepoError:
|
||||
repo = ExternalGitRepo(path, url, rev)
|
||||
# Local HEAD points to the tip of whatever branch we first cloned from
|
||||
# (which may not be the default branch), use origin/HEAD here to get
|
||||
# the tip of the default branch
|
||||
rev = rev or "refs/remotes/origin/HEAD"
|
||||
|
||||
root_dir = path if for_write else os.path.realpath(path)
|
||||
conf = dict(
|
||||
root_dir=root_dir,
|
||||
url=url,
|
||||
scm=None if for_write else Git(root_dir),
|
||||
rev=None if for_write else rev,
|
||||
for_write=for_write,
|
||||
uninitialized=True,
|
||||
**kwargs,
|
||||
)
|
||||
repo = ExternalRepo(**conf)
|
||||
|
||||
try:
|
||||
yield repo
|
||||
except NoRemoteError:
|
||||
raise NoRemoteInExternalRepoError(url)
|
||||
except NoRemoteError as exc:
|
||||
raise NoRemoteInExternalRepoError(url) from exc
|
||||
except OutputNotFoundError as exc:
|
||||
if exc.repo is repo:
|
||||
raise NoOutputInExternalRepoError(exc.output, repo.root_dir, url)
|
||||
raise NoOutputInExternalRepoError(
|
||||
exc.output, repo.root_dir, url
|
||||
) from exc
|
||||
raise
|
||||
except FileMissingError as exc:
|
||||
raise PathMissingError(exc.path, url)
|
||||
raise PathMissingError(exc.path, url) from exc
|
||||
finally:
|
||||
repo.close()
|
||||
if for_write:
|
||||
_remove(path)
|
||||
|
||||
|
||||
CLONES = {}
|
||||
CACHE_DIRS = {}
|
||||
CLONES: Dict[str, str] = {}
|
||||
CACHE_DIRS: Dict[str, str] = {}
|
||||
|
||||
|
||||
def clean_repos():
|
||||
@@ -71,45 +87,67 @@ def clean_repos():
|
||||
_remove(path)
|
||||
|
||||
|
||||
class BaseExternalRepo:
|
||||
class ExternalRepo(Repo):
|
||||
# pylint: disable=no-member
|
||||
|
||||
_local_cache = None
|
||||
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
|
||||
|
||||
@property
|
||||
def local_cache(self):
|
||||
if hasattr(self, "cache"):
|
||||
return self.cache.local
|
||||
return self._local_cache
|
||||
|
||||
@contextmanager
|
||||
def use_cache(self, cache):
|
||||
"""Use the specified cache in place of default tmpdir cache for
|
||||
download operations.
|
||||
"""
|
||||
if hasattr(self, "cache"):
|
||||
save_cache = self.cache.local
|
||||
self.cache.local = cache
|
||||
self._local_cache = cache
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(self, "cache"):
|
||||
self.cache.local = save_cache
|
||||
self._local_cache = None
|
||||
|
||||
@cached_property
|
||||
def repo_tree(self):
|
||||
return RepoTree(self, fetch=True)
|
||||
return self._get_tree_for(
|
||||
self, subrepos=not self.for_write, repo_factory=self.make_repo
|
||||
)
|
||||
|
||||
def get_rev(self):
|
||||
assert self.scm
|
||||
if isinstance(self.tree, LocalTree):
|
||||
return self.scm.get_rev()
|
||||
return self.tree.rev
|
||||
|
||||
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, **kwargs
|
||||
)
|
||||
cache.save(
|
||||
path_info,
|
||||
tree,
|
||||
hash_info,
|
||||
save_link=False,
|
||||
download_callback=callback,
|
||||
)
|
||||
return hash_info
|
||||
|
||||
def fetch_external(self, paths: Iterable, **kwargs):
|
||||
"""Fetch specified external repo paths into cache.
|
||||
|
||||
@@ -120,130 +158,135 @@ class BaseExternalRepo:
|
||||
"""
|
||||
download_results = []
|
||||
failed = 0
|
||||
root = PathInfo(self.root_dir)
|
||||
|
||||
paths = [PathInfo(self.root_dir) / path for path in paths]
|
||||
paths = [root / path for path in paths]
|
||||
|
||||
def download_update(result):
|
||||
download_results.append(result)
|
||||
|
||||
save_infos = []
|
||||
hash_infos = []
|
||||
for path in paths:
|
||||
if not self.repo_tree.exists(path):
|
||||
raise PathMissingError(path, self.url)
|
||||
save_info = self.local_cache.save(
|
||||
path,
|
||||
self.repo_tree,
|
||||
None,
|
||||
save_link=False,
|
||||
download_callback=download_update,
|
||||
)
|
||||
save_infos.append(save_info)
|
||||
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
|
||||
metadata = self.repo_tree.metadata(path)
|
||||
|
||||
return sum(download_results), failed, save_infos
|
||||
self._check_repo(path, metadata.repo)
|
||||
repo = metadata.repo
|
||||
hash_info = self._fetch_to_cache(
|
||||
path, repo, download_update, **kwargs
|
||||
)
|
||||
hash_infos.append(hash_info)
|
||||
|
||||
return sum(download_results), failed, hash_infos
|
||||
|
||||
def _check_repo(self, path_info, repo):
|
||||
if not repo:
|
||||
return
|
||||
|
||||
repo_path = PathInfo(repo.root_dir)
|
||||
if path_info == repo_path and isinstance(repo, Repo):
|
||||
message = "Cannot fetch a complete DVC repository"
|
||||
if repo.root_dir != self.root_dir:
|
||||
rel = relpath(repo.root_dir, self.root_dir)
|
||||
message += f" '{rel}'"
|
||||
raise IsADVCRepoError(message)
|
||||
|
||||
def get_external(self, path, dest):
|
||||
"""Convenience wrapper for fetch_external and checkout."""
|
||||
if self.local_cache:
|
||||
path_info = PathInfo(self.root_dir) / path
|
||||
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
|
||||
metadata = self.repo_tree.metadata(path_info)
|
||||
|
||||
self._check_repo(path_info, metadata.repo)
|
||||
if metadata.output_exists:
|
||||
repo = metadata.repo
|
||||
cache = repo.cache.local
|
||||
# fetch DVC and git files to tmpdir cache, then checkout
|
||||
_, _, save_infos = self.fetch_external([path])
|
||||
self.local_cache.checkout(PathInfo(dest), save_infos[0])
|
||||
save_info = self._fetch_to_cache(path_info, repo, None)
|
||||
cache.checkout(PathInfo(dest), save_info)
|
||||
else:
|
||||
# git-only erepo with no cache, just copy files directly
|
||||
# to dest
|
||||
path = PathInfo(self.root_dir) / path
|
||||
if not self.repo_tree.exists(path):
|
||||
raise PathMissingError(path, self.url)
|
||||
self.repo_tree.copytree(path, dest)
|
||||
# git-only folder, just copy files directly to dest
|
||||
tree = self._get_tree_for(metadata.repo) # ignore subrepos
|
||||
tree.copytree(path_info, dest)
|
||||
|
||||
def _get_tree_for(self, repo, **kwargs):
|
||||
"""
|
||||
Provides a combined tree of a single repo with dvc + git/local tree.
|
||||
"""
|
||||
kw = {**self.tree_confs, **kwargs}
|
||||
if "fetch" not in kw:
|
||||
kw["fetch"] = True
|
||||
return RepoTree(repo, **kw)
|
||||
|
||||
class ExternalRepo(Repo, BaseExternalRepo):
|
||||
def __init__(self, root_dir, url, rev, for_write=False):
|
||||
if for_write:
|
||||
super().__init__(root_dir)
|
||||
else:
|
||||
root_dir = os.path.realpath(root_dir)
|
||||
super().__init__(root_dir, scm=Git(root_dir), rev=rev)
|
||||
self.url = url
|
||||
self._set_cache_dir()
|
||||
self._fix_upstream()
|
||||
def get_checksum(self, path):
|
||||
path_info = PathInfo(self.root_dir) / path
|
||||
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
|
||||
metadata = self.repo_tree.metadata(path_info)
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _set_cache_dir(self):
|
||||
try:
|
||||
cache_dir = CACHE_DIRS[self.url]
|
||||
except KeyError:
|
||||
cache_dir = CACHE_DIRS[self.url] = tempfile.mkdtemp("dvc-cache")
|
||||
# skip subrepos to check for
|
||||
tree = self._get_tree_for(metadata.repo)
|
||||
return tree.get_hash(path_info)
|
||||
|
||||
self.cache.local.cache_dir = cache_dir
|
||||
self._local_cache = self.cache.local
|
||||
|
||||
def _fix_upstream(self):
|
||||
if not os.path.isdir(self.url):
|
||||
return
|
||||
|
||||
try:
|
||||
src_repo = Repo(self.url)
|
||||
except NotDvcRepoError:
|
||||
# If ExternalRepo does not throw NotDvcRepoError and Repo does,
|
||||
# the self.url might be a bare git repo.
|
||||
# NOTE: This will fail to resolve remote with relative path,
|
||||
# same as if it was a remote DVC repo.
|
||||
return
|
||||
|
||||
try:
|
||||
remote_name = self.config["core"].get("remote")
|
||||
if remote_name:
|
||||
self._fix_local_remote(src_repo, remote_name)
|
||||
else:
|
||||
self._add_upstream(src_repo)
|
||||
finally:
|
||||
src_repo.close()
|
||||
|
||||
def _fix_local_remote(self, src_repo, remote_name):
|
||||
@staticmethod
|
||||
def _fix_local_remote(orig_repo, src_repo, remote_name):
|
||||
# If a remote URL is relative to the source repo,
|
||||
# it will have changed upon config load and made
|
||||
# relative to this new repo. Restore the old one here.
|
||||
new_remote = self.config["remote"][remote_name]
|
||||
new_remote = orig_repo.config["remote"][remote_name]
|
||||
old_remote = src_repo.config["remote"][remote_name]
|
||||
if new_remote["url"] != old_remote["url"]:
|
||||
new_remote["url"] = old_remote["url"]
|
||||
|
||||
def _add_upstream(self, src_repo):
|
||||
@staticmethod
|
||||
def _add_upstream(orig_repo, src_repo):
|
||||
# Fill the empty upstream entry with a new remote pointing to the
|
||||
# original repo's cache location.
|
||||
cache_dir = src_repo.cache.local.cache_dir
|
||||
self.config["remote"]["auto-generated-upstream"] = {"url": cache_dir}
|
||||
self.config["core"]["remote"] = "auto-generated-upstream"
|
||||
orig_repo.config["remote"]["auto-generated-upstream"] = {
|
||||
"url": cache_dir
|
||||
}
|
||||
orig_repo.config["core"]["remote"] = "auto-generated-upstream"
|
||||
|
||||
def make_repo(self, path):
|
||||
repo = Repo(path, scm=self.scm, rev=self.get_rev())
|
||||
|
||||
class ExternalGitRepo(BaseExternalRepo):
|
||||
def __init__(self, root_dir, url, rev):
|
||||
self.root_dir = os.path.realpath(root_dir)
|
||||
self.url = url
|
||||
self.tree = self.scm.get_tree(rev)
|
||||
self._setup_cache(repo)
|
||||
self._fix_upstream(repo)
|
||||
|
||||
@cached_property
|
||||
def scm(self):
|
||||
return Git(self.root_dir)
|
||||
return repo
|
||||
|
||||
def close(self):
|
||||
if "scm" in self.__dict__:
|
||||
self.scm.close()
|
||||
def _setup_cache(self, repo):
|
||||
repo.config["cache"]["dir"] = self.cache_dir
|
||||
repo.cache = Cache(repo)
|
||||
if self.cache_types:
|
||||
repo.cache.local.cache_types = self.cache_types
|
||||
|
||||
def find_out_by_relpath(self, path):
|
||||
raise OutputNotFoundError(path, self)
|
||||
def _fix_upstream(self, repo):
|
||||
if not os.path.isdir(self.url):
|
||||
return
|
||||
|
||||
@contextmanager
|
||||
def open_by_relpath(self, path, mode="r", encoding=None, **kwargs):
|
||||
"""Opens a specified resource as a file object."""
|
||||
tree = RepoTree(self)
|
||||
try:
|
||||
with tree.open(
|
||||
path, mode=mode, encoding=encoding, **kwargs
|
||||
) as fobj:
|
||||
yield fobj
|
||||
except FileNotFoundError:
|
||||
raise PathMissingError(path, self.url)
|
||||
rel_path = os.path.relpath(repo.root_dir, self.root_dir)
|
||||
src_repo = Repo(PathInfo(self.url) / rel_path)
|
||||
except NotDvcRepoError:
|
||||
return
|
||||
|
||||
try:
|
||||
remote_name = repo.config["core"].get("remote")
|
||||
if remote_name:
|
||||
self._fix_local_remote(repo, src_repo, remote_name)
|
||||
else:
|
||||
self._add_upstream(repo, src_repo)
|
||||
finally:
|
||||
src_repo.close()
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _get_cache_dir(self):
|
||||
try:
|
||||
cache_dir = CACHE_DIRS[self.url]
|
||||
except KeyError:
|
||||
cache_dir = CACHE_DIRS[self.url] = tempfile.mkdtemp("dvc-cache")
|
||||
return cache_dir
|
||||
|
||||
|
||||
def _cached_clone(url, rev, for_write=False):
|
||||
@@ -351,6 +394,11 @@ def _remove(path):
|
||||
if os.name == "nt":
|
||||
# git.exe may hang for a while not permitting to remove temp dir
|
||||
os_retry = retry(5, errors=OSError, timeout=0.1)
|
||||
os_retry(remove)(path)
|
||||
try:
|
||||
os_retry(remove)(path)
|
||||
except PermissionError:
|
||||
logger.warning(
|
||||
"Failed to remove '%s'", relpath(path), exc_info=True
|
||||
)
|
||||
else:
|
||||
remove(path)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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:
|
||||
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):
|
||||
_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, size=size, nfiles=nfiles)
|
||||
|
||||
def to_dict(self):
|
||||
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):
|
||||
if not self:
|
||||
return False
|
||||
return self.value.endswith(HASH_DIR_SUFFIX)
|
||||
+134
-23
@@ -1,16 +1,17 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from itertools import groupby, takewhile
|
||||
|
||||
from pathspec.patterns import GitWildMatchPattern
|
||||
from pathspec.util import normalize_file
|
||||
from pygtrie import StringTrie
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.pathspec_math import merge_patterns
|
||||
from dvc.pathspec_math import PatternInfo, merge_patterns
|
||||
from dvc.system import System
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.collections import PathStringTrie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,18 +25,26 @@ class DvcIgnore:
|
||||
|
||||
class DvcIgnorePatterns(DvcIgnore):
|
||||
def __init__(self, pattern_list, dirname):
|
||||
if pattern_list:
|
||||
if isinstance(pattern_list[0], str):
|
||||
pattern_list = [
|
||||
PatternInfo(pattern, "") for pattern in pattern_list
|
||||
]
|
||||
|
||||
self.pattern_list = pattern_list
|
||||
self.dirname = dirname
|
||||
self.prefix = self.dirname + os.sep
|
||||
|
||||
regex_pattern_list = map(
|
||||
GitWildMatchPattern.pattern_to_regex, pattern_list
|
||||
)
|
||||
self.regex_pattern_list = [
|
||||
GitWildMatchPattern.pattern_to_regex(pattern_info.patterns)
|
||||
for pattern_info in pattern_list
|
||||
]
|
||||
|
||||
self.ignore_spec = [
|
||||
(ignore, re.compile("|".join(item[0] for item in group)))
|
||||
for ignore, group in groupby(regex_pattern_list, lambda x: x[1])
|
||||
for ignore, group in groupby(
|
||||
self.regex_pattern_list, lambda x: x[1]
|
||||
)
|
||||
if ignore is not None
|
||||
]
|
||||
|
||||
@@ -43,9 +52,19 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
def from_files(cls, ignore_file_path, tree):
|
||||
assert os.path.isabs(ignore_file_path)
|
||||
dirname = os.path.normpath(os.path.dirname(ignore_file_path))
|
||||
ignore_file_rel_path = os.path.relpath(
|
||||
ignore_file_path, tree.tree_root
|
||||
)
|
||||
with tree.open(ignore_file_path, encoding="utf-8") as fobj:
|
||||
path_spec_lines = [
|
||||
line for line in map(str.strip, fobj.readlines()) if line
|
||||
PatternInfo(
|
||||
line,
|
||||
"{}:{}:{}".format(ignore_file_rel_path, line_no + 1, line),
|
||||
)
|
||||
for line_no, line in enumerate(
|
||||
map(str.strip, fobj.readlines())
|
||||
)
|
||||
if line and not (line.strip().startswith("#"))
|
||||
]
|
||||
|
||||
return cls(path_spec_lines, dirname)
|
||||
@@ -56,7 +75,7 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
|
||||
return dirs, files
|
||||
|
||||
def matches(self, dirname, basename, is_dir=False):
|
||||
def _get_normalize_path(self, dirname, basename):
|
||||
# NOTE: `relpath` is too slow, so we have to assume that both
|
||||
# `dirname` and `self.dirname` are relative or absolute together.
|
||||
if dirname == self.dirname:
|
||||
@@ -70,6 +89,12 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
|
||||
if not System.is_unix():
|
||||
path = normalize_file(path)
|
||||
return path
|
||||
|
||||
def matches(self, dirname, basename, is_dir=False):
|
||||
path = self._get_normalize_path(dirname, basename)
|
||||
if not path:
|
||||
return False
|
||||
return self.ignore(path, is_dir)
|
||||
|
||||
def ignore(self, path, is_dir):
|
||||
@@ -85,25 +110,57 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
result = ignore
|
||||
return result
|
||||
|
||||
def match_details(self, dirname, basename, is_dir=False):
|
||||
path = self._get_normalize_path(dirname, basename)
|
||||
if not path:
|
||||
return False
|
||||
return self._ignore_details(path, is_dir)
|
||||
|
||||
def _ignore_details(self, path, is_dir):
|
||||
result = []
|
||||
for ignore, pattern in zip(self.regex_pattern_list, self.pattern_list):
|
||||
regex = re.compile(ignore[0])
|
||||
# skip system pattern
|
||||
if not pattern.file_info:
|
||||
continue
|
||||
if is_dir:
|
||||
path_dir = f"{path}/"
|
||||
if regex.match(path) or regex.match(path_dir):
|
||||
result.append(pattern.file_info)
|
||||
else:
|
||||
if regex.match(path):
|
||||
result.append(pattern.file_info)
|
||||
return result
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.dirname + ":" + "\n".join(self.pattern_list))
|
||||
return hash(self.dirname + ":" + str(self.pattern_list))
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, DvcIgnorePatterns):
|
||||
return NotImplemented
|
||||
return (self.dirname == other.dirname) & (
|
||||
self.pattern_list == other.pattern_list
|
||||
[pattern.patterns for pattern in self.pattern_list]
|
||||
== [pattern.patterns for pattern in other.pattern_list]
|
||||
)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.pattern_list)
|
||||
|
||||
|
||||
CheckIgnoreResult = namedtuple(
|
||||
"CheckIgnoreResult", ["file", "match", "patterns"]
|
||||
)
|
||||
|
||||
|
||||
def _no_match(path):
|
||||
return CheckIgnoreResult(path, False, ["::"])
|
||||
|
||||
|
||||
class DvcIgnoreFilterNoop:
|
||||
def __init__(self, tree, root_dir):
|
||||
pass
|
||||
|
||||
def __call__(self, root, dirs, files):
|
||||
def __call__(self, root, dirs, files, **kwargs):
|
||||
return dirs, files
|
||||
|
||||
def is_ignored_dir(self, _):
|
||||
@@ -112,6 +169,12 @@ class DvcIgnoreFilterNoop:
|
||||
def is_ignored_file(self, _):
|
||||
return False
|
||||
|
||||
def check_ignore(self, path):
|
||||
return _no_match(path)
|
||||
|
||||
def is_ignored(self, _):
|
||||
return False
|
||||
|
||||
|
||||
class DvcIgnoreFilter:
|
||||
@staticmethod
|
||||
@@ -127,10 +190,11 @@ class DvcIgnoreFilter:
|
||||
|
||||
self.tree = tree
|
||||
self.root_dir = root_dir
|
||||
self.ignores_trie_tree = StringTrie(separator=os.sep)
|
||||
self.ignores_trie_tree = PathStringTrie()
|
||||
self.ignores_trie_tree[root_dir] = DvcIgnorePatterns(
|
||||
default_ignore_patterns, root_dir
|
||||
)
|
||||
self._ignored_subrepos = PathStringTrie()
|
||||
self._update(self.root_dir)
|
||||
|
||||
def _update(self, dirname):
|
||||
@@ -166,27 +230,33 @@ class DvcIgnoreFilter:
|
||||
def _update_sub_repo(self, root, dirs):
|
||||
for d in dirs:
|
||||
if self._is_dvc_repo(root, d):
|
||||
self._ignored_subrepos[root] = self._ignored_subrepos.get(
|
||||
root, set()
|
||||
) | {d}
|
||||
pattern_info = PatternInfo(
|
||||
f"/{d}/", "in sub_repo:{}".format(d)
|
||||
)
|
||||
new_pattern = DvcIgnorePatterns([pattern_info], root)
|
||||
old_pattern = self.ignores_trie_tree.longest_prefix(root).value
|
||||
if old_pattern:
|
||||
self.ignores_trie_tree[root] = DvcIgnorePatterns(
|
||||
*merge_patterns(
|
||||
old_pattern.pattern_list,
|
||||
old_pattern.dirname,
|
||||
["/{}/".format(d)],
|
||||
root,
|
||||
new_pattern.pattern_list,
|
||||
new_pattern.dirname,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.ignores_trie_tree[root] = DvcIgnorePatterns(
|
||||
["/{}/".format(d)], root
|
||||
)
|
||||
self.ignores_trie_tree[root] = new_pattern
|
||||
|
||||
def __call__(self, root, dirs, files):
|
||||
def __call__(self, root, dirs, files, ignore_subrepos=True):
|
||||
ignore_pattern = self._get_trie_pattern(root)
|
||||
if ignore_pattern:
|
||||
return ignore_pattern(root, dirs, files)
|
||||
else:
|
||||
return dirs, files
|
||||
dirs, files = ignore_pattern(root, dirs, files)
|
||||
if not ignore_subrepos:
|
||||
dirs.extend(self._ignored_subrepos.get(root, []))
|
||||
return dirs, files
|
||||
|
||||
def _get_trie_pattern(self, dirname):
|
||||
ignore_pattern = self.ignores_trie_tree.get(dirname)
|
||||
@@ -214,7 +284,7 @@ class DvcIgnoreFilter:
|
||||
|
||||
def _is_ignored(self, path, is_dir=False):
|
||||
if self._outside_repo(path):
|
||||
return True
|
||||
return False
|
||||
dirname, basename = os.path.split(os.path.normpath(path))
|
||||
ignore_pattern = self._get_trie_pattern(dirname)
|
||||
if ignore_pattern:
|
||||
@@ -222,14 +292,21 @@ class DvcIgnoreFilter:
|
||||
else:
|
||||
return False
|
||||
|
||||
def is_ignored_dir(self, path):
|
||||
def _is_subrepo(self, path):
|
||||
dirname, basename = os.path.split(os.path.normpath(path))
|
||||
return basename in self._ignored_subrepos.get(dirname, set())
|
||||
|
||||
def is_ignored_dir(self, path, ignore_subrepos=True):
|
||||
path = os.path.abspath(path)
|
||||
if not ignore_subrepos:
|
||||
return not self._is_subrepo(path)
|
||||
if path == self.root_dir:
|
||||
return False
|
||||
|
||||
return self._is_ignored(path, True)
|
||||
|
||||
def is_ignored_file(self, path):
|
||||
path = os.path.abspath(path)
|
||||
return self._is_ignored(path, False)
|
||||
|
||||
def _outside_repo(self, path):
|
||||
@@ -245,3 +322,37 @@ class DvcIgnoreFilter:
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_ignore(self, target):
|
||||
full_target = os.path.abspath(target)
|
||||
if not self._outside_repo(full_target):
|
||||
dirname, basename = os.path.split(os.path.normpath(full_target))
|
||||
pattern = self._get_trie_pattern(dirname)
|
||||
if pattern:
|
||||
matches = pattern.match_details(
|
||||
dirname, basename, os.path.isdir(full_target)
|
||||
)
|
||||
|
||||
if matches:
|
||||
return CheckIgnoreResult(target, True, matches)
|
||||
return _no_match(target)
|
||||
|
||||
def is_ignored(self, path):
|
||||
# NOTE: can't use self.check_ignore(path).match for now, see
|
||||
# https://github.com/iterative/dvc/issues/4555
|
||||
return self.is_ignored_dir(path) or self.is_ignored_file(path)
|
||||
|
||||
|
||||
def init(path):
|
||||
dvcignore = os.path.join(path, DvcIgnore.DVCIGNORE_FILE)
|
||||
if os.path.exists(dvcignore):
|
||||
return dvcignore
|
||||
|
||||
with open(dvcignore, "w") as fobj:
|
||||
fobj.write(
|
||||
"# Add patterns of files dvc should ignore, which could improve\n"
|
||||
"# the performance. Learn more at\n"
|
||||
"# https://dvc.org/doc/user-guide/dvcignore\n"
|
||||
)
|
||||
|
||||
return dvcignore
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import itertools
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import uuid
|
||||
|
||||
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, get_tree_cls, get_tree_config
|
||||
from dvc.utils import error_link
|
||||
from dvc.utils.pkg import PKG
|
||||
from dvc.version import __version__
|
||||
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
psutil = None
|
||||
|
||||
if PKG is None:
|
||||
package = ""
|
||||
else:
|
||||
package = f"({PKG})"
|
||||
|
||||
|
||||
def get_dvc_info():
|
||||
info = [
|
||||
f"DVC version: {__version__} {package}",
|
||||
"---------------------------------",
|
||||
f"Platform: Python {platform.python_version()} on "
|
||||
f"{platform.platform()}",
|
||||
f"Supports: {_get_supported_remotes()}",
|
||||
]
|
||||
|
||||
try:
|
||||
repo = Repo()
|
||||
|
||||
# cache_dir might not exist yet (e.g. after `dvc init`), and we
|
||||
# can't auto-create it, as it might cause issues if the user
|
||||
# later decides to enable shared cache mode with
|
||||
# `dvc config cache.shared group`.
|
||||
if os.path.exists(repo.cache.local.cache_dir):
|
||||
info.append(
|
||||
"Cache types: {}".format(_get_linktype_support_info(repo))
|
||||
)
|
||||
if psutil:
|
||||
fs_type = get_fs_type(repo.cache.local.cache_dir)
|
||||
info.append(f"Cache directory: {fs_type}")
|
||||
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:
|
||||
info.append("Repo: dvc, git (broken)")
|
||||
else:
|
||||
root_directory = repo.root_dir
|
||||
if psutil:
|
||||
fs_root = get_fs_type(os.path.abspath(root_directory))
|
||||
info.append(f"Workspace directory: {fs_root}")
|
||||
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
|
||||
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 = {
|
||||
"reflink": (System.reflink, None),
|
||||
"hardlink": (System.hardlink, System.is_hardlink),
|
||||
"symlink": (System.symlink, System.is_symlink),
|
||||
}
|
||||
|
||||
fname = "." + str(uuid.uuid4())
|
||||
src = os.path.join(repo.cache.local.cache_dir, fname)
|
||||
open(src, "w").close()
|
||||
dst = os.path.join(repo.root_dir, fname)
|
||||
|
||||
cache = []
|
||||
|
||||
for name, (link, is_link) in links.items():
|
||||
try:
|
||||
link(src, dst)
|
||||
status = "supported"
|
||||
if is_link and not is_link(dst):
|
||||
status = "broken"
|
||||
os.unlink(dst)
|
||||
except DvcException:
|
||||
status = "not supported"
|
||||
|
||||
if status == "supported":
|
||||
cache.append(name)
|
||||
os.remove(src)
|
||||
|
||||
return ", ".join(cache)
|
||||
|
||||
|
||||
def _get_supported_remotes():
|
||||
|
||||
supported_remotes = []
|
||||
for tree_cls in TREES:
|
||||
if not tree_cls.get_missing_deps():
|
||||
supported_remotes.append(tree_cls.scheme)
|
||||
|
||||
if len(supported_remotes) == len(TREES):
|
||||
return "All remotes"
|
||||
|
||||
if len(supported_remotes) == 1:
|
||||
return supported_remotes
|
||||
|
||||
return ", ".join(supported_remotes)
|
||||
|
||||
|
||||
def get_fs_type(path):
|
||||
|
||||
partition = {
|
||||
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
|
||||
for part in psutil.disk_partitions(all=True)
|
||||
}
|
||||
|
||||
path = pathlib.Path(path)
|
||||
|
||||
for parent in itertools.chain([path], path.parents):
|
||||
if parent in partition:
|
||||
return partition[parent]
|
||||
return ("unknown", "none")
|
||||
|
||||
|
||||
def _get_dvc_repo_info(self):
|
||||
if self.config.get("core", {}).get("no_scm", False):
|
||||
return "dvc (no_scm)"
|
||||
|
||||
if self.root_dir != self.scm.root_dir:
|
||||
return "dvc (subdir), git"
|
||||
|
||||
return "dvc, git"
|
||||
+74
-22
@@ -2,22 +2,26 @@
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
|
||||
import flufl.lock
|
||||
import zc.lockfile
|
||||
from funcy import retry
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import format_link
|
||||
|
||||
DEFAULT_TIMEOUT = 5
|
||||
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")
|
||||
)
|
||||
|
||||
|
||||
@@ -25,20 +29,74 @@ class LockError(DvcException):
|
||||
"""Thrown when unable to acquire the lock for DVC repo."""
|
||||
|
||||
|
||||
class Lock:
|
||||
class LockBase(ABC):
|
||||
@abstractmethod
|
||||
def __init__(self, lockfile):
|
||||
self._lockfile = lockfile
|
||||
|
||||
@property
|
||||
def lockfile(self):
|
||||
return self._lockfile
|
||||
|
||||
@abstractmethod
|
||||
def lock(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def unlock(self):
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_locked(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __exit__(self, typ, value, tbck):
|
||||
pass
|
||||
|
||||
|
||||
class LockNoop(LockBase):
|
||||
def __init__(
|
||||
self, *args, **kwargs
|
||||
): # pylint: disable=super-init-not-called
|
||||
self._lock = False
|
||||
|
||||
def lock(self):
|
||||
self._lock = True
|
||||
|
||||
def unlock(self):
|
||||
self._lock = False
|
||||
|
||||
@property
|
||||
def is_locked(self):
|
||||
return self._lock
|
||||
|
||||
def __enter__(self):
|
||||
self.lock()
|
||||
|
||||
def __exit__(self, typ, value, tbck):
|
||||
self.unlock()
|
||||
|
||||
|
||||
class Lock(LockBase):
|
||||
"""Class for DVC repo lock.
|
||||
|
||||
Uses zc.lockfile as backend.
|
||||
"""
|
||||
|
||||
def __init__(self, lockfile, friendly=False, **kwargs):
|
||||
super().__init__(lockfile)
|
||||
self._friendly = friendly
|
||||
self.lockfile = lockfile
|
||||
self._lock = None
|
||||
|
||||
@property
|
||||
def files(self):
|
||||
return [self.lockfile]
|
||||
return [self._lockfile]
|
||||
|
||||
def _do_lock(self):
|
||||
try:
|
||||
@@ -51,18 +109,15 @@ class Lock:
|
||||
)
|
||||
),
|
||||
):
|
||||
self._lock = zc.lockfile.LockFile(self.lockfile)
|
||||
self._lock = zc.lockfile.LockFile(self._lockfile)
|
||||
except zc.lockfile.LockError:
|
||||
raise LockError(FAILED_TO_LOCK_MESSAGE)
|
||||
|
||||
def lock(self):
|
||||
try:
|
||||
self._do_lock()
|
||||
return
|
||||
except LockError:
|
||||
time.sleep(DEFAULT_TIMEOUT)
|
||||
|
||||
self._do_lock()
|
||||
retries = 6
|
||||
delay = DEFAULT_TIMEOUT / retries
|
||||
lock_retry = retry(retries, LockError, timeout=delay)(self._do_lock)
|
||||
lock_retry()
|
||||
|
||||
def unlock(self):
|
||||
self._lock.close()
|
||||
@@ -79,7 +134,7 @@ class Lock:
|
||||
self.unlock()
|
||||
|
||||
|
||||
class HardlinkLock(flufl.lock.Lock):
|
||||
class HardlinkLock(flufl.lock.Lock, LockBase):
|
||||
"""Class for DVC repo lock.
|
||||
|
||||
Args:
|
||||
@@ -93,6 +148,8 @@ class HardlinkLock(flufl.lock.Lock):
|
||||
): # pylint: disable=super-init-not-called
|
||||
import socket
|
||||
|
||||
super().__init__(lockfile)
|
||||
|
||||
self._tmp_dir = tmp_dir
|
||||
|
||||
# NOTE: this is basically Lock.__init__ copy-paste, except that
|
||||
@@ -108,17 +165,12 @@ class HardlinkLock(flufl.lock.Lock):
|
||||
# [1] https://github.com/iterative/dvc/issues/2582
|
||||
self._hostname = socket.gethostname()
|
||||
|
||||
self._lockfile = lockfile
|
||||
self._lifetime = timedelta(days=365) # Lock for good by default
|
||||
self._separator = flufl.lock.SEP
|
||||
self._set_claimfile()
|
||||
self._owned = True
|
||||
self._retry_errnos = []
|
||||
|
||||
@property
|
||||
def lockfile(self):
|
||||
return self._lockfile
|
||||
|
||||
def lock(self): # pylint: disable=arguments-differ
|
||||
try:
|
||||
super().lock(timedelta(seconds=DEFAULT_TIMEOUT))
|
||||
|
||||
+68
-30
@@ -2,6 +2,7 @@
|
||||
|
||||
import errno
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
|
||||
from dvc import analytics
|
||||
from dvc.cli import parse_args
|
||||
@@ -10,7 +11,7 @@ from dvc.exceptions import DvcException, DvcParserError, NotDvcRepoError
|
||||
from dvc.external_repo import clean_repos
|
||||
from dvc.logger import FOOTER, disable_other_loggers
|
||||
from dvc.tree.pool import close_pools
|
||||
from dvc.utils import format_link
|
||||
from dvc.utils import error_link
|
||||
|
||||
# Workaround for CPython bug. See [1] and [2] for more info.
|
||||
# [1] https://github.com/aws/aws-cli/blob/1.16.277/awscli/clidriver.py#L55
|
||||
@@ -21,7 +22,40 @@ from dvc.utils import format_link
|
||||
logger = logging.getLogger("dvc")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@contextmanager
|
||||
def profile(enable, dump):
|
||||
if not enable:
|
||||
yield
|
||||
return
|
||||
|
||||
import cProfile
|
||||
|
||||
prof = cProfile.Profile()
|
||||
prof.enable()
|
||||
|
||||
yield
|
||||
|
||||
prof.disable()
|
||||
if not dump:
|
||||
prof.print_stats(sort="cumtime")
|
||||
return
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -37,20 +71,23 @@ def main(argv=None):
|
||||
try:
|
||||
args = parse_args(argv)
|
||||
|
||||
verbosity = args.verbose - args.quiet
|
||||
if verbosity:
|
||||
logger.setLevel(
|
||||
{
|
||||
-2: logging.CRITICAL,
|
||||
-1: logging.ERROR,
|
||||
1: logging.DEBUG,
|
||||
2: logging.TRACE,
|
||||
}[max(-2, min(verbosity, 2))]
|
||||
)
|
||||
level = None
|
||||
if args.quiet:
|
||||
level = logging.CRITICAL
|
||||
elif args.verbose == 1:
|
||||
level = logging.DEBUG
|
||||
elif args.verbose > 1:
|
||||
level = logging.TRACE
|
||||
|
||||
if level is not None:
|
||||
logger.setLevel(level)
|
||||
|
||||
logger.trace(args)
|
||||
|
||||
cmd = args.func(args)
|
||||
ret = cmd.run()
|
||||
with profile(enable=args.cprofile, dump=args.cprofile_dump):
|
||||
with debug(args.pdb):
|
||||
cmd = args.func(args)
|
||||
ret = cmd.run()
|
||||
except ConfigError:
|
||||
logger.exception("configuration error")
|
||||
ret = 251
|
||||
@@ -65,22 +102,31 @@ def main(argv=None):
|
||||
except DvcException:
|
||||
ret = 255
|
||||
logger.exception("")
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EMFILE:
|
||||
except Exception as exc: # noqa, pylint: disable=broad-except
|
||||
# pylint: disable=no-member
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
logger.exception(
|
||||
"too many open files, please visit "
|
||||
"{} to see how to handle this "
|
||||
"problem".format(
|
||||
format_link("https://error.dvc.org/many-files")
|
||||
),
|
||||
"problem".format(error_link("many-files")),
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
else:
|
||||
from dvc.info import get_dvc_info
|
||||
|
||||
logger.exception("unexpected error")
|
||||
|
||||
dvc_info = get_dvc_info()
|
||||
logger.debug("Version info for developers:\n%s", dvc_info)
|
||||
|
||||
logger.info(FOOTER)
|
||||
ret = 255
|
||||
except Exception: # noqa, pylint: disable=broad-except
|
||||
logger.exception("unexpected error")
|
||||
ret = 255
|
||||
|
||||
try:
|
||||
if analytics.is_enabled():
|
||||
analytics.collect_and_send_report(args, ret)
|
||||
|
||||
return ret
|
||||
finally:
|
||||
logger.setLevel(outerLogLevel)
|
||||
|
||||
@@ -92,11 +138,3 @@ def main(argv=None):
|
||||
# Remove cached repos in the end of the call, these are anonymous
|
||||
# so won't be reused by any other subsequent run anyway.
|
||||
clean_repos()
|
||||
|
||||
if ret != 0:
|
||||
logger.info(FOOTER)
|
||||
|
||||
if analytics.is_enabled():
|
||||
analytics.collect_and_send_report(args, ret)
|
||||
|
||||
return ret
|
||||
|
||||
+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)
|
||||
|
||||
+133
-57
@@ -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
|
||||
@@ -8,10 +9,13 @@ from voluptuous import Any
|
||||
import dvc.prompt as prompt
|
||||
from dvc.cache import NamedCache
|
||||
from dvc.exceptions import (
|
||||
CheckoutError,
|
||||
CollectCacheError,
|
||||
DvcException,
|
||||
MergeError,
|
||||
RemoteCacheRequiredError,
|
||||
)
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
from ..tree.base import BaseTree
|
||||
|
||||
@@ -45,6 +49,12 @@ class OutputIsStageFileError(DvcException):
|
||||
super().__init__(f"Stage file '{path}' cannot be an output.")
|
||||
|
||||
|
||||
class OutputIsIgnoredError(DvcException):
|
||||
def __init__(self, match):
|
||||
lines = "\n".join(match.patterns)
|
||||
super().__init__(f"Path '{match.file}' is ignored by\n{lines}")
|
||||
|
||||
|
||||
class BaseOutput:
|
||||
IS_DEPENDENCY = False
|
||||
|
||||
@@ -52,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"
|
||||
@@ -64,6 +75,7 @@ class BaseOutput:
|
||||
PARAM_PLOT_TITLE = "title"
|
||||
PARAM_PLOT_HEADER = "header"
|
||||
PARAM_PERSIST = "persist"
|
||||
PARAM_DESC = "desc"
|
||||
|
||||
METRIC_SCHEMA = Any(
|
||||
None,
|
||||
@@ -74,9 +86,10 @@ class BaseOutput:
|
||||
},
|
||||
)
|
||||
|
||||
DoesNotExistError = OutputDoesNotExistError
|
||||
IsNotFileOrDirError = OutputIsNotFileOrDirError
|
||||
IsStageFileError = OutputIsStageFileError
|
||||
DoesNotExistError = OutputDoesNotExistError # type: Type[DvcException]
|
||||
IsNotFileOrDirError = OutputIsNotFileOrDirError # type: Type[DvcException]
|
||||
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
|
||||
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
|
||||
|
||||
sep = "/"
|
||||
|
||||
@@ -90,8 +103,10 @@ class BaseOutput:
|
||||
metric=False,
|
||||
plot=False,
|
||||
persist=False,
|
||||
checkpoint=False,
|
||||
desc=None,
|
||||
):
|
||||
self._validate_output_path(path)
|
||||
self._validate_output_path(path, stage)
|
||||
# This output (and dependency) objects have too many paths/urls
|
||||
# here is a list and comments:
|
||||
#
|
||||
@@ -105,7 +120,7 @@ class BaseOutput:
|
||||
self.stage = stage
|
||||
self.repo = stage.repo if stage else None
|
||||
self.def_path = path
|
||||
self.info = info
|
||||
self.hash_info = HashInfo.from_dict(info)
|
||||
if tree:
|
||||
self.tree = tree
|
||||
else:
|
||||
@@ -114,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:
|
||||
@@ -154,7 +171,7 @@ class BaseOutput:
|
||||
|
||||
@property
|
||||
def dir_cache(self):
|
||||
return self.cache.get_dir_cache(self.checksum)
|
||||
return self.cache.get_dir_cache(self.hash_info)
|
||||
|
||||
@classmethod
|
||||
def supported(cls, url):
|
||||
@@ -162,58 +179,50 @@ class BaseOutput:
|
||||
|
||||
@property
|
||||
def cache_path(self):
|
||||
return self.cache.tree.hash_to_path_info(self.checksum).url
|
||||
return self.cache.tree.hash_to_path_info(self.hash_info.value).url
|
||||
|
||||
@property
|
||||
def checksum_type(self):
|
||||
return self.tree.PARAM_CHECKSUM
|
||||
|
||||
@property
|
||||
def checksum(self):
|
||||
return self.info.get(self.tree.PARAM_CHECKSUM)
|
||||
|
||||
@checksum.setter
|
||||
def checksum(self, checksum):
|
||||
self.info[self.tree.PARAM_CHECKSUM] = checksum
|
||||
|
||||
def get_checksum(self):
|
||||
return self.tree.get_hash(self.path_info)
|
||||
def get_hash(self):
|
||||
if not self.use_cache:
|
||||
return self.tree.get_hash(self.path_info)
|
||||
return self.cache.get_hash(self.tree, self.path_info)
|
||||
|
||||
@property
|
||||
def is_dir_checksum(self):
|
||||
return self.tree.is_dir_hash(self.checksum)
|
||||
return self.hash_info.isdir
|
||||
|
||||
@property
|
||||
def exists(self):
|
||||
return self.tree.exists(self.path_info)
|
||||
|
||||
def save_info(self):
|
||||
return self.tree.save_info(self.path_info)
|
||||
|
||||
def changed_checksum(self):
|
||||
return self.checksum != self.get_checksum()
|
||||
return self.hash_info != self.get_hash()
|
||||
|
||||
def changed_cache(self, filter_info=None):
|
||||
if not self.use_cache or not self.checksum:
|
||||
if not self.use_cache or not self.hash_info:
|
||||
return True
|
||||
|
||||
return self.cache.changed_cache(self.checksum, filter_info=filter_info)
|
||||
|
||||
def status(self):
|
||||
if self.checksum and self.use_cache and self.changed_cache():
|
||||
return {str(self): "not in cache"}
|
||||
return self.cache.changed_cache(
|
||||
self.hash_info, filter_info=filter_info
|
||||
)
|
||||
|
||||
def workspace_status(self):
|
||||
if not self.exists:
|
||||
return {str(self): "deleted"}
|
||||
|
||||
if self.changed_checksum():
|
||||
return {str(self): "modified"}
|
||||
|
||||
if not self.checksum:
|
||||
if not self.hash_info:
|
||||
return {str(self): "new"}
|
||||
|
||||
return {}
|
||||
|
||||
def status(self):
|
||||
if self.hash_info and self.use_cache and self.changed_cache():
|
||||
return {str(self): "not in cache"}
|
||||
|
||||
return self.workspace_status()
|
||||
|
||||
def changed(self):
|
||||
status = self.status()
|
||||
logger.debug(str(status))
|
||||
@@ -264,7 +273,7 @@ class BaseOutput:
|
||||
self.verify_metric()
|
||||
|
||||
if not self.use_cache:
|
||||
self.info = self.save_info()
|
||||
self.hash_info = self.get_hash()
|
||||
if not self.IS_DEPENDENCY:
|
||||
logger.debug(
|
||||
"Output '%s' doesn't use cache. Skipping saving.", self
|
||||
@@ -277,19 +286,26 @@ class BaseOutput:
|
||||
logger.debug("Output '%s' didn't change. Skipping saving.", self)
|
||||
return
|
||||
|
||||
self.info = self.save_info()
|
||||
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.info)
|
||||
self.cache.save(self.path_info, self.cache.tree, self.hash_info)
|
||||
|
||||
def dumpd(self):
|
||||
ret = copy(self.info)
|
||||
ret = copy(self.hash_info.to_dict())
|
||||
ret[self.PARAM_PATH] = self.def_path
|
||||
|
||||
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
|
||||
|
||||
@@ -309,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):
|
||||
@@ -323,6 +342,8 @@ class BaseOutput:
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
filter_info=None,
|
||||
allow_missing=False,
|
||||
**kwargs,
|
||||
):
|
||||
if not self.use_cache:
|
||||
if progress_callback:
|
||||
@@ -331,14 +352,20 @@ class BaseOutput:
|
||||
)
|
||||
return None
|
||||
|
||||
return self.cache.checkout(
|
||||
self.path_info,
|
||||
self.info,
|
||||
force=force,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
)
|
||||
try:
|
||||
return self.cache.checkout(
|
||||
self.path_info,
|
||||
self.hash_info,
|
||||
force=force,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
**kwargs,
|
||||
)
|
||||
except CheckoutError:
|
||||
if allow_missing or self.checkpoint:
|
||||
return None
|
||||
raise
|
||||
|
||||
def remove(self, ignore_remove=False):
|
||||
self.tree.remove(self.path_info)
|
||||
@@ -367,7 +394,7 @@ class BaseOutput:
|
||||
return 0
|
||||
|
||||
return self.cache.get_files_number(
|
||||
self.path_info, self.checksum, filter_info
|
||||
self.path_info, self.hash_info, filter_info
|
||||
)
|
||||
|
||||
def unprotect(self):
|
||||
@@ -377,12 +404,15 @@ class BaseOutput:
|
||||
def get_dir_cache(self, **kwargs):
|
||||
if not self.is_dir_checksum:
|
||||
raise DvcException("cannot get dir cache for file checksum")
|
||||
if self.cache.changed_cache_file(self.checksum):
|
||||
if self.cache.changed_cache_file(self.hash_info):
|
||||
self.repo.cloud.pull(
|
||||
NamedCache.make("local", self.checksum, str(self)),
|
||||
NamedCache.make("local", self.hash_info.value, str(self)),
|
||||
show_checksums=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.hash_info.dir_info = None
|
||||
if not self.hash_info.dir_info:
|
||||
self.cache.set_dir_info(self.hash_info)
|
||||
return self.dir_cache
|
||||
|
||||
def collect_used_dir_cache(
|
||||
@@ -414,7 +444,7 @@ class BaseOutput:
|
||||
except DvcException:
|
||||
logger.debug(f"failed to pull cache for '{self}'")
|
||||
|
||||
if self.cache.changed_cache_file(self.checksum):
|
||||
if self.cache.changed_cache_file(self.hash_info):
|
||||
msg = (
|
||||
"Missing cache for directory '{}'. "
|
||||
"Cache for files inside will be lost. "
|
||||
@@ -431,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)
|
||||
@@ -442,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
|
||||
|
||||
@@ -464,7 +492,7 @@ class BaseOutput:
|
||||
cache.external[dep.repo_pair].add(dep.def_path)
|
||||
return cache
|
||||
|
||||
if not self.checksum:
|
||||
if not self.hash_info:
|
||||
msg = (
|
||||
"Output '{}'({}) is missing version info. "
|
||||
"Cache for it will not be collected. "
|
||||
@@ -483,20 +511,68 @@ class BaseOutput:
|
||||
logger.warning(msg)
|
||||
return NamedCache()
|
||||
|
||||
ret = NamedCache.make(self.scheme, self.checksum, str(self))
|
||||
ret = NamedCache.make(self.scheme, self.hash_info.value, str(self))
|
||||
|
||||
if not self.is_dir_checksum:
|
||||
return ret
|
||||
|
||||
ret.add_child_cache(
|
||||
self.checksum, self.collect_used_dir_cache(**kwargs),
|
||||
self.hash_info.value, self.collect_used_dir_cache(**kwargs),
|
||||
)
|
||||
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def _validate_output_path(cls, path):
|
||||
def _validate_output_path(cls, path, stage=None):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
|
||||
if is_valid_filename(path):
|
||||
raise cls.IsStageFileError(path)
|
||||
|
||||
if stage:
|
||||
check = stage.repo.tree.dvcignore.check_ignore(path)
|
||||
if check.match:
|
||||
raise cls.IsIgnoredError(check)
|
||||
|
||||
def _check_can_merge(self, out):
|
||||
if self.scheme != out.scheme:
|
||||
raise MergeError("unable to auto-merge outputs of different types")
|
||||
|
||||
my = self.dumpd()
|
||||
other = out.dumpd()
|
||||
|
||||
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(
|
||||
"unable to auto-merge outputs with different options"
|
||||
)
|
||||
|
||||
if not out.is_dir_checksum:
|
||||
raise MergeError(
|
||||
"unable to auto-merge outputs that are not directories"
|
||||
)
|
||||
|
||||
def merge(self, ancestor, other):
|
||||
assert other
|
||||
|
||||
if ancestor:
|
||||
self._check_can_merge(ancestor)
|
||||
ancestor_info = ancestor.hash_info
|
||||
else:
|
||||
ancestor_info = None
|
||||
|
||||
self._check_can_merge(self)
|
||||
self._check_can_merge(other)
|
||||
|
||||
self.hash_info = self.cache.merge(
|
||||
ancestor_info, self.hash_info, other.hash_info
|
||||
)
|
||||
|
||||
+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
|
||||
|
||||
+3
-2
@@ -87,8 +87,9 @@ class LocalOutput(BaseOutput):
|
||||
|
||||
name = "metrics" if self.metric else "plot"
|
||||
if os.path.isdir(path):
|
||||
msg = "directory '{}' cannot be used as {}."
|
||||
raise IsADirectoryError(msg.format(self.path_info, name))
|
||||
msg = "directory '%s' cannot be used as %s."
|
||||
logger.debug(msg, str(self.path_info), name)
|
||||
return
|
||||
|
||||
if not istextfile(path):
|
||||
msg = "binary file '{}' cannot be used as {}."
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from dvc.output.base import BaseOutput
|
||||
|
||||
from ..tree.webhdfs import WebHDFSTree
|
||||
|
||||
|
||||
class WebHDFSOutput(BaseOutput):
|
||||
TREE_CLS = WebHDFSTree
|
||||
@@ -0,0 +1,287 @@
|
||||
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, List, Set
|
||||
|
||||
from funcy import join
|
||||
|
||||
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,
|
||||
ParamsFileNotFound,
|
||||
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 ResolveError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
def format_and_raise(exc, msg, path):
|
||||
spacing = "\n" if isinstance(exc, (ParseError, MergeError)) else " "
|
||||
message = f"failed to parse {msg} in '{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: Set[str] = set()
|
||||
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)}
|
||||
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 (ParamsFileNotFound, MergeError) as exc:
|
||||
format_and_raise(exc, "'vars'", self.relpath)
|
||||
|
||||
def load_from_vars(
|
||||
self,
|
||||
context: "Context",
|
||||
vars_: List,
|
||||
wdir: PathInfo,
|
||||
skip_imports: Set[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_info = wdir / item
|
||||
path = os.path.abspath(path_info)
|
||||
if path in skip_imports:
|
||||
continue
|
||||
|
||||
context.merge_from(self.tree, path_info)
|
||||
skip_imports.add(path)
|
||||
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?
|
||||
self.load_from_vars(
|
||||
context,
|
||||
vars_,
|
||||
wdir,
|
||||
skip_imports=deepcopy(self.imported_files),
|
||||
stage_name=name,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# FIXME: Decide if we should track them or not (it does right now)
|
||||
params = resolved.get(PARAMS_KWD, []) + self._resolve_params(
|
||||
context, wdir
|
||||
)
|
||||
if params:
|
||||
resolved[PARAMS_KWD] = params
|
||||
return {name: resolved}
|
||||
|
||||
def _resolve_params(self, context: Context, wdir):
|
||||
tracked = defaultdict(set)
|
||||
for src, keys in context.tracked.items():
|
||||
tracked[str(PathInfo(src).relative_to(wdir))].update(keys)
|
||||
|
||||
return [{file: list(keys)} for file, keys in tracked.items()]
|
||||
|
||||
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,436 @@
|
||||
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) -> "Context":
|
||||
file = relpath(path)
|
||||
if not tree.exists(path):
|
||||
raise ParamsFileNotFound(f"'{file}' does not exist")
|
||||
|
||||
_, ext = os.path.splitext(file)
|
||||
loader = LOADERS[ext]
|
||||
|
||||
meta = Meta(source=file, local=False)
|
||||
return cls(loader(path, tree=tree), meta=meta)
|
||||
|
||||
def merge_from(self, tree, path: PathInfo, overwrite=False):
|
||||
self.merge_update(Context.load_from(tree, path), 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,115 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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] + 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)
|
||||
+30
-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)
|
||||
@@ -253,6 +264,15 @@ class HTTPURLInfo(URLInfo):
|
||||
self.query = p.query
|
||||
self.fragment = p.fragment
|
||||
|
||||
def replace(self, path=None):
|
||||
return self.from_parts(
|
||||
*self._base_parts,
|
||||
params=self.params,
|
||||
query=self.query,
|
||||
fragment=self.fragment,
|
||||
path=path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_parts(
|
||||
cls,
|
||||
@@ -315,3 +335,11 @@ class HTTPURLInfo(URLInfo):
|
||||
and self._path == other._path
|
||||
and self._extra_parts == other._extra_parts
|
||||
)
|
||||
|
||||
|
||||
class WebDAVURLInfo(URLInfo):
|
||||
@cached_property
|
||||
def url(self):
|
||||
return "{}://{}{}".format(
|
||||
self.scheme.replace("webdav", "http"), self.netloc, self._spath
|
||||
)
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
# of two path specification patterns with different base
|
||||
# All the operations follow the documents of `gitignore`
|
||||
import os
|
||||
from collections import namedtuple
|
||||
|
||||
from pathspec.util import normalize_file
|
||||
|
||||
from dvc.utils import relpath
|
||||
|
||||
PatternInfo = namedtuple("PatternInfo", ["patterns", "file_info"])
|
||||
|
||||
|
||||
def _not_ignore(rule):
|
||||
return (True, rule[1:]) if rule.startswith("!") else (False, rule)
|
||||
@@ -59,7 +62,10 @@ def _change_dirname(dirname, pattern_list, new_dirname):
|
||||
if rel.startswith(".."):
|
||||
raise ValueError("change dirname can only change to parent path")
|
||||
|
||||
return [change_rule(rule, rel) for rule in pattern_list]
|
||||
return [
|
||||
PatternInfo(change_rule(rule.patterns, rel), rule.file_info)
|
||||
for rule in pattern_list
|
||||
]
|
||||
|
||||
|
||||
def merge_patterns(pattern_a, prefix_a, pattern_b, prefix_b):
|
||||
|
||||
+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,
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ from getpass import getpass
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ask(prompt, limited_to=None):
|
||||
def ask(prompt, limited_to=None):
|
||||
if not sys.stdout.isatty():
|
||||
return None
|
||||
|
||||
@@ -39,7 +39,7 @@ def confirm(statement):
|
||||
bool: whether or not specified statement was confirmed.
|
||||
"""
|
||||
prompt = f"{statement} [y/n]"
|
||||
answer = _ask(prompt, limited_to=["yes", "no", "y", "n"])
|
||||
answer = ask(prompt, limited_to=["yes", "no", "y", "n"])
|
||||
return answer and answer.startswith("y")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-1
@@ -6,6 +6,7 @@ import threading
|
||||
from funcy import lchunks
|
||||
|
||||
from dvc.state import _connect_sqlite
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,9 +68,12 @@ class RemoteIndex:
|
||||
VERSION = 1
|
||||
INDEX_TABLE = "remote_index"
|
||||
INDEX_TABLE_LAYOUT = "checksum TEXT PRIMARY KEY, dir INTEGER NOT NULL"
|
||||
INDEX_DIR = "index"
|
||||
|
||||
def __init__(self, repo, name, dir_suffix=".dir"):
|
||||
self.path = os.path.join(repo.index_dir, f"{name}{self.INDEX_SUFFIX}")
|
||||
self.path = os.path.join(
|
||||
repo.tmp_dir, self.INDEX_DIR, f"{name}{self.INDEX_SUFFIX}"
|
||||
)
|
||||
|
||||
self.dir_suffix = dir_suffix
|
||||
self.database = None
|
||||
@@ -150,6 +154,7 @@ class RemoteIndex:
|
||||
assert self.cursor is None
|
||||
|
||||
empty = not os.path.isfile(self.path)
|
||||
makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
self.database = _connect_sqlite(self.path, {"nolock": 1})
|
||||
self.cursor = self.database.cursor()
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+159
-204
@@ -2,8 +2,10 @@ 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
|
||||
@@ -15,26 +17,47 @@ from dvc.exceptions import (
|
||||
OutputNotFoundError,
|
||||
)
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.repo.tree import RepoTree
|
||||
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_pipeline, 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: "Repo"):
|
||||
# pylint: disable=protected-access
|
||||
depth = repo._lock_depth
|
||||
repo._lock_depth += 1
|
||||
|
||||
try:
|
||||
if depth > 0:
|
||||
yield
|
||||
else:
|
||||
with repo.lock, repo.state:
|
||||
repo._reset()
|
||||
yield
|
||||
# Graph cache is no longer valid after we release the repo.lock
|
||||
repo._reset()
|
||||
finally:
|
||||
repo._lock_depth = depth
|
||||
|
||||
|
||||
def locked(f):
|
||||
@wraps(f)
|
||||
def wrapper(repo, *args, **kwargs):
|
||||
with repo.lock, repo.state:
|
||||
# pylint: disable=protected-access
|
||||
repo._reset()
|
||||
ret = f(repo, *args, **kwargs)
|
||||
# Our graph cache is no longer valid after we release the repo.lock
|
||||
repo._reset()
|
||||
return ret
|
||||
with lock_repo(repo):
|
||||
return f(repo, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -42,109 +65,149 @@ def locked(f):
|
||||
class Repo:
|
||||
DVC_DIR = ".dvc"
|
||||
|
||||
from dvc.repo.destroy import destroy
|
||||
from dvc.repo.install import install
|
||||
from dvc.repo.add import add
|
||||
from dvc.repo.remove import remove
|
||||
from dvc.repo.ls import ls
|
||||
from dvc.repo.brancher import brancher
|
||||
from dvc.repo.checkout import checkout
|
||||
from dvc.repo.commit import commit
|
||||
from dvc.repo.destroy import destroy
|
||||
from dvc.repo.diff import diff
|
||||
from dvc.repo.fetch import fetch
|
||||
from dvc.repo.freeze import freeze, unfreeze
|
||||
from dvc.repo.move import move
|
||||
from dvc.repo.run import run
|
||||
from dvc.repo.gc import gc
|
||||
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.reproduce import reproduce
|
||||
from dvc.repo.checkout import _checkout
|
||||
from dvc.repo.push import push
|
||||
from dvc.repo.fetch import _fetch
|
||||
from dvc.repo.install import install
|
||||
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
|
||||
from dvc.repo.remove import remove
|
||||
from dvc.repo.reproduce import reproduce
|
||||
from dvc.repo.run import run
|
||||
from dvc.repo.status import status
|
||||
from dvc.repo.gc import gc
|
||||
from dvc.repo.commit import commit
|
||||
from dvc.repo.diff import diff
|
||||
from dvc.repo.brancher import brancher
|
||||
from dvc.repo.get import get
|
||||
from dvc.repo.get_url import get_url
|
||||
from dvc.repo.update import update
|
||||
|
||||
def __init__(self, root_dir=None, scm=None, rev=None):
|
||||
from dvc.state import State, StateNoop
|
||||
from dvc.lock import make_lock
|
||||
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,
|
||||
scm=None,
|
||||
rev=None,
|
||||
subrepos=False,
|
||||
uninitialized=False,
|
||||
):
|
||||
from dvc.cache import Cache
|
||||
from dvc.data_cloud import DataCloud
|
||||
from dvc.lock import LockNoop, make_lock
|
||||
from dvc.repo.experiments import Experiments
|
||||
from dvc.repo.metrics import Metrics
|
||||
from dvc.repo.plots import Plots
|
||||
from dvc.repo.params import Params
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.utils.fs import makedirs
|
||||
from dvc.repo.plots import Plots
|
||||
from dvc.stage.cache import StageCache
|
||||
from dvc.state import State, StateNoop
|
||||
from dvc.tree.local import LocalTree
|
||||
|
||||
if scm:
|
||||
tree = scm.get_tree(rev)
|
||||
self.root_dir = self.find_root(root_dir, tree)
|
||||
self.scm = scm
|
||||
self.tree = scm.get_tree(
|
||||
rev, use_dvcignore=True, dvcignore_root=self.root_dir
|
||||
)
|
||||
self.state = StateNoop()
|
||||
else:
|
||||
root_dir = self.find_root(root_dir)
|
||||
self.root_dir = os.path.abspath(os.path.realpath(root_dir))
|
||||
self.tree = LocalTree(
|
||||
self,
|
||||
{"url": self.root_dir},
|
||||
use_dvcignore=True,
|
||||
dvcignore_root=self.root_dir,
|
||||
)
|
||||
|
||||
self.dvc_dir = os.path.join(self.root_dir, self.DVC_DIR)
|
||||
self.config = Config(self.dvc_dir, tree=self.tree)
|
||||
|
||||
if not scm:
|
||||
no_scm = self.config["core"].get("no_scm", False)
|
||||
self.scm = SCM(self.root_dir, no_scm=no_scm)
|
||||
|
||||
self.tmp_dir = os.path.join(self.dvc_dir, "tmp")
|
||||
self.index_dir = os.path.join(self.tmp_dir, "index")
|
||||
makedirs(self.index_dir, exist_ok=True)
|
||||
|
||||
hardlink_lock = self.config["core"].get("hardlink_lock", False)
|
||||
self.lock = make_lock(
|
||||
os.path.join(self.tmp_dir, "lock"),
|
||||
tmp_dir=self.tmp_dir,
|
||||
hardlink_lock=hardlink_lock,
|
||||
friendly=True,
|
||||
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 = {"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)
|
||||
self._scm = scm
|
||||
|
||||
# used by RepoTree to determine if it should traverse subrepos
|
||||
self.subrepos = subrepos
|
||||
|
||||
self.cache = Cache(self)
|
||||
self.cloud = DataCloud(self)
|
||||
|
||||
if not scm:
|
||||
if scm or not self.dvc_dir:
|
||||
self.lock = LockNoop()
|
||||
self.state = StateNoop()
|
||||
else:
|
||||
self.lock = make_lock(
|
||||
os.path.join(self.tmp_dir, "lock"),
|
||||
tmp_dir=self.tmp_dir,
|
||||
hardlink_lock=self.config["core"].get("hardlink_lock", False),
|
||||
friendly=True,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
self.stage_cache = StageCache(self)
|
||||
try:
|
||||
self.experiments = Experiments(self)
|
||||
except NotImplementedError:
|
||||
self.experiments = None
|
||||
|
||||
self._ignore()
|
||||
|
||||
self.metrics = Metrics(self)
|
||||
self.plots = Plots(self)
|
||||
self.params = Params(self)
|
||||
self._lock_depth = 0
|
||||
|
||||
try:
|
||||
self.experiments = Experiments(self)
|
||||
except NotImplementedError:
|
||||
self.experiments = None
|
||||
@cached_property
|
||||
def scm(self):
|
||||
from dvc.scm import SCM
|
||||
|
||||
self._ignore()
|
||||
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.
|
||||
@@ -154,7 +217,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:
|
||||
@@ -241,7 +304,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)
|
||||
build_graph(self.stages + new_stages)
|
||||
|
||||
def _collect_inside(self, path, graph):
|
||||
import networkx as nx
|
||||
@@ -400,113 +463,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,
|
||||
StagePathAsOutputError,
|
||||
OverlappingOutputPathsError,
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -525,12 +492,6 @@ class Repo:
|
||||
"""
|
||||
return self._collect_stages()
|
||||
|
||||
@cached_property
|
||||
def plot_templates(self):
|
||||
from .plots.template import PlotTemplates
|
||||
|
||||
return PlotTemplates(self.dvc_dir)
|
||||
|
||||
def _collect_stages(self):
|
||||
stages = []
|
||||
outs = set()
|
||||
@@ -580,19 +541,20 @@ class Repo:
|
||||
path_parts = os.path.normpath(path).split(os.path.sep)
|
||||
return self.DVC_DIR in path_parts
|
||||
|
||||
@cached_property
|
||||
def repo_tree(self):
|
||||
return RepoTree(self, subrepos=self.subrepos, fetch=True)
|
||||
|
||||
@contextmanager
|
||||
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
|
||||
"""Opens a specified resource as a file descriptor"""
|
||||
|
||||
tree = RepoTree(self, stream=True)
|
||||
path = os.path.join(self.root_dir, path)
|
||||
tree = RepoTree(self, stream=True, subrepos=True)
|
||||
path = PathInfo(self.root_dir) / path
|
||||
try:
|
||||
with self.state:
|
||||
with tree.open(
|
||||
os.path.join(self.root_dir, path),
|
||||
mode=mode,
|
||||
encoding=encoding,
|
||||
remote=remote,
|
||||
path, mode=mode, encoding=encoding, remote=remote,
|
||||
) as fobj:
|
||||
yield fobj
|
||||
except FileNotFoundError as exc:
|
||||
@@ -603,16 +565,9 @@ class Repo:
|
||||
def close(self):
|
||||
self.scm.close()
|
||||
|
||||
@locked
|
||||
def checkout(self, *args, **kwargs):
|
||||
return self._checkout(*args, **kwargs)
|
||||
|
||||
@locked
|
||||
def fetch(self, *args, **kwargs):
|
||||
return self._fetch(*args, **kwargs)
|
||||
|
||||
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)
|
||||
self.tree.__dict__.pop("dvcignore", None)
|
||||
|
||||
+64
-25
@@ -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):
|
||||
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
|
||||
|
||||
stages = []
|
||||
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)
|
||||
@@ -146,6 +183,8 @@ def _create_stages(repo, targets, fname, pbar=None, external=False):
|
||||
)
|
||||
if stage:
|
||||
Dvcfile(repo, stage.path).remove()
|
||||
if desc:
|
||||
stage.outs[0].desc = desc
|
||||
|
||||
repo._reset() # pylint: disable=protected-access
|
||||
|
||||
|
||||
+11
-2
@@ -4,7 +4,12 @@ from dvc.tree.local import LocalTree
|
||||
|
||||
|
||||
def brancher( # noqa: E302
|
||||
self, revs=None, all_branches=False, all_tags=False, all_commits=False
|
||||
self,
|
||||
revs=None,
|
||||
all_branches=False,
|
||||
all_tags=False,
|
||||
all_commits=False,
|
||||
sha_only=False,
|
||||
):
|
||||
"""Generator that iterates over specified revisions.
|
||||
|
||||
@@ -13,6 +18,7 @@ def brancher( # noqa: E302
|
||||
all_branches (bool): iterate over all available branches.
|
||||
all_commits (bool): iterate over all commits.
|
||||
all_tags (bool): iterate over all available tags.
|
||||
sha_only (bool): only return git SHA for a revision.
|
||||
|
||||
Yields:
|
||||
str: the display name for the currently selected tree, it could be:
|
||||
@@ -53,6 +59,9 @@ def brancher( # noqa: E302
|
||||
# ignore revs that don't contain repo root
|
||||
# (i.e. revs from before a subdir=True repo was init'ed)
|
||||
if self.tree.exists(self.root_dir):
|
||||
yield ", ".join(names)
|
||||
if sha_only:
|
||||
yield sha
|
||||
else:
|
||||
yield ", ".join(names)
|
||||
finally:
|
||||
self.tree = saved_tree
|
||||
|
||||
@@ -9,6 +9,8 @@ from dvc.exceptions import (
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import relpath
|
||||
|
||||
from . import locked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -36,13 +38,16 @@ def get_all_files_numbers(pairs):
|
||||
)
|
||||
|
||||
|
||||
def _checkout(
|
||||
@locked
|
||||
def checkout(
|
||||
self,
|
||||
targets=None,
|
||||
with_deps=False,
|
||||
force=False,
|
||||
relink=False,
|
||||
recursive=False,
|
||||
allow_missing=False,
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileBadNameError,
|
||||
@@ -93,6 +98,8 @@ def _checkout(
|
||||
progress_callback=pbar.update_msg,
|
||||
relink=relink,
|
||||
filter_info=filter_info,
|
||||
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)
|
||||
+1
-1
@@ -45,5 +45,5 @@ def commit(self, target, with_deps=False, recursive=False, force=False):
|
||||
stage.save()
|
||||
stage.commit()
|
||||
|
||||
Dvcfile(self, stage.path).dump(stage)
|
||||
Dvcfile(self, stage.path).dump(stage, update_pipeline=False)
|
||||
return stages
|
||||
|
||||
+89
-50
@@ -1,7 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dvc.repo import locked
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.tree.repo import RepoTree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@locked
|
||||
@@ -14,64 +18,32 @@ def diff(self, a_rev="HEAD", b_rev=None):
|
||||
`dvc diff` would be the same as `dvc diff HEAD`.
|
||||
"""
|
||||
|
||||
def _paths_checksums():
|
||||
"""
|
||||
A dictionary of checksums addressed by relpaths collected from
|
||||
the current tree outputs.
|
||||
|
||||
To help distinguish between a directory and a file output,
|
||||
the former one will come with a trailing slash in the path:
|
||||
|
||||
directory: "data/"
|
||||
file: "data"
|
||||
"""
|
||||
|
||||
def _to_path(output):
|
||||
return (
|
||||
str(output)
|
||||
if not output.is_dir_checksum
|
||||
else os.path.join(str(output), "")
|
||||
)
|
||||
|
||||
on_working_tree = isinstance(self.tree, LocalTree)
|
||||
|
||||
def _to_checksum(output):
|
||||
if on_working_tree:
|
||||
return self.cache.local.tree.get_hash(output.path_info)
|
||||
return output.checksum
|
||||
|
||||
def _exists(output):
|
||||
if on_working_tree:
|
||||
return output.exists
|
||||
return True
|
||||
|
||||
return {
|
||||
_to_path(output): _to_checksum(output)
|
||||
for stage in self.stages
|
||||
for output in stage.outs
|
||||
if _exists(output)
|
||||
}
|
||||
|
||||
if self.scm.no_commits:
|
||||
return {}
|
||||
|
||||
working_tree = self.tree
|
||||
a_tree = self.scm.get_tree(a_rev)
|
||||
b_tree = self.scm.get_tree(b_rev) if b_rev else working_tree
|
||||
b_rev = b_rev if b_rev else "workspace"
|
||||
results = {}
|
||||
for rev in self.brancher(revs=[a_rev, b_rev]):
|
||||
if rev == "workspace" and rev != b_rev:
|
||||
# brancher always returns workspace, but we only need to compute
|
||||
# workspace paths/checksums if b_rev was None
|
||||
continue
|
||||
results[rev] = _paths_checksums(self)
|
||||
|
||||
try:
|
||||
self.tree = a_tree
|
||||
old = _paths_checksums()
|
||||
|
||||
self.tree = b_tree
|
||||
new = _paths_checksums()
|
||||
finally:
|
||||
self.tree = working_tree
|
||||
old = results[a_rev]
|
||||
new = results[b_rev]
|
||||
|
||||
# Compare paths between the old and new tree.
|
||||
# set() efficiently converts dict keys to a set
|
||||
added = sorted(set(new) - set(old))
|
||||
deleted = sorted(set(old) - set(new))
|
||||
deleted_or_missing = set(old) - set(new)
|
||||
if b_rev == "workspace":
|
||||
# missing status is only applicable when diffing local workspace
|
||||
# against a commit
|
||||
missing = sorted(_filter_missing(self, deleted_or_missing))
|
||||
else:
|
||||
missing = []
|
||||
deleted = sorted(deleted_or_missing - set(missing))
|
||||
modified = sorted(set(old) & set(new))
|
||||
|
||||
ret = {
|
||||
@@ -82,6 +54,73 @@ def diff(self, a_rev="HEAD", b_rev=None):
|
||||
for path in modified
|
||||
if old[path] != new[path]
|
||||
],
|
||||
"not in cache": [
|
||||
{"path": path, "hash": old[path]} for path in missing
|
||||
],
|
||||
}
|
||||
|
||||
return ret if any(ret.values()) else {}
|
||||
|
||||
|
||||
def _paths_checksums(repo):
|
||||
"""
|
||||
A dictionary of checksums addressed by relpaths collected from
|
||||
the current tree outputs.
|
||||
|
||||
To help distinguish between a directory and a file output,
|
||||
the former one will come with a trailing slash in the path:
|
||||
|
||||
directory: "data/"
|
||||
file: "data"
|
||||
"""
|
||||
|
||||
return dict(_output_paths(repo))
|
||||
|
||||
|
||||
def _output_paths(repo):
|
||||
repo_tree = RepoTree(repo, stream=True)
|
||||
on_working_tree = isinstance(repo.tree, LocalTree)
|
||||
|
||||
def _exists(output):
|
||||
if on_working_tree:
|
||||
return output.exists
|
||||
return True
|
||||
|
||||
def _to_path(output):
|
||||
return (
|
||||
str(output)
|
||||
if not output.is_dir_checksum
|
||||
else os.path.join(str(output), "")
|
||||
)
|
||||
|
||||
def _to_checksum(output):
|
||||
if on_working_tree:
|
||||
return repo.cache.local.tree.get_hash(output.path_info).value
|
||||
return output.hash_info.value
|
||||
|
||||
for stage in repo.stages:
|
||||
for output in stage.outs:
|
||||
if _exists(output):
|
||||
yield _to_path(output), _to_checksum(output)
|
||||
if output.is_dir_checksum:
|
||||
yield from _dir_output_paths(repo_tree, output)
|
||||
|
||||
|
||||
def _dir_output_paths(repo_tree, output):
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
try:
|
||||
for fname in repo_tree.walk_files(output.path_info):
|
||||
yield str(fname), repo_tree.get_file_hash(fname).value
|
||||
except NoRemoteError:
|
||||
logger.warning("dir cache entry for '%s' is missing", output)
|
||||
|
||||
|
||||
def _filter_missing(repo, paths):
|
||||
repo_tree = RepoTree(repo, stream=True)
|
||||
for path in paths:
|
||||
metadata = repo_tree.metadata(path)
|
||||
if metadata.is_dvc:
|
||||
out = metadata.outs[0]
|
||||
if out.status().get(str(out)) == "not in cache":
|
||||
yield path
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.path_info import PathInfo
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.tree.base import BaseTree
|
||||
from dvc.tree.local import LocalTree
|
||||
from dvc.tree.repo import RepoTree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExperimentExecutor:
|
||||
"""Base class for executing experiments in parallel.
|
||||
|
||||
Args:
|
||||
baseline_rev: Baseline revision that this experiment is derived from.
|
||||
|
||||
Optional keyword args:
|
||||
branch: Existing git branch for this experiment.
|
||||
rev: Git revision to be checked out for this experiment, defaults to
|
||||
branch, baseline_rev in that order.
|
||||
repro_args: Args to be passed into reproduce.
|
||||
repro_kwargs: Keyword args to be passed into reproduce.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
baseline_rev: str,
|
||||
branch: Optional[str] = None,
|
||||
rev: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.baseline_rev = baseline_rev
|
||||
self._rev = rev
|
||||
self.branch = branch
|
||||
self.repro_args = kwargs.pop("repro_args", [])
|
||||
self.repro_kwargs = kwargs.pop("repro_kwargs", {})
|
||||
self.tmp_dir: Optional[TemporaryDirectory] = None
|
||||
|
||||
@property
|
||||
def tree(self) -> BaseTree:
|
||||
raise NotImplementedError
|
||||
|
||||
@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 cleanup(self):
|
||||
pass
|
||||
|
||||
# TODO: come up with better way to stash repro arguments
|
||||
@staticmethod
|
||||
def pack_repro_args(path, *args, tree=None, **kwargs):
|
||||
dpath = os.path.dirname(path)
|
||||
if tree:
|
||||
open_func = tree.open
|
||||
tree.makedirs(dpath)
|
||||
else:
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
open_func = open
|
||||
makedirs(dpath, exist_ok=True)
|
||||
data = {"args": args, "kwargs": kwargs}
|
||||
with open_func(path, "wb") as fobj:
|
||||
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:
|
||||
data = pickle.load(fobj)
|
||||
return data["args"], data["kwargs"]
|
||||
|
||||
@staticmethod
|
||||
def collect_files(tree: BaseTree, repo_tree: RepoTree):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class LocalExecutor(ExperimentExecutor):
|
||||
"""Local machine experiment executor."""
|
||||
|
||||
def __init__(
|
||||
self, checkpoint_reset: Optional[bool] = False, **kwargs,
|
||||
):
|
||||
from dvc.repo import Repo
|
||||
|
||||
dvc_dir = kwargs.pop("dvc_dir")
|
||||
cache_dir = kwargs.pop("cache_dir")
|
||||
super().__init__(**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,
|
||||
self.baseline_rev[:7],
|
||||
)
|
||||
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
|
||||
self.checkpoint_reset = checkpoint_reset
|
||||
self.checkpoint = False
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def rev(self):
|
||||
return self._rev if self._rev else self.baseline_rev
|
||||
|
||||
@staticmethod
|
||||
def reproduce(dvc_dir, cwd=None, **kwargs):
|
||||
"""Run dvc repro and return the result."""
|
||||
from dvc.repo import Repo
|
||||
|
||||
unchanged = []
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
logger.debug("Running repro in '%s'", cwd)
|
||||
dvc = Repo(dvc_dir)
|
||||
|
||||
# NOTE: for checkpoint experiments we handle persist outs slightly
|
||||
# differently than normal:
|
||||
#
|
||||
# - checkpoint out may not yet exist if this is the first time this
|
||||
# experiment has been run, this is not an error condition for
|
||||
# experiments
|
||||
# - at the start of a repro run, we need to remove the persist out
|
||||
# and restore it to its last known (committed) state (which may
|
||||
# be removed/does not yet exist) so that our executor workspace
|
||||
# is not polluted with the (persistent) out from an unrelated
|
||||
# experiment run
|
||||
dvc.checkout(force=True, quiet=True)
|
||||
stages = dvc.reproduce(on_unchanged=filter_pipeline, **kwargs)
|
||||
finally:
|
||||
if old_cwd is not None:
|
||||
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 stages + unchanged
|
||||
|
||||
def collect_output(self) -> Iterable["PathInfo"]:
|
||||
repo_tree = RepoTree(self.dvc)
|
||||
yield from self.collect_files(self.tree, repo_tree)
|
||||
|
||||
@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)
|
||||
|
||||
def cleanup(self):
|
||||
logger.debug("Removing tmpdir '%s'", self.tmp_dir)
|
||||
self.tmp_dir.cleanup()
|
||||
super().cleanup()
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from dvc.repo import locked
|
||||
|
||||
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
|
||||
|
||||
delete_branches = []
|
||||
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 not in keep_revs:
|
||||
delete_branches.append(exp_branch)
|
||||
if delete_branches:
|
||||
repo.experiments.scm.repo.delete_head(*delete_branches, force=True)
|
||||
removed = len(delete_branches)
|
||||
|
||||
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.scm.repo.git.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 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,6 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import OrderedDict, defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from dvc.repo import locked
|
||||
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
|
||||
@@ -9,56 +9,119 @@ from dvc.repo.params.show import _collect_configs, _read_params
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
EXP_RE = re.compile(r"(?P<rev_sha>[a-f0-9]{7})-(?P<exp_sha>[a-f0-9]+)")
|
||||
def _collect_experiment(repo, rev, stash=False, sha_only=True):
|
||||
from git.exc import GitCommandError
|
||||
|
||||
|
||||
def _collect_experiment(repo, branch):
|
||||
res = defaultdict(dict)
|
||||
for rev in repo.brancher(revs=[branch]):
|
||||
configs = _collect_configs(repo)
|
||||
for rev in repo.brancher(revs=[rev]):
|
||||
if rev == "workspace":
|
||||
res["timestamp"] = None
|
||||
else:
|
||||
commit = repo.scm.resolve_commit(rev)
|
||||
res["timestamp"] = datetime.fromtimestamp(commit.committed_date)
|
||||
|
||||
configs = _collect_configs(repo, rev=rev)
|
||||
params = _read_params(repo, configs, rev)
|
||||
if params:
|
||||
res["params"] = params
|
||||
|
||||
metrics = _collect_metrics(repo, None, False)
|
||||
vals = _read_metrics(repo, metrics, rev)
|
||||
if vals:
|
||||
res["queued"] = stash
|
||||
if not stash:
|
||||
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
|
||||
)
|
||||
name = name.rsplit("/")[-1]
|
||||
res["name"] = name
|
||||
except GitCommandError:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _collect_checkpoint_experiment(res, repo, branch, baseline, **kwargs):
|
||||
exp_rev = repo.scm.resolve_rev(branch)
|
||||
prev = None
|
||||
for rev in repo.scm.branch_revs(exp_rev, baseline):
|
||||
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(_collect_experiment(repo, rev, **kwargs))
|
||||
res[rev] = exp
|
||||
prev = rev
|
||||
res[prev]["checkpoint_parent"] = baseline
|
||||
return res
|
||||
|
||||
|
||||
@locked
|
||||
def show(
|
||||
repo, all_branches=False, all_tags=False, revs=None, all_commits=False
|
||||
repo,
|
||||
all_branches=False,
|
||||
all_tags=False,
|
||||
revs=None,
|
||||
all_commits=False,
|
||||
sha_only=False,
|
||||
):
|
||||
res = defaultdict(OrderedDict)
|
||||
|
||||
if revs is None:
|
||||
revs = [repo.scm.get_rev()]
|
||||
|
||||
revs = set(
|
||||
repo.brancher(
|
||||
revs = OrderedDict(
|
||||
(rev, None)
|
||||
for rev in repo.brancher(
|
||||
revs=revs,
|
||||
all_branches=all_branches,
|
||||
all_tags=all_tags,
|
||||
all_commits=all_commits,
|
||||
sha_only=True,
|
||||
)
|
||||
)
|
||||
|
||||
for rev in revs:
|
||||
res[rev]["baseline"] = _collect_experiment(repo, rev)
|
||||
res[rev]["baseline"] = _collect_experiment(
|
||||
repo, rev, sha_only=sha_only
|
||||
)
|
||||
|
||||
for exp_branch in repo.experiments.scm.list_branches():
|
||||
m = re.match(EXP_RE, exp_branch)
|
||||
# collect reproduced experiments
|
||||
for head in sorted(
|
||||
repo.experiments.scm.repo.heads,
|
||||
key=lambda h: h.commit.committed_date,
|
||||
reverse=True,
|
||||
):
|
||||
exp_branch = head.name
|
||||
m = repo.experiments.BRANCH_RE.match(exp_branch)
|
||||
if m:
|
||||
rev = repo.scm.resolve_rev(m.group("rev_sha"))
|
||||
rev = repo.scm.resolve_rev(m.group("baseline_rev"))
|
||||
if rev in revs:
|
||||
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
|
||||
with repo.experiments.chdir():
|
||||
experiment = _collect_experiment(
|
||||
repo.experiments.exp_dvc, exp_branch
|
||||
)
|
||||
res[rev][exp_rev] = experiment
|
||||
if m.group("checkpoint"):
|
||||
_collect_checkpoint_experiment(
|
||||
res[rev], repo.experiments.exp_dvc, exp_branch, rev
|
||||
)
|
||||
else:
|
||||
exp_rev = repo.experiments.scm.resolve_rev(exp_branch)
|
||||
experiment = _collect_experiment(
|
||||
repo.experiments.exp_dvc, exp_branch
|
||||
)
|
||||
res[rev][exp_rev] = experiment
|
||||
|
||||
# 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
|
||||
)
|
||||
res[entry.baseline_rev][stash_rev] = experiment
|
||||
|
||||
return res
|
||||
|
||||
+14
-10
@@ -4,10 +4,13 @@ from dvc.config import NoRemoteError
|
||||
from dvc.exceptions import DownloadError
|
||||
from dvc.scm.base import CloneError
|
||||
|
||||
from . import locked
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fetch(
|
||||
@locked
|
||||
def fetch(
|
||||
self,
|
||||
targets=None,
|
||||
jobs=None,
|
||||
@@ -33,8 +36,6 @@ def _fetch(
|
||||
remote is configured
|
||||
"""
|
||||
|
||||
used_run_cache = self.stage_cache.pull(remote) if run_cache else []
|
||||
|
||||
if isinstance(targets, str):
|
||||
targets = [targets]
|
||||
|
||||
@@ -48,13 +49,14 @@ def _fetch(
|
||||
remote=remote,
|
||||
jobs=jobs,
|
||||
recursive=recursive,
|
||||
used_run_cache=used_run_cache,
|
||||
)
|
||||
|
||||
downloaded = 0
|
||||
failed = 0
|
||||
|
||||
try:
|
||||
if run_cache:
|
||||
self.stage_cache.pull(remote)
|
||||
downloaded += self.cloud.pull(
|
||||
used, jobs, remote=remote, show_checksums=show_checksums,
|
||||
)
|
||||
@@ -72,19 +74,21 @@ def _fetch(
|
||||
if failed:
|
||||
raise DownloadError(failed)
|
||||
|
||||
return downloaded + len(used_run_cache)
|
||||
return downloaded
|
||||
|
||||
|
||||
def _fetch_external(self, repo_url, repo_rev, files, jobs):
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
failed, downloaded = 0, 0
|
||||
cache = self.cache.local
|
||||
try:
|
||||
with external_repo(repo_url, repo_rev) as repo:
|
||||
with repo.use_cache(self.cache.local):
|
||||
d, f, _ = repo.fetch_external(files, jobs=jobs)
|
||||
downloaded += d
|
||||
failed += f
|
||||
with external_repo(
|
||||
repo_url, repo_rev, cache_dir=cache.cache_dir
|
||||
) as repo:
|
||||
d, f, _ = repo.fetch_external(files, jobs=jobs)
|
||||
downloaded += d
|
||||
failed += f
|
||||
except CloneError:
|
||||
failed += 1
|
||||
logger.exception(
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ def _set(repo, target, frozen):
|
||||
path, name = parse_target(target)
|
||||
stage = repo.get_stage(path, name)
|
||||
stage.frozen = frozen
|
||||
stage.dvcfile.dump(stage, update_pipeline=True)
|
||||
stage.dvcfile.dump(stage, update_lock=False)
|
||||
|
||||
return stage
|
||||
|
||||
|
||||
+14
-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(
|
||||
@@ -50,6 +43,7 @@ def gc(
|
||||
)
|
||||
|
||||
from contextlib import ExitStack
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
if not repos:
|
||||
@@ -75,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.")
|
||||
|
||||
+15
-16
@@ -18,10 +18,9 @@ class GetDVCFileError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get(url, path, out=None, rev=None):
|
||||
from dvc.external_repo import external_repo
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
out = resolve_output(path, out)
|
||||
|
||||
@@ -35,21 +34,21 @@ def get(url, path, out=None, rev=None):
|
||||
# and won't work with reflink/hardlink.
|
||||
dpath = os.path.dirname(os.path.abspath(out))
|
||||
tmp_dir = os.path.join(dpath, "." + str(shortuuid.uuid()))
|
||||
try:
|
||||
with external_repo(url=url, rev=rev) as repo:
|
||||
if hasattr(repo, "cache"):
|
||||
repo.cache.local.cache_dir = tmp_dir
|
||||
|
||||
# Try any links possible to avoid data duplication.
|
||||
#
|
||||
# Not using symlink, because we need to remove cache after we
|
||||
# are done, and to make that work we would have to copy data
|
||||
# over anyway before removing the cache, so we might just copy
|
||||
# it right away.
|
||||
#
|
||||
# Also, we can't use theoretical "move" link type here, because
|
||||
# the same cache file might be used a few times in a directory.
|
||||
repo.cache.local.cache_types = ["reflink", "hardlink", "copy"]
|
||||
# Try any links possible to avoid data duplication.
|
||||
#
|
||||
# Not using symlink, because we need to remove cache after we
|
||||
# are done, and to make that work we would have to copy data
|
||||
# over anyway before removing the cache, so we might just copy
|
||||
# it right away.
|
||||
#
|
||||
# Also, we can't use theoretical "move" link type here, because
|
||||
# the same cache file might be used a few times in a directory.
|
||||
cache_types = ["reflink", "hardlink", "copy"]
|
||||
try:
|
||||
with external_repo(
|
||||
url=url, rev=rev, cache_dir=tmp_dir, cache_types=cache_types
|
||||
) as repo:
|
||||
repo.get_external(path, out)
|
||||
finally:
|
||||
remove(tmp_dir)
|
||||
|
||||
+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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
def check_acyclic(graph):
|
||||
import networkx as nx
|
||||
|
||||
from dvc.exceptions import CyclicGraphError
|
||||
|
||||
try:
|
||||
@@ -25,3 +26,97 @@ def get_pipelines(G):
|
||||
import networkx as nx
|
||||
|
||||
return [G.subgraph(c).copy() for c in nx.weakly_connected_components(G)]
|
||||
|
||||
|
||||
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, 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, erepo=erepo, frozen=True)
|
||||
return self.imp_url(
|
||||
path, out=out, fname=fname, erepo=erepo, frozen=True, **kwargs
|
||||
)
|
||||
|
||||
+11
-1
@@ -11,7 +11,14 @@ 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
|
||||
@@ -40,6 +47,9 @@ def imp_url(
|
||||
if stage is None:
|
||||
return None
|
||||
|
||||
if desc:
|
||||
stage.outs[0].desc = desc
|
||||
|
||||
dvcfile = Dvcfile(self, stage.path)
|
||||
dvcfile.remove()
|
||||
|
||||
|
||||
+9
-35
@@ -1,48 +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, relpath
|
||||
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"
|
||||
"{blue}https://dvc.org/doc/user-guide/analytics{nc}".format(
|
||||
blue=colorama.Fore.BLUE, nc=colorama.Fore.RESET
|
||||
),
|
||||
border_color="red",
|
||||
)
|
||||
)
|
||||
|
||||
msg = (
|
||||
"{yellow}What's next?{nc}\n"
|
||||
"{yellow}------------{nc}\n"
|
||||
"- Check out the documentation: {blue}https://dvc.org/doc{nc}\n"
|
||||
"- Get help and share ideas: {blue}https://dvc.org/chat{nc}\n"
|
||||
"- Star us on GitHub: {blue}https://github.com/iterative/dvc{nc}"
|
||||
).format(
|
||||
yellow=colorama.Fore.YELLOW,
|
||||
blue=colorama.Fore.BLUE,
|
||||
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
|
||||
@@ -99,14 +69,18 @@ def init(root_dir=os.curdir, no_scm=False, force=False, subdir=False):
|
||||
with config.edit() as conf:
|
||||
conf["core"]["no_scm"] = True
|
||||
|
||||
dvcignore = init_dvcignore(root_dir)
|
||||
|
||||
proj = Repo(root_dir)
|
||||
|
||||
scm.add([config.files["repo"], proj.plot_templates.templates_dir])
|
||||
proj.plots.templates.init()
|
||||
|
||||
scm.add(
|
||||
[config.files["repo"], dvcignore, proj.plots.templates.templates_dir]
|
||||
)
|
||||
|
||||
if scm.ignore_file:
|
||||
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
|
||||
|
||||
+25
-44
@@ -1,8 +1,9 @@
|
||||
from itertools import chain
|
||||
|
||||
from dvc.exceptions import PathMissingError
|
||||
from dvc.path_info import PathInfo
|
||||
|
||||
|
||||
@staticmethod
|
||||
def ls(
|
||||
url, path=None, rev=None, recursive=None, dvc_only=False,
|
||||
):
|
||||
@@ -29,12 +30,14 @@ def ls(
|
||||
"""
|
||||
from dvc.external_repo import external_repo
|
||||
|
||||
with external_repo(url, rev) as repo:
|
||||
# use our own RepoTree instance instead of repo.repo_tree since we want to
|
||||
# fetch directory listings, but don't want to fetch file contents.
|
||||
with external_repo(url, rev, fetch=False, stream=True) as repo:
|
||||
path_info = PathInfo(repo.root_dir)
|
||||
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)
|
||||
@@ -47,58 +50,36 @@ def ls(
|
||||
return ret_list
|
||||
|
||||
|
||||
def _ls(repo, path_info, recursive=None, dvc_only=False):
|
||||
from dvc.repo.tree import RepoTree
|
||||
|
||||
def _ls(tree, path_info, recursive=None, dvc_only=False):
|
||||
def onerror(exc):
|
||||
raise exc
|
||||
|
||||
# use our own RepoTree instance instead of repo.repo_tree since we want to
|
||||
# fetch directory listings, but don't want to fetch file contents.
|
||||
tree = RepoTree(repo, stream=True)
|
||||
|
||||
ret = {}
|
||||
infos = []
|
||||
try:
|
||||
for root, dirs, files in tree.walk(
|
||||
path_info.fspath, onerror=onerror, dvcfiles=True
|
||||
):
|
||||
for fname in files:
|
||||
info = PathInfo(root) / fname
|
||||
dvc = tree.isdvc(info)
|
||||
if dvc or not dvc_only:
|
||||
path = str(info.relative_to(path_info))
|
||||
ret[path] = {
|
||||
"isout": dvc,
|
||||
"isdir": False,
|
||||
"isexec": False if dvc else tree.isexec(info),
|
||||
}
|
||||
|
||||
entries = chain(files, dirs) if not recursive else files
|
||||
infos.extend(PathInfo(root) / entry for entry in entries)
|
||||
if not recursive:
|
||||
for dname in dirs:
|
||||
info = PathInfo(root) / dname
|
||||
if not dvc_only or (
|
||||
tree.dvctree and tree.dvctree.exists(info)
|
||||
):
|
||||
dvc = tree.isdvc(info)
|
||||
path = str(info.relative_to(path_info))
|
||||
ret[path] = {
|
||||
"isout": dvc,
|
||||
"isdir": True,
|
||||
"isexec": False if dvc else tree.isexec(info),
|
||||
}
|
||||
break
|
||||
except NotADirectoryError:
|
||||
dvc = tree.isdvc(path_info)
|
||||
if dvc or not dvc_only:
|
||||
return {
|
||||
path_info.name: {
|
||||
"isout": dvc,
|
||||
"isdir": False,
|
||||
"isexec": False if dvc else tree.isexec(path_info),
|
||||
}
|
||||
}
|
||||
return {}
|
||||
infos.append(path_info)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
ret = {}
|
||||
for info in infos:
|
||||
metadata = tree.metadata(info)
|
||||
if metadata.output_exists or not dvc_only:
|
||||
path = (
|
||||
path_info.name
|
||||
if path_info == info
|
||||
else str(info.relative_to(path_info))
|
||||
)
|
||||
ret[path] = {
|
||||
"isout": metadata.is_output,
|
||||
"isdir": metadata.isdir,
|
||||
"isexec": metadata.is_exec,
|
||||
}
|
||||
return ret
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user