mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 23:09:28 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3b4b373b | ||
|
|
512bfc4305 | ||
|
|
54ee8c0d77 | ||
|
|
063eb6904d | ||
|
|
3e183d62ed | ||
|
|
e815bbe211 | ||
|
|
2374c72105 | ||
|
|
5cf1421c61 | ||
|
|
a6a2feea62 | ||
|
|
6c1d00c2d9 | ||
|
|
f72456f67c | ||
|
|
2c556f58d3 | ||
|
|
460d08057c | ||
|
|
cc6deb24c8 | ||
|
|
e681030c72 | ||
|
|
7a51548214 | ||
|
|
8e99fd0091 | ||
|
|
7e5a586f85 | ||
|
|
c76a84b7b1 | ||
|
|
26008f1559 | ||
|
|
92d680ff94 | ||
|
|
c69c46d08c | ||
|
|
6477ade557 | ||
|
|
78dd045d29 | ||
|
|
2ddbbdf5a4 | ||
|
|
17a565023b | ||
|
|
b5d5728bf8 | ||
|
|
055641a51e | ||
|
|
fc509d4e4a | ||
|
|
a67fea2235 | ||
|
|
f062b52e05 | ||
|
|
25166f3e02 | ||
|
|
a79ea15827 | ||
|
|
8db9a2f70f | ||
|
|
ff2f2e433f | ||
|
|
3fc9f323a7 | ||
|
|
bf0aa5e8aa | ||
|
|
3b3866323e | ||
|
|
3c3cbee7fd | ||
|
|
4a95020155 | ||
|
|
3a429a869e | ||
|
|
99465b25b5 | ||
|
|
fcdad4d229 | ||
|
|
2e8b463795 | ||
|
|
206e00ccbe | ||
|
|
f13443477d | ||
|
|
9837747eef | ||
|
|
cf5dfc517a | ||
|
|
c134d4ab8e | ||
|
|
7a0b3e9377 | ||
|
|
d6f8e5a507 | ||
|
|
2ca1d6c7c1 | ||
|
|
f7fbac1eed | ||
|
|
7c1e339650 | ||
|
|
b7c0c41262 | ||
|
|
9180a718c1 | ||
|
|
306456d453 | ||
|
|
95421a5605 | ||
|
|
31635b8312 | ||
|
|
45ac64ddde | ||
|
|
c7b22b467c | ||
|
|
ae83e3c787 | ||
|
|
f68c4e9a4b | ||
|
|
2182c4508d | ||
|
|
eaab253e2b | ||
|
|
905c416ffc | ||
|
|
a6063307b5 | ||
|
|
704191b15a | ||
|
|
5625a94365 | ||
|
|
634894d3b1 | ||
|
|
0ce4e769f1 | ||
|
|
2c3655f7ff | ||
|
|
52bdd90840 | ||
|
|
fcc7240617 | ||
|
|
ecd98bdf7d | ||
|
|
e525d9bedb | ||
|
|
27b2a5cd6d | ||
|
|
8518fe639f | ||
|
|
496599518a | ||
|
|
116ad512e4 | ||
|
|
6a7b55fdf7 | ||
|
|
ed62ddb373 | ||
|
|
38b1a41f2b | ||
|
|
6f0736142b | ||
|
|
737d7cda4b | ||
|
|
75745a70b4 | ||
|
|
9ff18502b6 | ||
|
|
44a4fb59a7 | ||
|
|
6543683c0c | ||
|
|
8fee26c3d2 | ||
|
|
7c8994b538 | ||
|
|
9dfe88e2b1 | ||
|
|
d0eda1d538 | ||
|
|
896acedb07 | ||
|
|
fd7dcae8aa | ||
|
|
a4763593c7 | ||
|
|
cc2d2495c0 | ||
|
|
9b30ef474a | ||
|
|
f95e6340e0 | ||
|
|
c3ac4f84bf | ||
|
|
61729ff66a | ||
|
|
3dfcb82e5c | ||
|
|
340a158595 | ||
|
|
4b965c3f9f | ||
|
|
b5220129e7 | ||
|
|
7a4886386f | ||
|
|
ae143505e4 | ||
|
|
b9b2e5bbd9 | ||
|
|
66420439c9 | ||
|
|
ed1d8e2b8f | ||
|
|
0b07354361 | ||
|
|
fa200c9b4c | ||
|
|
1aaf836340 | ||
|
|
f51e96fe6d | ||
|
|
afefc0ddc1 | ||
|
|
bd82af2e1e | ||
|
|
34f20bf69f | ||
|
|
2c339a7fb3 | ||
|
|
ec4d61e2b1 | ||
|
|
94eec50d27 | ||
|
|
dd32ff6206 | ||
|
|
52a9c58cc0 | ||
|
|
01855b7dcf | ||
|
|
38f10884a7 | ||
|
|
9e6d2163df | ||
|
|
dbfe88510f | ||
|
|
c04eb40f90 | ||
|
|
c2668110b7 | ||
|
|
80272a0770 | ||
|
|
5a6e363c16 | ||
|
|
ed593493df | ||
|
|
3130a9a75f | ||
|
|
eeb516e7d3 | ||
|
|
c0368e1756 | ||
|
|
e19824fd2e | ||
|
|
02681f2e65 | ||
|
|
b37524603a | ||
|
|
ff3b23c1b6 | ||
|
|
4e005411fe | ||
|
|
1f7b802f79 | ||
|
|
7c2bd340db | ||
|
|
9e0788aa3a | ||
|
|
e1cf62a583 | ||
|
|
ce4cee184e | ||
|
|
db52c75221 | ||
|
|
bd74172633 | ||
|
|
e5b4946309 | ||
|
|
3918db945a | ||
|
|
25b5d1d522 | ||
|
|
164efe089f | ||
|
|
4e82eb4978 | ||
|
|
16bb31a284 | ||
|
|
d7a917e2ab | ||
|
|
8dfcb0a36d | ||
|
|
67cbdf6de8 | ||
|
|
f12091002f | ||
|
|
570f4345a0 | ||
|
|
425d0701c9 | ||
|
|
99a11f7056 | ||
|
|
8ab46c1549 | ||
|
|
8b663f2719 | ||
|
|
c0db513cf0 | ||
|
|
bf1bc9625d | ||
|
|
686c15dab9 | ||
|
|
10aa833f80 | ||
|
|
9f5af04963 | ||
|
|
ac5d72967a | ||
|
|
bcc2f3b389 | ||
|
|
3d0ee0d202 | ||
|
|
6b8c87b58a | ||
|
|
fee1d02791 | ||
|
|
d766c01d2d | ||
|
|
dc56c4c9b0 | ||
|
|
7ece26d58a | ||
|
|
f26664a569 | ||
|
|
4b0c65093a | ||
|
|
12fc8c4e7d | ||
|
|
a6cb4eb72e | ||
|
|
4df5a1321d | ||
|
|
22deca3a6a | ||
|
|
dc4292c602 | ||
|
|
56f90eb3c9 | ||
|
|
1c474e8f30 | ||
|
|
24654fe16c | ||
|
|
44820e74a3 | ||
|
|
c16908e9fc | ||
|
|
33dbdd20af | ||
|
|
69d7bdd0c0 | ||
|
|
b9f4398826 | ||
|
|
a6e7d43668 | ||
|
|
e2ee69ddb2 | ||
|
|
e8448345f1 | ||
|
|
acab008c65 | ||
|
|
8c74775a4e | ||
|
|
9b31a4b463 | ||
|
|
9f022c576c | ||
|
|
316029a9d2 | ||
|
|
34fe643cee | ||
|
|
6a01cc530e | ||
|
|
81475bee09 | ||
|
|
a167dff019 | ||
|
|
cffd587952 | ||
|
|
a0ae9d3f45 | ||
|
|
e74f8f9e11 | ||
|
|
e45ca1e830 | ||
|
|
46c734eb7e | ||
|
|
c17f7f03bd | ||
|
|
7113568c60 | ||
|
|
6143146974 | ||
|
|
15ada9a1c3 | ||
|
|
217eb13495 | ||
|
|
20d1f7a87d | ||
|
|
9c59689aa7 | ||
|
|
d47e3c40b8 | ||
|
|
d8be68436f | ||
|
|
f6adc49089 | ||
|
|
a4c0ae9c73 | ||
|
|
8b3020aaf3 | ||
|
|
182a22b8f6 | ||
|
|
892b235d0b | ||
|
|
da93e9a76c | ||
|
|
bd93d85d47 | ||
|
|
eed6a8483c | ||
|
|
954de0cae4 | ||
|
|
909941308b | ||
|
|
c59f935cc5 | ||
|
|
03b789e206 | ||
|
|
cacc2f14cb | ||
|
|
0d71194741 | ||
|
|
683d22c42b | ||
|
|
c687d1bf21 | ||
|
|
fbb98a87d1 | ||
|
|
f42beb4433 | ||
|
|
de8ab044f8 | ||
|
|
3c76885577 | ||
|
|
c347d9f84b | ||
|
|
efc4787cb0 | ||
|
|
be3ec9dc59 | ||
|
|
685a2d5579 | ||
|
|
c336507e51 | ||
|
|
d448eb5fc1 | ||
|
|
3dcd0107d2 | ||
|
|
2773e99afc | ||
|
|
9a1a3a05f5 | ||
|
|
286fab4aab | ||
|
|
20f69a121e | ||
|
|
b994c529a2 | ||
|
|
f106a68424 | ||
|
|
66c2e8cbd8 | ||
|
|
735b563ce4 | ||
|
|
604e0178f7 | ||
|
|
f210e41d5a | ||
|
|
e07e79da52 | ||
|
|
1284842f70 | ||
|
|
0aa063a1e7 | ||
|
|
87c5cb7ebf | ||
|
|
59570c08a4 | ||
|
|
eafe686dd6 | ||
|
|
d056d8a4c5 | ||
|
|
165d95be56 | ||
|
|
ea1c54f48e | ||
|
|
31f67cd3de | ||
|
|
ee87e2b260 | ||
|
|
ae61143956 | ||
|
|
1253e6cc46 | ||
|
|
19f2186c8b | ||
|
|
2e21c92c4f | ||
|
|
4262e26a15 | ||
|
|
830c116b4e | ||
|
|
1663b97eaa | ||
|
|
15e10a1d81 | ||
|
|
4bc6b5699a | ||
|
|
8e003e5022 | ||
|
|
af649af462 | ||
|
|
957d8d179b | ||
|
|
e7b265a178 | ||
|
|
422511f66d | ||
|
|
6826d8ac35 | ||
|
|
bdea9847b5 | ||
|
|
8e6ea87b76 | ||
|
|
036ac8e7c2 | ||
|
|
3306d2789b | ||
|
|
b094839210 | ||
|
|
b9c04a247a | ||
|
|
ef689c5593 | ||
|
|
7b16a377e5 | ||
|
|
793f97bdd5 | ||
|
|
d30dbd50bc | ||
|
|
e6db6ffc89 | ||
|
|
9ac2f49b57 | ||
|
|
9b2adfd3cc | ||
|
|
6dd3f1e053 | ||
|
|
677da2b22c | ||
|
|
a3ee993739 | ||
|
|
95f67445de | ||
|
|
c9a3cb803c | ||
|
|
41ecd2e204 | ||
|
|
33b3afa2f5 | ||
|
|
e849162018 | ||
|
|
c14f96391a | ||
|
|
88d3582d40 | ||
|
|
8fa0e40669 | ||
|
|
a3d6b1299a | ||
|
|
20c7b0e3e6 | ||
|
|
6794dd28f7 | ||
|
|
1cc803d12b | ||
|
|
2b6aa01392 | ||
|
|
906c87fdef | ||
|
|
52f02d238d | ||
|
|
3d791778de | ||
|
|
1e77be3ae3 | ||
|
|
844f77b4f9 | ||
|
|
34ef1807be | ||
|
|
dae0000bcd | ||
|
|
109d0e4e28 | ||
|
|
68b0d61c9e | ||
|
|
942a080567 | ||
|
|
60cbcec399 | ||
|
|
0c5a18513c | ||
|
|
b5c28dd9f9 | ||
|
|
84439e6a04 | ||
|
|
75fa487406 | ||
|
|
0ecd47b333 | ||
|
|
9710cafd38 | ||
|
|
e34767ca90 | ||
|
|
9a2feaa4ed | ||
|
|
7bc98e3fb3 | ||
|
|
5e561c1dfd | ||
|
|
572b6dfca9 | ||
|
|
37921d618d | ||
|
|
95c6cea36d | ||
|
|
455260b29c | ||
|
|
82dc67d890 | ||
|
|
2057c471bb | ||
|
|
f23d31af64 | ||
|
|
cf5d3830c5 | ||
|
|
32cd6c2c03 | ||
|
|
54917e3255 | ||
|
|
e8a439df8d | ||
|
|
71a4b4823a | ||
|
|
01cc97f38a | ||
|
|
3872f79dad | ||
|
|
48176b80f6 | ||
|
|
8c9a9e9a1f | ||
|
|
120eb09c4d | ||
|
|
bf7892012d | ||
|
|
36605eea4c | ||
|
|
01e5f9eadd | ||
|
|
752e10a834 | ||
|
|
092eda183c | ||
|
|
d82e094b13 | ||
|
|
570e3a5359 | ||
|
|
b8e770747f | ||
|
|
1999ecf0d3 | ||
|
|
1ff5f30839 | ||
|
|
c6ae91c907 | ||
|
|
dc1d3e880a | ||
|
|
3f5757a0a5 | ||
|
|
7a795f79db | ||
|
|
b3fb0dfdad | ||
|
|
ee29888e18 | ||
|
|
11eaa87849 | ||
|
|
b4faa5616f | ||
|
|
4d6df989f2 | ||
|
|
e585c52187 | ||
|
|
4add88f627 | ||
|
|
aac2fc0ca0 | ||
|
|
005d56eedf | ||
|
|
b6d25825c8 | ||
|
|
799dca8b67 | ||
|
|
3faf413288 | ||
|
|
a3261637de | ||
|
|
b8d738220d | ||
|
|
fb36db450f | ||
|
|
946ceb288d | ||
|
|
8d99ce06aa | ||
|
|
9b3873c4f0 | ||
|
|
e177f041c8 | ||
|
|
a80a85e6bd | ||
|
|
14c9348f93 | ||
|
|
7a1f133f29 | ||
|
|
45a01c6a30 | ||
|
|
09afd2a0cd | ||
|
|
d92c6fed67 | ||
|
|
f10dbef0cf | ||
|
|
8fb2f65d4b | ||
|
|
477f266dc5 | ||
|
|
b14a972cd0 | ||
|
|
562e61235b | ||
|
|
9bd9ac4a37 | ||
|
|
f89f4ac152 | ||
|
|
54dd3ae90c | ||
|
|
35d2963069 | ||
|
|
1f5f2f5c97 | ||
|
|
c64db917f5 | ||
|
|
9d476aab8b | ||
|
|
5067e1f5bc | ||
|
|
8a0c5680e0 | ||
|
|
61699d1d66 | ||
|
|
94c776182e | ||
|
|
e3d3500db8 | ||
|
|
d0e05c4b66 | ||
|
|
58d960a47c | ||
|
|
dd187df667 | ||
|
|
b71ea349d2 |
@@ -1 +1,4 @@
|
||||
node: $Format:%H$
|
||||
node-date: $Format:%cI$
|
||||
describe-name: $Format:%(describe:tags=true)$
|
||||
ref-names: $Format:%D$
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: DVCorg
|
||||
@@ -11,12 +11,12 @@ jobs:
|
||||
os: [ubuntu-18.04]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.9.1
|
||||
uses: styfle/cancel-workflow-action@0.10.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/setup-python@v3
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: 3.8
|
||||
- uses: iterative/dvc-bench@main
|
||||
with:
|
||||
pytest_options: "-k 'test_init or test_help'"
|
||||
|
||||
+54
-114
@@ -6,152 +6,92 @@ on:
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deb:
|
||||
runs-on: ubuntu-18.04
|
||||
binary:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- {pkg: "deb", os: "ubuntu-18.04", asset: "dvc_${{ github.event.release.tag_name }}_amd64.deb"}
|
||||
- {pkg: "rpm", os: "ubuntu-18.04", asset: "dvc-${{ github.event.release.tag_name }}-1.x86_64.rpm"}
|
||||
- {pkg: "osxpkg", os: "macos-10.15", asset: "dvc-${{ github.event.release.tag_name }}.pkg"}
|
||||
- {pkg: "exe", os: "windows-2019", asset: "dvc-${{ github.event.release.tag_name }}.exe"}
|
||||
|
||||
name: ${{ matrix.pkg }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
uses: actions/[email protected]
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
setup.cfg
|
||||
scripts/build-requirements.txt
|
||||
|
||||
- name: Set up Ruby 2.6
|
||||
uses: ruby/setup-ruby@v1
|
||||
if: matrix.pkg != 'exe'
|
||||
with:
|
||||
ruby-version: 2.4
|
||||
- name: Install deps
|
||||
run: |
|
||||
pip install .[all]
|
||||
pip install -r scripts/build-requirements.txt
|
||||
gem install --no-document fpm
|
||||
- name: Build deb
|
||||
run: python scripts/build.py deb
|
||||
- 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: scripts/fpm/dvc_${{ github.event.release.tag_name }}_amd64.deb
|
||||
asset_name: dvc_${{ github.event.release.tag_name }}_amd64.deb
|
||||
asset_content_type: binary/octet-stream
|
||||
rpm:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
ruby-version: 2.4
|
||||
- name: Install deps
|
||||
run: |
|
||||
pip install .[all]
|
||||
pip install -r scripts/build-requirements.txt
|
||||
gem install --no-document fpm
|
||||
- name: Build rpm
|
||||
run: python scripts/build.py rpm
|
||||
- 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: scripts/fpm/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
|
||||
osxpkg:
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
- name: Set up Ruby 2.4
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
ruby-version: 2.4
|
||||
- name: Install deps
|
||||
run: |
|
||||
# available lxml (webdav dependency) wheels are built with an old
|
||||
# SDK, which breaks osxpkg notarization. Building from scratch
|
||||
# instead.
|
||||
pip install .[all] --no-binary lxml
|
||||
pip install -r scripts/build-requirements.txt
|
||||
gem install --no-document fpm
|
||||
- name: Build
|
||||
run: python scripts/build.py osxpkg
|
||||
- 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: scripts/fpm/dvc-${{ github.event.release.tag_name }}.pkg
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}.pkg
|
||||
asset_content_type: binary/octet-stream
|
||||
exe:
|
||||
runs-on: windows-2019
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.9
|
||||
ruby-version: '2.6'
|
||||
|
||||
- name: Install fpm
|
||||
if: matrix.pkg != 'exe'
|
||||
run: gem install --no-document fpm
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
pip install -U wheel pip setuptools
|
||||
pip install .[all]
|
||||
pip install -r scripts/build-requirements.txt
|
||||
|
||||
- name: Pull images
|
||||
run: |
|
||||
dvc pull
|
||||
- name: Build
|
||||
run: python scripts\build.py exe
|
||||
shell: cmd
|
||||
- name: Publish
|
||||
run: dvc pull
|
||||
|
||||
- name: Build ${{ matrix.pkg }}
|
||||
run: python scripts/build.py ${{ matrix.pkg }}
|
||||
|
||||
- name: Publish ${{ matrix.pkg }}
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: scripts/innosetup/dvc-${{ github.event.release.tag_name }}.exe
|
||||
asset_name: dvc-${{ github.event.release.tag_name }}.exe
|
||||
asset_path: scripts/${{ matrix.pkg == 'exe' && 'innosetup' || 'fpm' }}/${{ matrix.asset }}
|
||||
asset_name: ${{ matrix.asset }}
|
||||
asset_content_type: binary/octet-stream
|
||||
|
||||
pip:
|
||||
runs-on: ubuntu-18.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.7
|
||||
uses: actions/setup-python@v3
|
||||
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.7
|
||||
python-version: 3.8
|
||||
cache: pip
|
||||
cache-dependency-path: setup.cfg
|
||||
|
||||
- name: Install
|
||||
run: pip install ".[all,tests]"
|
||||
run: |
|
||||
pip install -U wheel pip build setuptools
|
||||
pip install ".[all,tests]"
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
pip install -U pip
|
||||
./scripts/build_package.sh
|
||||
|
||||
- name: Publish packages to PyPI
|
||||
if: github.event_name == 'release'
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
@@ -17,14 +17,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.9.1
|
||||
uses: styfle/cancel-workflow-action@0.10.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
- name: Install requirements
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
pip install ".[dev]" pre-commit
|
||||
- name: Check README
|
||||
run: python setup.py checkdocs
|
||||
- uses: pre-commit/action@v2.0.3
|
||||
- uses: pre-commit/action@v3.0.0
|
||||
tests:
|
||||
timeout-minutes: 45
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -42,13 +42,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-20.04, windows-latest, macos-latest]
|
||||
pyv: ["3.7", "3.8", "3.9", "3.10"]
|
||||
pyv: ["3.8", "3.9", "3.10"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.pyv }}
|
||||
- name: get pip cache dir
|
||||
|
||||
@@ -6,9 +6,9 @@ repos:
|
||||
- id: black
|
||||
language_version: python3
|
||||
repo: https://github.com/ambv/black
|
||||
rev: 22.3.0
|
||||
rev: 22.6.0
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.2.0
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
|
||||
+128
-138
@@ -3,97 +3,114 @@
|
||||
`Website <https://dvc.org>`_
|
||||
• `Docs <https://dvc.org/doc>`_
|
||||
• `Blog <http://blog.dataversioncontrol.com>`_
|
||||
• `Twitter <https://twitter.com/DVCorg>`_
|
||||
• `Chat (Community & Support) <https://dvc.org/chat>`_
|
||||
• `Tutorial <https://dvc.org/doc/get-started>`_
|
||||
• `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
|
||||
• `Related Technologies <https://dvc.org/doc/user-guide/related-technologies>`_
|
||||
• `How DVC works`_
|
||||
• `VS Code Extension`_
|
||||
• `Installation`_
|
||||
• `Contributing`_
|
||||
• `Community and Support`_
|
||||
|
||||
|CI| |Maintainability| |Coverage| |Donate| |DOI|
|
||||
|CI| |Python Version| |Coverage| |VS Code| |DOI|
|
||||
|
||||
|PyPI| |Packages| |Brew| |Conda| |Choco| |Snap|
|
||||
|
||||
|
|
||||
|
||||
**Data Version Control** or **DVC** is an **open-source** tool for data science and machine
|
||||
learning projects. Key features:
|
||||
**Data Version Control** or **DVC** is a command line tool and `VS Code Extension`_ to help you develop reproducible machine learning projects:
|
||||
|
||||
#. Simple **command line** Git-like experience. Does not require installing and maintaining
|
||||
any databases. Does not depend on any proprietary online services.
|
||||
#. **Version** your data and models.
|
||||
Store them in your cloud storage but keep their version info in your Git repo.
|
||||
|
||||
#. Management and versioning of **datasets** and **machine learning
|
||||
models**. Data can be saved in S3, Google cloud, Azure, Alibaba
|
||||
cloud, SSH server, HDFS, or even local HDD RAID.
|
||||
#. **Iterate** fast with lightweight pipelines.
|
||||
When you make changes, only run the steps impacted by those changes.
|
||||
|
||||
#. Makes projects **reproducible** and **shareable**; helping to answer questions about how
|
||||
a model was built.
|
||||
#. **Track** experiments in your local Git repo (no servers needed).
|
||||
|
||||
#. Helps manage experiments with Git tags/branches and **metrics** tracking.
|
||||
#. **Compare** any data, code, parameters, model, or performance plots.
|
||||
|
||||
**DVC** aims to replace spreadsheet and document sharing tools (such as Excel or Google Docs)
|
||||
frequently used as both knowledge repositories and team ledgers.
|
||||
DVC also replaces both ad-hoc scripts to track, move, and deploy different model versions
|
||||
and ad-hoc data file suffixes and prefixes.
|
||||
|
||||
.. contents:: **Contents**
|
||||
:backlinks: none
|
||||
|
||||
How DVC works
|
||||
=============
|
||||
|
||||
We encourage you to read our `Get Started <https://dvc.org/doc/get-started>`_ guide to better understand what DVC
|
||||
is and how it can fit your scenarios.
|
||||
|
||||
The easiest (but not perfect!) *analogy* to describe it: DVC is Git (or Git-LFS to be precise) & Makefiles
|
||||
made right and tailored specifically for ML and Data Science scenarios.
|
||||
|
||||
#. ``Git/Git-LFS`` part - DVC helps store and share data artifacts and models, connecting them with a Git repository.
|
||||
#. ``Makefile``\ s part - DVC describes how one data or model artifact was built from other data and code.
|
||||
|
||||
DVC usually runs along with Git. Git is used as usual to store and version code (including DVC meta-files). DVC helps
|
||||
to store data and model files seamlessly out of Git, while preserving almost the same user experience as if they
|
||||
were stored in Git itself. To store and share the data cache, DVC supports multiple remotes - any cloud (S3, Azure,
|
||||
Google Cloud, etc) or any on-premise network storage (via SSH, for example).
|
||||
|
||||
|Flowchart|
|
||||
|
||||
The DVC pipelines (computational graph) feature connects code and data together. It is possible to explicitly
|
||||
specify all steps required to produce a model: input dependencies including data, commands to run,
|
||||
and output information to be saved. See the quick start section below or
|
||||
the `Get Started <https://dvc.org/doc/get-started>`_ tutorial to learn more.
|
||||
#. **Share** experiments and automatically reproduce anyone's experiment.
|
||||
|
||||
Quick start
|
||||
===========
|
||||
|
||||
Please read `Get Started <https://dvc.org/doc/get-started>`_ guide for a full version. Common workflow commands include:
|
||||
Please read our `Command Reference <https://dvc.org/doc/command-reference>`_ for a complete list.
|
||||
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| Step | Command |
|
||||
+===================================+============================================================================+
|
||||
| Track data | | ``$ git add train.py`` |
|
||||
| | | ``$ dvc add images.zip`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| Connect code and data by commands | | ``$ dvc run -n prepare -d images.zip -o images/ unzip -q images.zip`` |
|
||||
| | | ``$ dvc run -n train -d images/ -d train.py -o model.p python train.py`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| Make changes and reproduce | | ``$ vi train.py`` |
|
||||
| | | ``$ dvc repro model.p.dvc`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| Share code | | ``$ git add .`` |
|
||||
| | | ``$ git commit -m 'The baseline model'`` |
|
||||
| | | ``$ git push`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
|
||||
| | | ``$ dvc push`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------+
|
||||
A common CLI workflow includes:
|
||||
|
||||
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Task | Terminal |
|
||||
+===================================+====================================================================================================+
|
||||
| Track data | | ``$ git add train.py params.yaml`` |
|
||||
| | | ``$ dvc add images/`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Connect code and data | | ``$ dvc stage add -n featurize -d images/ -o features/ python featurize.py`` |
|
||||
| | | ``$ dvc stage add -n train -d features/ -d train.py -o model.p -M metrics.json python train.py`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Make changes and experiment | | ``$ dvc exp run -n exp-baseline`` |
|
||||
| | | ``$ vi train.py`` |
|
||||
| | | ``$ dvc exp run -n exp-code-change`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Compare and select experiments | | ``$ dvc exp show`` |
|
||||
| | | ``$ dvc exp apply exp-baseline`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Share code | | ``$ git add .`` |
|
||||
| | | ``$ git commit -m 'The baseline model'`` |
|
||||
| | | ``$ git push`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
|
||||
| | | ``$ dvc push`` |
|
||||
+-----------------------------------+----------------------------------------------------------------------------------------------------+
|
||||
|
||||
How DVC works
|
||||
=============
|
||||
|
||||
We encourage you to read our `Get Started
|
||||
<https://dvc.org/doc/get-started>`_ docs to better understand what DVC
|
||||
does and how it can fit your scenarios.
|
||||
|
||||
The closest *analogies* to describe the main DVC features are these:
|
||||
|
||||
#. **Git for data**: Store and share data artifacts (like Git-LFS but without a server) and models, connecting them with a Git repository. Data management meets GitOps!
|
||||
#. **Makefiles** for ML: Describes how data or model artifacts are built from other data and code in a standard format. Now you can version your data pipelines with Git.
|
||||
#. Local **experiment tracking**: Turn your machine into an ML experiment management platform, and collaborate with others using existing Git hosting (Github, Gitlab, etc.).
|
||||
|
||||
Git is employed as usual to store and version code (including DVC meta-files as placeholders for data).
|
||||
DVC `stores data and model files <https://dvc.org/doc/start/data-management>`_ seamlessly in a cache outside of Git, while preserving almost the same user experience as if they were in the repo.
|
||||
To share and back up the *data cache*, DVC supports multiple remote storage platforms - any cloud (S3, Azure, Google Cloud, etc.) or on-premise network storage (via SSH, for example).
|
||||
|
||||
|Flowchart|
|
||||
|
||||
`DVC pipelines <https://dvc.org/doc/start/data-management/pipelines>`_ (computational graphs) connect code and data together.
|
||||
They specify all steps required to produce a model: input dependencies including code, data, commands to run; and output information to be saved.
|
||||
|
||||
Last but not least, `DVC Experiment Versioning <https://dvc.org/doc/start/experiments>`_ lets you prepare and run a large number of experiments.
|
||||
Their results can be filtered and compared based on hyperparameters and metrics, and visualized with multiple plots.
|
||||
|
||||
.. _`VS Code Extension`:
|
||||
|
||||
Visual Studio Code Extension
|
||||
============================
|
||||
|
||||
|VS Code|
|
||||
|
||||
To use DVC as a GUI right from your VS Code IDE, install the `DVC Extension <https://marketplace.visualstudio.com/items?itemName=Iterative.dvc>`_ from the Marketplace.
|
||||
It currently features experiment tracking and data management, and more features (data pipeline support, etc.) are coming soon!
|
||||
|
||||
|VS Code Extension Overview|
|
||||
|
||||
Note: You'll have to install core DVC on your system separately (as detailed
|
||||
below). The Extension will guide you if needed.
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
There are four options to install DVC: ``pip``, Homebrew, Conda (Anaconda) or an OS-specific package.
|
||||
There are several ways to install DVC: in VS Code; using ``snap``, ``choco``, ``brew``, ``conda``, ``pip``; or with an OS-specific package.
|
||||
Full instructions are `available here <https://dvc.org/doc/get-started/install>`_.
|
||||
|
||||
Snap (Snapcraft/Linux)
|
||||
----------------------
|
||||
Snapcraft (Linux)
|
||||
-----------------
|
||||
|
||||
|Snap|
|
||||
|
||||
@@ -102,11 +119,10 @@ Snap (Snapcraft/Linux)
|
||||
snap install dvc --classic
|
||||
|
||||
This corresponds to the latest tagged release.
|
||||
Add ``--beta`` for the latest tagged release candidate,
|
||||
or ``--edge`` for the latest ``main`` version.
|
||||
Add ``--beta`` for the latest tagged release candidate, or ``--edge`` for the latest ``main`` version.
|
||||
|
||||
Choco (Chocolatey/Windows)
|
||||
--------------------------
|
||||
Chocolatey (Windows)
|
||||
--------------------
|
||||
|
||||
|Choco|
|
||||
|
||||
@@ -114,8 +130,8 @@ Choco (Chocolatey/Windows)
|
||||
|
||||
choco install dvc
|
||||
|
||||
Brew (Homebrew/Mac OS)
|
||||
----------------------
|
||||
Brew (mac OS)
|
||||
-------------
|
||||
|
||||
|Brew|
|
||||
|
||||
@@ -123,8 +139,8 @@ Brew (Homebrew/Mac OS)
|
||||
|
||||
brew install dvc
|
||||
|
||||
Conda (Anaconda)
|
||||
----------------
|
||||
Anaconda (Any platform)
|
||||
-----------------------
|
||||
|
||||
|Conda|
|
||||
|
||||
@@ -133,11 +149,10 @@ Conda (Anaconda)
|
||||
conda install -c conda-forge mamba # installs much faster than conda
|
||||
mamba install -c conda-forge dvc
|
||||
|
||||
Depending on the remote storage type you plan to use to keep and share your data, you might need to
|
||||
install optional dependencies: `dvc-s3`, `dvc-azure`, `dvc-gdrive`, `dvc-gs`, `dvc-oss`, `dvc-ssh`.
|
||||
Depending on the remote storage type you plan to use to keep and share your data, you might need to install optional dependencies: `dvc-s3`, `dvc-azure`, `dvc-gdrive`, `dvc-gs`, `dvc-oss`, `dvc-ssh`.
|
||||
|
||||
pip (PyPI)
|
||||
----------
|
||||
PyPI (Python)
|
||||
-------------
|
||||
|
||||
|PyPI|
|
||||
|
||||
@@ -145,10 +160,8 @@ pip (PyPI)
|
||||
|
||||
pip install dvc
|
||||
|
||||
Depending on the remote storage type you plan to use to keep and share your data, you might need to specify
|
||||
one of the optional dependencies: ``s3``, ``gs``, ``azure``, ``oss``, ``ssh``. Or ``all`` to include them all.
|
||||
The command should look like this: ``pip install dvc[s3]`` (in this case AWS S3 dependencies such as ``boto3``
|
||||
will be installed automatically).
|
||||
Depending on the remote storage type you plan to use to keep and share your data, you might need to specify one of the optional dependencies: ``s3``, ``gs``, ``azure``, ``oss``, ``ssh``. Or ``all`` to include them all.
|
||||
The command should look like this: ``pip install 'dvc[s3]'`` (in this case AWS S3 dependencies such as ``boto3`` will be installed automatically).
|
||||
|
||||
To install the development version, run:
|
||||
|
||||
@@ -156,88 +169,58 @@ To install the development version, run:
|
||||
|
||||
pip install git+git://github.com/iterative/dvc
|
||||
|
||||
Package
|
||||
-------
|
||||
Package (Platform-specific)
|
||||
---------------------------
|
||||
|
||||
|Packages|
|
||||
|
||||
Self-contained packages for Linux, Windows, and Mac are available. The latest version of the packages
|
||||
can be found on the GitHub `releases page <https://github.com/iterative/dvc/releases>`_.
|
||||
Self-contained packages for Linux, Windows, and Mac are available.
|
||||
The latest version of the packages can be found on the GitHub `releases page <https://github.com/iterative/dvc/releases>`_.
|
||||
|
||||
Ubuntu / Debian (deb)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
.. code-block:: bash
|
||||
|
||||
sudo wget https://dvc.org/deb/dvc.list -O /etc/apt/sources.list.d/dvc.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install dvc
|
||||
wget -qO - https://dvc.org/deb/iterative.asc | sudo apt-key add -
|
||||
sudo apt update
|
||||
sudo apt install dvc
|
||||
|
||||
Fedora / CentOS (rpm)
|
||||
^^^^^^^^^^^^^^^^^^^^^
|
||||
.. code-block:: bash
|
||||
|
||||
sudo wget https://dvc.org/rpm/dvc.repo -O /etc/yum.repos.d/dvc.repo
|
||||
sudo rpm --import https://dvc.org/rpm/iterative.asc
|
||||
sudo yum update
|
||||
sudo yum install dvc
|
||||
|
||||
Comparison to related technologies
|
||||
==================================
|
||||
|
||||
#. Data Engineering tools such as `AirFlow <https://airflow.apache.org/>`_,
|
||||
`Luigi <https://github.com/spotify/luigi>`_, and others - in DVC data,
|
||||
model and ML pipelines represent a single ML project focused on data
|
||||
scientists' experience. Data engineering tools orchestrate multiple data
|
||||
projects and focus on efficient execution. A DVC project can be used from
|
||||
existing data pipelines as a single execution step.
|
||||
|
||||
#. `Git-annex <https://git-annex.branchable.com/>`_ - DVC uses the idea of storing the content of large files (which should
|
||||
not be in a Git repository) in a local key-value store, and uses file hardlinks/symlinks instead of
|
||||
copying/duplicating files.
|
||||
|
||||
#. `Git-LFS <https://git-lfs.github.com/>`_ - DVC is compatible with many
|
||||
remote storage services (S3, Google Cloud, Azure, SSH, etc). DVC also
|
||||
uses reflinks or hardlinks to avoid copy operations on checkouts; thus
|
||||
handling large data files much more efficiently.
|
||||
|
||||
#. Makefile (and analogues including ad-hoc scripts) - DVC tracks
|
||||
dependencies (in a directed acyclic graph).
|
||||
|
||||
#. `Workflow Management Systems <https://en.wikipedia.org/wiki/Workflow_management_system>`_ - DVC is a workflow
|
||||
management system designed specifically to manage machine learning experiments. DVC is built on top of Git.
|
||||
|
||||
#. `DAGsHub <https://dagshub.com/>`_ - online service to host DVC
|
||||
projects. It provides a useful UI around DVC repositories and integrates
|
||||
other tools.
|
||||
|
||||
#. `DVC Studio <https://studio.iterative.ai/>`_ - official online
|
||||
platform for DVC projects. It can be used to manage data and models, run
|
||||
and track experiments, and visualize and share results. Also, it
|
||||
integrates with `CML (CI/CD for ML) <https://cml.dev/>`__ for training
|
||||
models in the cloud or Kubernetes.
|
||||
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
|Maintainability| |Donate|
|
||||
|Maintainability|
|
||||
|
||||
Contributions are welcome! Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more
|
||||
details. Thanks to all our contributors!
|
||||
Contributions are welcome!
|
||||
Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more details.
|
||||
Thanks to all our contributors!
|
||||
|
||||
|Contribs|
|
||||
|
||||
Mailing List
|
||||
============
|
||||
Community and Support
|
||||
=====================
|
||||
|
||||
Want to stay up to date? Want to help improve DVC by participating in our occasional polls? Subscribe to our `mailing list <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_. No spam, really low traffic.
|
||||
* `Twitter <https://twitter.com/DVCorg>`_
|
||||
* `Forum <https://discuss.dvc.org/>`_
|
||||
* `Discord Chat <https://dvc.org/chat>`_
|
||||
* `Email <mailto:[email protected]>`_
|
||||
* `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
|
||||
|
||||
Copyright
|
||||
=========
|
||||
|
||||
This project is distributed under the Apache license version 2.0 (see the LICENSE file in the project root).
|
||||
|
||||
By submitting a pull request to this project, you agree to license your contribution under the Apache license version
|
||||
2.0 to this project.
|
||||
By submitting a pull request to this project, you agree to license your contribution under the Apache license version 2.0 to this project.
|
||||
|
||||
Citation
|
||||
========
|
||||
@@ -254,6 +237,9 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
|
||||
:target: https://dvc.org
|
||||
:alt: DVC logo
|
||||
|
||||
.. |VS Code Extension Overview| image:: https://raw.githubusercontent.com/iterative/vscode-dvc/main/extension/docs/overview.gif
|
||||
:alt: DVC Extension for VS Code
|
||||
|
||||
.. |CI| image:: https://github.com/iterative/dvc/workflows/Tests/badge.svg?branch=main
|
||||
:target: https://github.com/iterative/dvc/actions
|
||||
:alt: GHA Tests
|
||||
@@ -262,14 +248,14 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
|
||||
:target: https://codeclimate.com/github/iterative/dvc
|
||||
:alt: Code Climate
|
||||
|
||||
.. |Python Version| image:: https://img.shields.io/pypi/pyversions/dvc
|
||||
:target: https://pypi.org/project/dvc
|
||||
:alt: Python Version
|
||||
|
||||
.. |Coverage| image:: https://codecov.io/gh/iterative/dvc/branch/main/graph/badge.svg
|
||||
:target: https://codecov.io/gh/iterative/dvc
|
||||
:alt: Codecov
|
||||
|
||||
.. |Donate| image:: https://img.shields.io/badge/patreon-donate-green.svg?logo=patreon
|
||||
:target: https://www.patreon.com/DVCorg/overview
|
||||
:alt: Donate
|
||||
|
||||
.. |Snap| image:: https://img.shields.io/badge/snap-install-82BEA0.svg?logo=snapcraft
|
||||
:target: https://snapcraft.io/dvc
|
||||
:alt: Snapcraft
|
||||
@@ -305,3 +291,7 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
|
||||
.. |Contribs| image:: https://contrib.rocks/image?repo=iterative/dvc
|
||||
:target: https://github.com/iterative/dvc/graphs/contributors
|
||||
:alt: Contributors
|
||||
|
||||
.. |VS Code| image:: https://vsmarketplacebadge.apphb.com/version/Iterative.dvc.svg
|
||||
:target: https://marketplace.visualstudio.com/items?itemName=Iterative.dvc
|
||||
:alt: VS Code Extension
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ def is_enabled():
|
||||
Config(validate=False).get("core", {}).get("analytics", "true")
|
||||
)
|
||||
|
||||
logger.debug("Analytics is {}abled.".format("en" if enabled else "dis"))
|
||||
logger.debug("Analytics is %sabled.", "en" if enabled else "dis")
|
||||
|
||||
return enabled
|
||||
|
||||
@@ -187,4 +187,4 @@ def _find_or_create_user_id():
|
||||
return user_id
|
||||
|
||||
except LockError:
|
||||
logger.debug(f"Failed to acquire '{lockfile}'")
|
||||
logger.debug("Failed to acquire '%s'", lockfile)
|
||||
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
import os
|
||||
from contextlib import _GeneratorContextManager as GCM
|
||||
|
||||
from funcy import reraise
|
||||
|
||||
from dvc.exceptions import OutputNotFoundError, PathMissingError
|
||||
from dvc.repo import Repo
|
||||
|
||||
|
||||
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 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 Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
|
||||
with reraise(FileNotFoundError, PathMissingError(path, repo)):
|
||||
info = _repo.repo_fs.info(path)
|
||||
|
||||
dvc_info = info.get("dvc_info")
|
||||
if not dvc_info:
|
||||
raise OutputNotFoundError(path, repo)
|
||||
|
||||
dvc_repo = info["repo"]
|
||||
md5 = dvc_info["md5"]
|
||||
|
||||
return dvc_repo.cloud.get_url_for(remote, checksum=md5)
|
||||
|
||||
|
||||
def open( # noqa, pylint: disable=redefined-builtin
|
||||
path, repo=None, rev=None, remote=None, mode="r", encoding=None
|
||||
):
|
||||
"""
|
||||
Open file in the supplied path tracked in a repo (both DVC projects and
|
||||
plain Git repos are supported). For Git repos, HEAD is used unless a rev
|
||||
argument is supplied. The default remote is tried unless a remote argument
|
||||
is supplied. It may only be used as a context manager:
|
||||
|
||||
with dvc.api.open(
|
||||
'path/to/file',
|
||||
repo='https://example.com/url/to/repo'
|
||||
) as fd:
|
||||
# ... Handle file object fd
|
||||
"""
|
||||
args = (path,)
|
||||
kwargs = {
|
||||
"repo": repo,
|
||||
"remote": remote,
|
||||
"rev": rev,
|
||||
"mode": mode,
|
||||
"encoding": encoding,
|
||||
}
|
||||
return _OpenContextManager(_open, args, kwargs)
|
||||
|
||||
|
||||
class _OpenContextManager(GCM):
|
||||
def __init__(
|
||||
self, func, args, kwds
|
||||
): # pylint: disable=super-init-not-called
|
||||
self.gen = func(*args, **kwds)
|
||||
self.func, self.args, self.kwds = func, args, kwds
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AttributeError(
|
||||
"dvc.api.open() should be used in a with statement."
|
||||
)
|
||||
|
||||
|
||||
def _open(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
|
||||
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
|
||||
with _repo.open_by_relpath(
|
||||
path, remote=remote, mode=mode, encoding=encoding
|
||||
) as fd:
|
||||
yield fd
|
||||
|
||||
|
||||
def read(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
|
||||
"""
|
||||
Returns the contents of a tracked file (by DVC or Git). For Git repos, HEAD
|
||||
is used unless a rev argument is supplied. The default remote is tried
|
||||
unless a remote argument is supplied.
|
||||
"""
|
||||
with open(
|
||||
path, repo=repo, rev=rev, remote=remote, mode=mode, encoding=encoding
|
||||
) as fd:
|
||||
return fd.read()
|
||||
|
||||
|
||||
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.monitor import CheckpointTask
|
||||
|
||||
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", CheckpointTask.SIGNAL_FILE
|
||||
)
|
||||
|
||||
with builtins.open(signal_file, "w", encoding="utf-8") 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(0.1)
|
||||
@@ -0,0 +1,9 @@
|
||||
from .data import ( # noqa, pylint: disable=redefined-builtin
|
||||
get_url,
|
||||
open,
|
||||
read,
|
||||
)
|
||||
from .experiments import make_checkpoint
|
||||
from .params import params_show
|
||||
|
||||
__all__ = ["get_url", "make_checkpoint", "open", "params_show", "read"]
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
from contextlib import _GeneratorContextManager as GCM
|
||||
from typing import Optional
|
||||
|
||||
from funcy import reraise
|
||||
|
||||
from dvc.exceptions import OutputNotFoundError, PathMissingError
|
||||
from dvc.repo import Repo
|
||||
|
||||
|
||||
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 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 Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
|
||||
fs_path = _repo.dvcfs.from_os_path(path)
|
||||
with reraise(FileNotFoundError, PathMissingError(path, repo)):
|
||||
info = _repo.dvcfs.info(fs_path)
|
||||
|
||||
dvc_info = info.get("dvc_info")
|
||||
if not dvc_info:
|
||||
raise OutputNotFoundError(path, repo)
|
||||
|
||||
dvc_repo = info["repo"]
|
||||
md5 = dvc_info["md5"]
|
||||
|
||||
return dvc_repo.cloud.get_url_for(remote, checksum=md5)
|
||||
|
||||
|
||||
class _OpenContextManager(GCM):
|
||||
def __init__(
|
||||
self, func, args, kwds
|
||||
): # pylint: disable=super-init-not-called
|
||||
self.gen = func(*args, **kwds)
|
||||
self.func, self.args, self.kwds = func, args, kwds
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise AttributeError(
|
||||
"dvc.api.open() should be used in a with statement."
|
||||
)
|
||||
|
||||
|
||||
def open( # noqa, pylint: disable=redefined-builtin
|
||||
path: str,
|
||||
repo: Optional[str] = None,
|
||||
rev: Optional[str] = None,
|
||||
remote: Optional[str] = None,
|
||||
mode: str = "r",
|
||||
encoding: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Opens a file tracked in a DVC project.
|
||||
|
||||
This function may only be used as a context manager (using the `with`
|
||||
keyword, as shown in the examples).
|
||||
|
||||
This function makes a direct connection to the remote storage, so the file
|
||||
contents can be streamed. Your code can process the data buffer as it's
|
||||
streamed, which optimizes memory usage.
|
||||
|
||||
Note:
|
||||
Use dvc.api.read() to load the complete file contents
|
||||
in a single function call, no context manager involved.
|
||||
Neither function utilizes disc space.
|
||||
|
||||
Args:
|
||||
path (str): location and file name of the target to open,
|
||||
relative to the root of `repo`.
|
||||
repo (str, optional): location of the DVC project or Git Repo.
|
||||
Defaults to the current DVC project (found by walking up from the
|
||||
current working directory tree).
|
||||
It can be a URL or a file system path.
|
||||
Both HTTP and SSH protocols are supported for online Git repos
|
||||
(e.g. [user@]server:project.git).
|
||||
rev (str, optional): Any `Git revision`_ such as a branch or tag name,
|
||||
a commit hash or a dvc experiment name.
|
||||
Defaults to HEAD.
|
||||
If `repo` is not a Git repo, this option is ignored.
|
||||
remote (str, optional): Name of the `DVC remote`_ used to form the
|
||||
returned URL string.
|
||||
Defaults to the `default remote`_ of `repo`.
|
||||
For local projects, the cache is tried before the default remote.
|
||||
mode (str, optional): Specifies the mode in which the file is opened.
|
||||
Defaults to "r" (read).
|
||||
Mirrors the namesake parameter in builtin `open()`_.
|
||||
Only reading `mode` is supported.
|
||||
encoding(str, optional): `Codec`_ used to decode the file contents.
|
||||
Defaults to None.
|
||||
This should only be used in text mode.
|
||||
Mirrors the namesake parameter in builtin `open()`_.
|
||||
|
||||
Returns:
|
||||
_OpenContextManager: A context manager that generatse a corresponding
|
||||
`file object`_.
|
||||
The exact type of file object depends on the mode used.
|
||||
For more details, please refer to Python's `open()`_ built-in,
|
||||
which is used under the hood.
|
||||
|
||||
Raises:
|
||||
AttributeError: If this method is not used as a context manager.
|
||||
ValueError: If non-read `mode` is used.
|
||||
|
||||
Examples:
|
||||
|
||||
- Use data or models from a DVC repository.
|
||||
|
||||
Any file tracked in a DVC project (and stored remotely) can be
|
||||
processed directly in your Python code with this API.
|
||||
For example, an XML file tracked in a public DVC repo on GitHub can be
|
||||
processed like this:
|
||||
|
||||
>>> from xml.sax import parse
|
||||
>>> import dvc.api
|
||||
>>> from mymodule import mySAXHandler
|
||||
|
||||
>>> with dvc.api.open(
|
||||
... 'get-started/data.xml',
|
||||
... repo='https://github.com/iterative/dataset-registry'
|
||||
... ) as fd:
|
||||
... parse(fd, mySAXHandler)
|
||||
|
||||
We use a SAX XML parser here because dvc.api.open() is able to stream
|
||||
the data from remote storage.
|
||||
The mySAXHandler object should handle the event-driven parsing of the
|
||||
document in this case.
|
||||
This increases the performance of the code (minimizing memory usage),
|
||||
and is typically faster than loading the whole data into memory.
|
||||
|
||||
- Accessing private repos
|
||||
|
||||
This is just a matter of using the right repo argument, for example an
|
||||
SSH URL (requires that the credentials are configured locally):
|
||||
|
||||
>>> import dvc.api
|
||||
|
||||
>>> with dvc.api.open(
|
||||
... 'features.dat',
|
||||
... repo='[email protected]:path/to/repo.git'
|
||||
... ) as fd:
|
||||
... # ... Process 'features'
|
||||
... pass
|
||||
|
||||
- Use different versions of data
|
||||
|
||||
Any git revision (see `rev`) can be accessed programmatically.
|
||||
For example, if your DVC repo has tagged releases of a CSV dataset:
|
||||
|
||||
>>> import csv
|
||||
>>> import dvc.api
|
||||
>>> with dvc.api.open(
|
||||
... 'clean.csv',
|
||||
... rev='v1.1.0'
|
||||
... ) as fd:
|
||||
... reader = csv.reader(fd)
|
||||
... # ... Process 'clean' data from version 1.1.0
|
||||
|
||||
.. _Git revision:
|
||||
https://git-scm.com/docs/revisions
|
||||
|
||||
.. _DVC remote:
|
||||
https://dvc.org/doc/command-reference/remote
|
||||
|
||||
.. _default remote:
|
||||
https://dvc.org/doc/command-reference/remote/default
|
||||
|
||||
.. _open():
|
||||
https://docs.python.org/3/library/functions.html#open
|
||||
|
||||
.. _Codec:
|
||||
https://docs.python.org/3/library/codecs.html#standard-encodings
|
||||
|
||||
.. _file object:
|
||||
https://docs.python.org/3/glossary.html#term-file-object
|
||||
|
||||
"""
|
||||
if "r" not in mode:
|
||||
raise ValueError("Only reading `mode` is supported.")
|
||||
|
||||
args = (path,)
|
||||
kwargs = {
|
||||
"repo": repo,
|
||||
"remote": remote,
|
||||
"rev": rev,
|
||||
"mode": mode,
|
||||
"encoding": encoding,
|
||||
}
|
||||
return _OpenContextManager(_open, args, kwargs)
|
||||
|
||||
|
||||
def _open(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
|
||||
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
|
||||
with _repo.open_by_relpath(
|
||||
path, remote=remote, mode=mode, encoding=encoding
|
||||
) as fd:
|
||||
yield fd
|
||||
|
||||
|
||||
def read(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
|
||||
"""
|
||||
Returns the contents of a tracked file (by DVC or Git). For Git repos, HEAD
|
||||
is used unless a rev argument is supplied. The default remote is tried
|
||||
unless a remote argument is supplied.
|
||||
"""
|
||||
with open(
|
||||
path, repo=repo, rev=rev, remote=remote, mode=mode, encoding=encoding
|
||||
) as fd:
|
||||
return fd.read()
|
||||
@@ -0,0 +1,33 @@
|
||||
import builtins
|
||||
import os
|
||||
from time import sleep
|
||||
|
||||
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
|
||||
from dvc.repo import Repo
|
||||
from dvc.stage.monitor import CheckpointTask
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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", CheckpointTask.SIGNAL_FILE
|
||||
)
|
||||
|
||||
with builtins.open(signal_file, "w", encoding="utf-8") 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(0.1)
|
||||
@@ -0,0 +1,274 @@
|
||||
from collections import Counter
|
||||
from typing import Dict, Iterable, Optional, Union
|
||||
|
||||
from funcy import first
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.repo import Repo
|
||||
|
||||
|
||||
def params_show(
|
||||
*targets: str,
|
||||
repo: Optional[str] = None,
|
||||
stages: Optional[Union[str, Iterable[str]]] = None,
|
||||
rev: Optional[str] = None,
|
||||
deps: bool = False,
|
||||
) -> Dict:
|
||||
"""Get parameters tracked in `repo`.
|
||||
|
||||
Without arguments, this function will retrieve all params from all tracked
|
||||
parameter files, for the current working tree.
|
||||
|
||||
See the options below to restrict the parameters retrieved.
|
||||
|
||||
Args:
|
||||
*targets (str, optional): Names of the parameter files to retrieve
|
||||
params from. For example, "params.py, myparams.toml".
|
||||
If no `targets` are provided, all parameter files tracked in `dvc.yaml`
|
||||
will be used.
|
||||
Note that targets don't necessarily have to be defined in `dvc.yaml`.
|
||||
repo (str, optional): location of the DVC repository.
|
||||
Defaults to the current project (found by walking up from the
|
||||
current working directory tree).
|
||||
It can be a URL or a file system path.
|
||||
Both HTTP and SSH protocols are supported for online Git repos
|
||||
(e.g. [user@]server:project.git).
|
||||
stages (Union[str, Iterable[str]], optional): Name or names of the
|
||||
stages to retrieve parameters from.
|
||||
Defaults to `None`.
|
||||
If `None`, all parameters from all stages will be retrieved.
|
||||
rev (str, optional): Name of the `Git revision`_ to retrieve parameters
|
||||
from.
|
||||
Defaults to `None`.
|
||||
An example of git revision can be a branch or tag name, a commit
|
||||
hash or a dvc experiment name.
|
||||
If `repo` is not a Git repo, this option is ignored.
|
||||
If `None`, the current working tree will be used.
|
||||
deps (bool, optional): Whether to retrieve only parameters that are
|
||||
stage dependencies or not.
|
||||
Defaults to `False`.
|
||||
|
||||
Returns:
|
||||
Dict: See Examples below.
|
||||
|
||||
Raises:
|
||||
DvcException: If no params are found in `repo`.
|
||||
|
||||
Examples:
|
||||
|
||||
- No arguments.
|
||||
|
||||
Working on https://github.com/iterative/example-get-started
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show()
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"prepare": {
|
||||
"split": 0.2,
|
||||
"seed": 20170428
|
||||
},
|
||||
"featurize": {
|
||||
"max_features": 200,
|
||||
"ngrams": 2
|
||||
},
|
||||
"train": {
|
||||
"seed": 20170428,
|
||||
"n_est": 50,
|
||||
"min_split": 0.01
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
- Filtering with `stages`.
|
||||
|
||||
Working on https://github.com/iterative/example-get-started
|
||||
|
||||
`stages` can a single string:
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show(stages="prepare")
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"prepare": {
|
||||
"split": 0.2,
|
||||
"seed": 20170428
|
||||
}
|
||||
}
|
||||
|
||||
Or an iterable of strings:
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show(stages=["prepare", "train"])
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"prepare": {
|
||||
"split": 0.2,
|
||||
"seed": 20170428
|
||||
},
|
||||
"train": {
|
||||
"seed": 20170428,
|
||||
"n_est": 50,
|
||||
"min_split": 0.01
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
- Using `rev`.
|
||||
|
||||
Working on https://github.com/iterative/example-get-started
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show(rev="tune-hyperparams")
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"prepare": {
|
||||
"split": 0.2,
|
||||
"seed": 20170428
|
||||
},
|
||||
"featurize": {
|
||||
"max_features": 200,
|
||||
"ngrams": 2
|
||||
},
|
||||
"train": {
|
||||
"seed": 20170428,
|
||||
"n_est": 100,
|
||||
"min_split": 8
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
- Using `targets`.
|
||||
|
||||
Working on `multi-params-files` folder of
|
||||
https://github.com/iterative/pipeline-conifguration
|
||||
|
||||
You can pass a single target:
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show("params.yaml")
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"run_mode": "prod",
|
||||
"configs": {
|
||||
"dev": "configs/params_dev.yaml",
|
||||
"test": "configs/params_test.yaml",
|
||||
"prod": "configs/params_prod.yaml"
|
||||
},
|
||||
"evaluate": {
|
||||
"dataset": "micro",
|
||||
"size": 5000,
|
||||
"metrics": ["f1", "roc-auc"],
|
||||
"metrics_file": "reports/metrics.json",
|
||||
"plots_cm": "reports/plot_confusion_matrix.png"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Or multiple targets:
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show(
|
||||
... "configs/params_dev.yaml", "configs/params_prod.yaml")
|
||||
>>> print(json.dumps(params, indent=4))
|
||||
{
|
||||
"configs/params_prod.yaml:run_mode": "prod",
|
||||
"configs/params_prod.yaml:config_file": "configs/params_prod.yaml",
|
||||
"configs/params_prod.yaml:data_load": {
|
||||
"dataset": "large",
|
||||
"sampling": {
|
||||
"enable": true,
|
||||
"size": 50000
|
||||
}
|
||||
},
|
||||
"configs/params_prod.yaml:train": {
|
||||
"epochs": 1000
|
||||
},
|
||||
"configs/params_dev.yaml:run_mode": "dev",
|
||||
"configs/params_dev.yaml:config_file": "configs/params_dev.yaml",
|
||||
"configs/params_dev.yaml:data_load": {
|
||||
"dataset": "development",
|
||||
"sampling": {
|
||||
"enable": true,
|
||||
"size": 1000
|
||||
}
|
||||
},
|
||||
"configs/params_dev.yaml:train": {
|
||||
"epochs": 10
|
||||
}
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
- Git URL as `repo`.
|
||||
|
||||
>>> import json
|
||||
>>> import dvc.api
|
||||
>>> params = dvc.api.params_show(
|
||||
... repo="https://github.com/iterative/demo-fashion-mnist")
|
||||
{
|
||||
"train": {
|
||||
"batch_size": 128,
|
||||
"hidden_units": 64,
|
||||
"dropout": 0.4,
|
||||
"num_epochs": 10,
|
||||
"lr": 0.001,
|
||||
"conv_activation": "relu"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.. _Git revision:
|
||||
https://git-scm.com/docs/revisions
|
||||
|
||||
"""
|
||||
if isinstance(stages, str):
|
||||
stages = [stages]
|
||||
|
||||
def _onerror_raise(result: Dict, exception: Exception, *args, **kwargs):
|
||||
raise exception
|
||||
|
||||
def _postprocess(params):
|
||||
processed = {}
|
||||
for rev, rev_data in params.items():
|
||||
processed[rev] = {}
|
||||
|
||||
counts = Counter()
|
||||
for file_data in rev_data["data"].values():
|
||||
for k in file_data["data"]:
|
||||
counts[k] += 1
|
||||
|
||||
for file_name, file_data in rev_data["data"].items():
|
||||
to_merge = {
|
||||
(k if counts[k] == 1 else f"{file_name}:{k}"): v
|
||||
for k, v in file_data["data"].items()
|
||||
}
|
||||
processed[rev] = {**processed[rev], **to_merge}
|
||||
|
||||
if "workspace" in processed:
|
||||
del processed["workspace"]
|
||||
|
||||
if not processed:
|
||||
raise DvcException("No params found")
|
||||
|
||||
return processed[first(processed)]
|
||||
|
||||
with Repo.open(repo) as _repo:
|
||||
params = _repo.params.show(
|
||||
revs=rev if rev is None else [rev],
|
||||
targets=targets,
|
||||
deps=deps,
|
||||
onerror=_onerror_raise,
|
||||
stages=stages,
|
||||
)
|
||||
|
||||
return _postprocess(params)
|
||||
+107
-23
@@ -6,6 +6,7 @@ import sys
|
||||
# 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
|
||||
# [2] https://bugs.python.org/issue29288
|
||||
from typing import Optional
|
||||
|
||||
"".encode("idna")
|
||||
|
||||
@@ -36,6 +37,100 @@ def parse_args(argv=None):
|
||||
return args
|
||||
|
||||
|
||||
def _log_unknown_exceptions() -> None:
|
||||
from dvc.info import get_dvc_info
|
||||
from dvc.logger import FOOTER
|
||||
|
||||
logger.exception("unexpected error")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Version info for developers:\n%s", get_dvc_info())
|
||||
logger.info(FOOTER)
|
||||
return None
|
||||
|
||||
|
||||
def _log_exceptions(exc: Exception) -> Optional[int]:
|
||||
"""Try to log some known exceptions, that are not DVCExceptions."""
|
||||
from dvc.utils import error_link, format_link
|
||||
|
||||
if isinstance(exc, OSError):
|
||||
import errno
|
||||
|
||||
if exc.errno == errno.EMFILE:
|
||||
logger.exception(
|
||||
"too many open files, please visit "
|
||||
"{} to see how to handle this "
|
||||
"problem".format(error_link("many-files")),
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
else:
|
||||
_log_unknown_exceptions()
|
||||
return None
|
||||
|
||||
from dvc.fs import AuthError, ConfigError, RemoteMissingDepsError
|
||||
|
||||
if isinstance(exc, RemoteMissingDepsError):
|
||||
from dvc.utils.pkg import PKG
|
||||
|
||||
proto = exc.protocol
|
||||
by_pkg = {
|
||||
"pip": f"pip install 'dvc[{proto}]'",
|
||||
"conda": f"conda install -c conda-forge dvc-{proto}",
|
||||
}
|
||||
|
||||
cmd = by_pkg.get(PKG)
|
||||
if cmd:
|
||||
link = format_link("https://dvc.org/doc/install")
|
||||
hint = (
|
||||
f"To install dvc with those dependencies, run:\n"
|
||||
"\n"
|
||||
f"\t{cmd}\n"
|
||||
"\n"
|
||||
f"See {link} for more info."
|
||||
)
|
||||
else:
|
||||
link = format_link("https://github.com/iterative/dvc/issues")
|
||||
hint = f"\nPlease report this bug to {link}. Thank you!"
|
||||
|
||||
logger.exception(
|
||||
f"URL '{exc.url}' is supported but requires these missing "
|
||||
f"dependencies: {exc.missing_deps}. {hint}",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return None
|
||||
|
||||
if isinstance(exc, (AuthError, ConfigError)):
|
||||
link = format_link("https://man.dvc.org/remote/modify")
|
||||
logger.exception("configuration error")
|
||||
logger.exception(
|
||||
f"{exc!s}\nLearn more about configuration settings at {link}.",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return 251
|
||||
|
||||
from dvc_data.hashfile.cache import DiskError
|
||||
|
||||
if isinstance(exc, DiskError):
|
||||
from dvc.utils import relpath
|
||||
|
||||
directory = relpath(exc.directory)
|
||||
logger.exception(
|
||||
f"Could not open pickled '{exc.type}' cache.\n"
|
||||
f"Remove the '{directory}' directory and then retry this command."
|
||||
f"\nSee {error_link('pickle')} for more information.",
|
||||
extra={"tb_only": True},
|
||||
)
|
||||
return None
|
||||
|
||||
from dvc_data.build import IgnoreInCollectedDirError
|
||||
|
||||
if isinstance(exc, IgnoreInCollectedDirError):
|
||||
logger.exception("")
|
||||
return None
|
||||
|
||||
_log_unknown_exceptions()
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None): # noqa: C901
|
||||
"""Main entry point for dvc CLI.
|
||||
|
||||
@@ -48,7 +143,7 @@ def main(argv=None): # noqa: C901
|
||||
from dvc._debug import debugtools
|
||||
from dvc.config import ConfigError
|
||||
from dvc.exceptions import DvcException, NotDvcRepoError
|
||||
from dvc.logger import FOOTER, disable_other_loggers
|
||||
from dvc.logger import disable_other_loggers, set_loggers_level
|
||||
|
||||
# NOTE: stderr/stdout may be closed if we are running from dvc.daemon.
|
||||
# On Linux we directly call cli.main after double forking and closing
|
||||
@@ -76,7 +171,7 @@ def main(argv=None): # noqa: C901
|
||||
level = logging.TRACE
|
||||
|
||||
if level is not None:
|
||||
logger.setLevel(level)
|
||||
set_loggers_level(level)
|
||||
|
||||
logger.trace(args)
|
||||
|
||||
@@ -94,6 +189,15 @@ def main(argv=None): # noqa: C901
|
||||
except KeyboardInterrupt:
|
||||
logger.exception("interrupted by the user")
|
||||
ret = 252
|
||||
except BrokenPipeError:
|
||||
import os
|
||||
|
||||
# Python flushes standard streams on exit; redirect remaining output
|
||||
# to devnull to avoid another BrokenPipeError at shutdown
|
||||
# See: https://docs.python.org/3/library/signal.html#note-on-sigpipe
|
||||
devnull = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull, sys.stdout.fileno())
|
||||
ret = 141 # 128 + 13 (SIGPIPE)
|
||||
except NotDvcRepoError:
|
||||
logger.exception("")
|
||||
ret = 253
|
||||
@@ -104,27 +208,7 @@ def main(argv=None): # noqa: C901
|
||||
ret = 254
|
||||
except Exception as exc: # noqa, pylint: disable=broad-except
|
||||
# pylint: disable=no-member
|
||||
import errno
|
||||
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
|
||||
from dvc.utils import error_link
|
||||
|
||||
logger.exception(
|
||||
"too many open files, please visit "
|
||||
"{} to see how to handle this "
|
||||
"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
|
||||
ret = _log_exceptions(exc) or 255
|
||||
|
||||
try:
|
||||
from dvc import analytics
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class CmdBase(ABC):
|
||||
|
||||
os.chdir(args.cd)
|
||||
|
||||
self.repo = Repo(uninitialized=self.UNINITIALIZED)
|
||||
self.repo: "Repo" = Repo(uninitialized=self.UNINITIALIZED)
|
||||
self.config = self.repo.config
|
||||
self.args = args
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from dvc.commands import (
|
||||
config,
|
||||
daemon,
|
||||
dag,
|
||||
data,
|
||||
data_sync,
|
||||
destroy,
|
||||
diff,
|
||||
@@ -33,6 +34,7 @@ from dvc.commands import (
|
||||
move,
|
||||
params,
|
||||
plots,
|
||||
queue,
|
||||
remote,
|
||||
remove,
|
||||
repro,
|
||||
@@ -50,6 +52,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
COMMANDS = [
|
||||
init,
|
||||
queue,
|
||||
get,
|
||||
get_url,
|
||||
destroy,
|
||||
@@ -86,6 +89,7 @@ COMMANDS = [
|
||||
experiments,
|
||||
check_ignore,
|
||||
machine,
|
||||
data,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ class CmdCheckout(CmdBase):
|
||||
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
if self.args.relink:
|
||||
msg = "Relinked successfully"
|
||||
ui.write(msg)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+22
-6
@@ -1,11 +1,15 @@
|
||||
import argparse
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from networkx import DiGraph
|
||||
|
||||
def _show_ascii(G):
|
||||
|
||||
def _show_ascii(G: "DiGraph"):
|
||||
from dvc.dagascii import draw
|
||||
from dvc.repo.graph import get_pipelines
|
||||
|
||||
@@ -18,13 +22,25 @@ def _show_ascii(G):
|
||||
return "\n".join(ret)
|
||||
|
||||
|
||||
def _show_dot(G):
|
||||
def _quote_label(node):
|
||||
label = str(node)
|
||||
# Node names should not contain ":" unless they are quoted with "".
|
||||
# See: https://github.com/pydot/pydot/issues/258.
|
||||
if label[0] != '"' and label[-1] != '"':
|
||||
return f'"{label}"'
|
||||
return label
|
||||
|
||||
|
||||
def _show_dot(G: "DiGraph"):
|
||||
import io
|
||||
|
||||
import networkx as nx
|
||||
from networkx.drawing.nx_pydot import write_dot
|
||||
|
||||
dot_file = io.StringIO()
|
||||
write_dot(G, dot_file)
|
||||
|
||||
nx.relabel_nodes(G, _quote_label, copy=False)
|
||||
write_dot(G.reverse(), dot_file)
|
||||
return dot_file.getvalue()
|
||||
|
||||
|
||||
@@ -42,7 +58,7 @@ def _show_mermaid(G, markdown: bool = False):
|
||||
for node in nodes:
|
||||
total_nodes += 1
|
||||
node_id = f"node{total_nodes}"
|
||||
graph += f"\n\t{node_id}[{node}]"
|
||||
graph += f'\n\t{node_id}["{node}"]'
|
||||
node_ids[node] = node_id
|
||||
edges = sorted((str(a), str(b)) for b, a in pipeline.edges)
|
||||
for a, b in edges:
|
||||
@@ -70,9 +86,9 @@ def _collect_targets(repo, target, outs):
|
||||
targets.extend([str(out) for out in stage.outs])
|
||||
continue
|
||||
|
||||
for out in outs_trie.itervalues(
|
||||
for out in outs_trie.itervalues( # noqa: B301
|
||||
prefix=repo.fs.path.parts(path)
|
||||
): # noqa: B301
|
||||
):
|
||||
targets.extend(str(out))
|
||||
|
||||
return targets
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import argparse
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from funcy import chunks, compact, log_durations
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.ui import ui
|
||||
from dvc.utils import colorize
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo.data import Status as DataStatus
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdDataStatus(CmdBase):
|
||||
COLORS = {
|
||||
"not_in_cache": "red",
|
||||
"committed": "green",
|
||||
"uncommitted": "yellow",
|
||||
"untracked": "cyan",
|
||||
}
|
||||
LABELS = {
|
||||
"not_in_cache": "Not in cache",
|
||||
"committed": "DVC committed changes",
|
||||
"uncommitted": "DVC uncommitted changes",
|
||||
"untracked": "Untracked files",
|
||||
"unchanged": "DVC unchanged files",
|
||||
}
|
||||
HINTS = {
|
||||
"not_in_cache": 'use "dvc pull <file>..." to download files',
|
||||
"committed": "git commit the corresponding dvc files "
|
||||
"to update the repo",
|
||||
"uncommitted": 'use "dvc commit <file>..." to track changes',
|
||||
"untracked": 'use "git add <file> ..." or '
|
||||
'dvc add <file>..." to commit to git or to dvc',
|
||||
"git_dirty": "there are {}changes not tracked by dvc, "
|
||||
'use "git status" to see',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _process_status(status: "DataStatus"):
|
||||
"""Flatten stage status, and filter empty stage status contents."""
|
||||
for stage, stage_status in status.items():
|
||||
items = stage_status
|
||||
if isinstance(stage_status, dict):
|
||||
items = {
|
||||
file: state
|
||||
for state, files in stage_status.items()
|
||||
for file in files
|
||||
if state != "unknown"
|
||||
}
|
||||
if not items:
|
||||
continue
|
||||
yield stage, items
|
||||
|
||||
@classmethod
|
||||
def _show_status(cls, status: "DataStatus") -> int:
|
||||
git_info = status.pop("git") # type: ignore[misc]
|
||||
result = dict(cls._process_status(status))
|
||||
if not result:
|
||||
no_changes = "No changes"
|
||||
if git_info.get("is_empty", False):
|
||||
no_changes += " in an empty git repo"
|
||||
ui.write(f"{no_changes}.")
|
||||
|
||||
for idx, (stage, stage_status) in enumerate(result.items()):
|
||||
if idx:
|
||||
ui.write()
|
||||
|
||||
label = cls.LABELS.get(stage, stage.capitalize() + " files")
|
||||
header = f"{label}:"
|
||||
color = cls.COLORS.get(stage, None)
|
||||
|
||||
ui.write(header)
|
||||
if hint := cls.HINTS.get(stage):
|
||||
ui.write(f" ({hint})")
|
||||
|
||||
if isinstance(stage_status, dict):
|
||||
items = [
|
||||
": ".join([state, file])
|
||||
for file, state in stage_status.items()
|
||||
]
|
||||
else:
|
||||
items = stage_status
|
||||
|
||||
tabs = "\t".expandtabs(8)
|
||||
for chunk in chunks(1000, items):
|
||||
out = "\n".join(tabs + item for item in chunk)
|
||||
ui.write(colorize(out, color))
|
||||
|
||||
if (hint := cls.HINTS.get("git_dirty")) and git_info.get("is_dirty"):
|
||||
message = hint.format("other " if result else "")
|
||||
ui.write(f"[blue]({message})[/]", styled=True)
|
||||
return 0
|
||||
|
||||
def run(self) -> int:
|
||||
with log_durations(logger.trace, "in data_status"): # type: ignore
|
||||
status = self.repo.data_status(
|
||||
granular=self.args.granular,
|
||||
untracked_files=self.args.untracked_files,
|
||||
with_dirs=self.args.with_dirs,
|
||||
)
|
||||
|
||||
if not self.args.unchanged:
|
||||
status.pop("unchanged") # type: ignore[misc]
|
||||
if self.args.untracked_files == "no":
|
||||
status.pop("untracked")
|
||||
if self.args.json:
|
||||
status.pop("git") # type: ignore[misc]
|
||||
ui.write_json(compact(status))
|
||||
return 0
|
||||
return self._show_status(status)
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
data_parser = subparsers.add_parser(
|
||||
"data",
|
||||
parents=[parent_parser],
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
data_subparsers = data_parser.add_subparsers(
|
||||
dest="cmd",
|
||||
help="Use `dvc data CMD --help` to display command-specific help.",
|
||||
)
|
||||
fix_subparsers(data_subparsers)
|
||||
|
||||
DATA_STATUS_HELP = (
|
||||
"Show changes between the last git commit, "
|
||||
"the dvcfiles and the workspace."
|
||||
)
|
||||
data_status_parser = data_subparsers.add_parser(
|
||||
"status",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(DATA_STATUS_HELP, "data/status"),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
help=DATA_STATUS_HELP,
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show output in JSON format.",
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--show-json",
|
||||
action="store_true",
|
||||
default=False,
|
||||
dest="json",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--granular",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show granular file-level info for DVC-tracked directories.",
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--unchanged",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show unmodified DVC-tracked files.",
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--untracked-files",
|
||||
choices=["no", "all"],
|
||||
default="no",
|
||||
const="all",
|
||||
nargs="?",
|
||||
help="Show untracked files.",
|
||||
)
|
||||
data_status_parser.add_argument(
|
||||
"--with-dirs",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
data_status_parser.set_defaults(func=CmdDataStatus)
|
||||
@@ -43,7 +43,8 @@ class CmdDataPull(CmdDataBase):
|
||||
)
|
||||
self.log_summary(stats)
|
||||
except (CheckoutError, DvcException) as exc:
|
||||
self.log_summary(getattr(exc, "stats", {}))
|
||||
if stats := getattr(exc, "stats", {}):
|
||||
self.log_summary(stats)
|
||||
logger.exception("failed to pull data from the cloud")
|
||||
return 1
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from dvc.commands.experiments import (
|
||||
ls,
|
||||
pull,
|
||||
push,
|
||||
queue_worker,
|
||||
remove,
|
||||
run,
|
||||
show,
|
||||
@@ -30,6 +31,7 @@ SUB_COMMANDS = [
|
||||
ls,
|
||||
pull,
|
||||
push,
|
||||
queue_worker,
|
||||
remove,
|
||||
run,
|
||||
show,
|
||||
|
||||
@@ -29,6 +29,6 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
"experiment", help="Experiment to be promoted."
|
||||
)
|
||||
experiments_branch_parser.add_argument(
|
||||
"branch", help="Git branch name to use."
|
||||
"branch", help="Name for the new Git branch"
|
||||
)
|
||||
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
|
||||
|
||||
@@ -12,8 +12,8 @@ logger = logging.getLogger(__name__)
|
||||
def _raise_error_if_all_disabled(**kwargs):
|
||||
if not any(kwargs.values()):
|
||||
raise InvalidArgumentError(
|
||||
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags` "
|
||||
"or `--all-commits` needs to be set."
|
||||
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags`,"
|
||||
" `--all-commits` or `--date` needs to be set."
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class CmdExperimentsGC(CmdBase):
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
commit_date=self.args.commit_date,
|
||||
workspace=self.args.workspace,
|
||||
)
|
||||
|
||||
@@ -31,13 +32,21 @@ class CmdExperimentsGC(CmdBase):
|
||||
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"
|
||||
else:
|
||||
if 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"
|
||||
|
||||
if self.args.commit_date:
|
||||
msg += (
|
||||
" and all git commits before date "
|
||||
f"{self.args.commit_date}"
|
||||
)
|
||||
msg += " of the current repo."
|
||||
|
||||
if self.args.queued:
|
||||
msg += " Run queued experiments will be preserved."
|
||||
else:
|
||||
@@ -54,6 +63,7 @@ class CmdExperimentsGC(CmdBase):
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
workspace=self.args.workspace,
|
||||
commit_date=self.args.commit_date,
|
||||
queued=self.args.queued,
|
||||
)
|
||||
|
||||
@@ -110,6 +120,17 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Keep experiments derived from all Git commits.",
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"--date",
|
||||
type=str,
|
||||
dest="commit_date",
|
||||
metavar="<YYYY-MM-DD>",
|
||||
default=None,
|
||||
help=(
|
||||
"Keep experiments from the commits after (inclusive) a certain "
|
||||
"date. Date must match the extended ISO 8601 format (YYYY-MM-DD)."
|
||||
),
|
||||
)
|
||||
experiments_gc_parser.add_argument(
|
||||
"--queued",
|
||||
action="store_true",
|
||||
|
||||
@@ -25,7 +25,6 @@ class CmdExperimentsInit(CmdBase):
|
||||
DEFAULT_METRICS = "metrics.json"
|
||||
DEFAULT_PARAMS = "params.yaml"
|
||||
PLOTS = "plots"
|
||||
DVCLIVE = "dvclive"
|
||||
DEFAULTS = {
|
||||
"code": CODE,
|
||||
"data": DATA,
|
||||
@@ -33,7 +32,6 @@ class CmdExperimentsInit(CmdBase):
|
||||
"metrics": DEFAULT_METRICS,
|
||||
"params": DEFAULT_PARAMS,
|
||||
"plots": PLOTS,
|
||||
"live": DVCLIVE,
|
||||
}
|
||||
|
||||
def run(self):
|
||||
@@ -63,7 +61,7 @@ class CmdExperimentsInit(CmdBase):
|
||||
}
|
||||
)
|
||||
|
||||
initialized_stage, initialized_deps = init(
|
||||
initialized_stage, initialized_deps, initialized_out_dirs = init(
|
||||
self.repo,
|
||||
name=self.args.name,
|
||||
type=self.args.type,
|
||||
@@ -72,21 +70,32 @@ class CmdExperimentsInit(CmdBase):
|
||||
interactive=self.args.interactive,
|
||||
force=self.args.force,
|
||||
)
|
||||
self._post_init_display(initialized_stage, initialized_deps)
|
||||
self._post_init_display(
|
||||
initialized_stage, initialized_deps, initialized_out_dirs
|
||||
)
|
||||
if self.args.run:
|
||||
self.repo.experiments.run(targets=[initialized_stage.addressing])
|
||||
return 0
|
||||
|
||||
def _post_init_display(
|
||||
self, stage: "PipelineStage", new_deps: List["Dependency"]
|
||||
self,
|
||||
stage: "PipelineStage",
|
||||
new_deps: List["Dependency"],
|
||||
new_out_dirs: List[str],
|
||||
) -> None:
|
||||
from dvc.utils import humanize
|
||||
|
||||
path_fmt = "[green]{0}[/green]".format
|
||||
path_fmt = "[green]{}[/green]".format
|
||||
if new_deps:
|
||||
deps_paths = humanize.join(map(path_fmt, new_deps))
|
||||
ui.write(f"Creating dependencies: {deps_paths}", styled=True)
|
||||
|
||||
if new_out_dirs:
|
||||
out_dirs_paths = humanize.join(map(path_fmt, new_out_dirs))
|
||||
ui.write(
|
||||
f"Creating output directories: {out_dirs_paths}", styled=True
|
||||
)
|
||||
|
||||
ui.write(
|
||||
f"Creating [b]{self.args.name}[/b] stage in [green]dvc.yaml[/]",
|
||||
styled=True,
|
||||
@@ -190,12 +199,11 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--live",
|
||||
help="Path to log dvclive outputs for your experiments"
|
||||
f" (default: {CmdExperimentsInit.DVCLIVE})",
|
||||
help="Path to log dvclive outputs for your experiments",
|
||||
)
|
||||
experiments_init_parser.add_argument(
|
||||
"--type",
|
||||
choices=["default", "dl"],
|
||||
choices=["default", "checkpoint"],
|
||||
default="default",
|
||||
help="Select type of stage to create (default: %(default)s)",
|
||||
)
|
||||
|
||||
@@ -59,6 +59,6 @@ def add_parser(experiments_subparsers, parent_parser):
|
||||
"If provided, experiments from the specified Git repository "
|
||||
" will be listed instead of local ones."
|
||||
),
|
||||
metavar="[<git_remote>]",
|
||||
metavar="<git_remote>",
|
||||
)
|
||||
experiments_list_parser.set_defaults(func=CmdExperimentsList)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueWorker(CmdBase):
|
||||
"""Run the exp queue worker."""
|
||||
|
||||
def run(self):
|
||||
self.repo.experiments.celery_queue.worker.start(self.args.name)
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(experiments_subparsers, parent_parser):
|
||||
QUEUE_WORKER_HELP = "Run the exp queue worker."
|
||||
parser = experiments_subparsers.add_parser(
|
||||
"queue-worker",
|
||||
parents=[parent_parser],
|
||||
description=QUEUE_WORKER_HELP,
|
||||
add_help=False,
|
||||
)
|
||||
parser.add_argument("name", help="Celery worker name.")
|
||||
parser.set_defaults(func=CmdQueueWorker)
|
||||
@@ -10,23 +10,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdExperimentsRemove(CmdBase):
|
||||
def raise_error_if_all_disabled(self):
|
||||
def check_arguments(self):
|
||||
if not any(
|
||||
[
|
||||
self.args.experiment,
|
||||
self.args.all_commits,
|
||||
self.args.rev,
|
||||
self.args.queue,
|
||||
]
|
||||
):
|
||||
) ^ bool(self.args.experiment):
|
||||
raise InvalidArgumentError(
|
||||
"Either provide an `experiment` argument, or use the "
|
||||
"`--rev` or `--all-commits` flag."
|
||||
"`--rev` or `--all-commits` or `--queue` flag."
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
self.raise_error_if_all_disabled()
|
||||
self.check_arguments()
|
||||
|
||||
removed_list = self.repo.experiments.remove(
|
||||
exp_names=self.args.experiment,
|
||||
|
||||
@@ -138,7 +138,7 @@ def _collect_rows(
|
||||
row_dict["Experiment"] = exp.get("name", "")
|
||||
row_dict["rev"] = name_rev
|
||||
row_dict["typ"] = typ
|
||||
row_dict["Created"] = _format_time(
|
||||
row_dict["Created"] = format_time(
|
||||
exp.get("timestamp"), fill_value, iso
|
||||
)
|
||||
row_dict["parent"] = parent
|
||||
@@ -218,7 +218,7 @@ def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
|
||||
return ret
|
||||
|
||||
|
||||
def _format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
|
||||
def format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
|
||||
if datetime_obj is None:
|
||||
return fill_value
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class CmdFreezeBase(CmdBase):
|
||||
try:
|
||||
func(target)
|
||||
except DvcException:
|
||||
logger.exception(f"failed to {name} '{target}'")
|
||||
logger.exception("failed to %s '%s'", name, target)
|
||||
ret = 1
|
||||
return ret
|
||||
|
||||
|
||||
+62
-10
@@ -11,29 +11,46 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class CmdGC(CmdBase):
|
||||
def run(self):
|
||||
from dvc.repo.gc import _raise_error_if_all_disabled
|
||||
from dvc.repo.gc import _validate_args
|
||||
|
||||
_raise_error_if_all_disabled(
|
||||
_validate_args(
|
||||
all_branches=self.args.all_branches,
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
all_experiments=self.args.all_experiments,
|
||||
commit_date=self.args.commit_date,
|
||||
workspace=self.args.workspace,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
)
|
||||
|
||||
if self.args.rev:
|
||||
self.args.num = self.args.num or 1
|
||||
|
||||
msg = "This will remove all cache except items used in "
|
||||
|
||||
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"
|
||||
elif self.args.all_experiments:
|
||||
msg += " and all experiments"
|
||||
else:
|
||||
if 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"
|
||||
if self.args.commit_date:
|
||||
msg += (
|
||||
" and all git commits before date "
|
||||
f"{self.args.commit_date}"
|
||||
)
|
||||
if self.args.rev:
|
||||
msg += (
|
||||
f" and last {self.args.num} commits from {self.args.rev}"
|
||||
)
|
||||
|
||||
if self.args.all_experiments:
|
||||
msg += " and all experiments"
|
||||
if self.args.repos:
|
||||
msg += " of the current and the following repos:"
|
||||
|
||||
@@ -53,12 +70,15 @@ class CmdGC(CmdBase):
|
||||
all_tags=self.args.all_tags,
|
||||
all_commits=self.args.all_commits,
|
||||
all_experiments=self.args.all_experiments,
|
||||
commit_date=self.args.commit_date,
|
||||
cloud=self.args.cloud,
|
||||
remote=self.args.remote,
|
||||
force=self.args.force,
|
||||
jobs=self.args.jobs,
|
||||
repos=self.args.repos,
|
||||
workspace=self.args.workspace,
|
||||
rev=self.args.rev,
|
||||
num=self.args.num,
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -83,6 +103,26 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Keep data files used in the current workspace.",
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
"--rev",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Keep data files used in the specified <commit>.",
|
||||
metavar="<commit>",
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
"-n",
|
||||
"--num",
|
||||
type=int,
|
||||
dest="num",
|
||||
metavar="<num>",
|
||||
help=(
|
||||
"Keep data files used in the last `num` commits "
|
||||
"starting from the `--rev` <commit>. "
|
||||
"Only used if `--rev` is also provided. "
|
||||
"Defaults to `1`."
|
||||
),
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
"-a",
|
||||
"--all-branches",
|
||||
@@ -104,6 +144,18 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Keep data files for all Git commits.",
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
"--date",
|
||||
type=str,
|
||||
dest="commit_date",
|
||||
metavar="<YYYY-MM-DD>",
|
||||
default=None,
|
||||
help=(
|
||||
"Keep cached data referenced in the commits after ( inclusive )"
|
||||
" a certain time. Date must match the extended ISO 8601 format "
|
||||
"(YYYY-MM-DD)."
|
||||
),
|
||||
)
|
||||
gc_parser.add_argument(
|
||||
"--all-experiments",
|
||||
action="store_true",
|
||||
|
||||
@@ -18,7 +18,7 @@ class CmdGetUrl(CmdBaseNoRepo):
|
||||
Repo.get_url(self.args.url, out=self.args.out, jobs=self.args.jobs)
|
||||
return 0
|
||||
except DvcException:
|
||||
logger.exception(f"failed to get '{self.args.url}'")
|
||||
logger.exception("failed to get '%s'", self.args.url)
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
+11
-2
@@ -19,6 +19,7 @@ class CmdImport(CmdBase):
|
||||
fname=self.args.file,
|
||||
rev=self.args.rev,
|
||||
no_exec=self.args.no_exec,
|
||||
no_download=self.args.no_download,
|
||||
desc=self.args.desc,
|
||||
jobs=self.args.jobs,
|
||||
)
|
||||
@@ -69,11 +70,19 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Specify name of the .dvc file this command will generate.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
no_download_exec_group = import_parser.add_mutually_exclusive_group()
|
||||
no_download_exec_group.add_argument(
|
||||
"--no-exec",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only create .dvc file without actually downloading it.",
|
||||
help="Only create .dvc file without actually importing target data.",
|
||||
)
|
||||
no_download_exec_group.add_argument(
|
||||
"--no-download",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Create .dvc file including target data hash value(s)"
|
||||
" but do not actually download the file(s).",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--desc",
|
||||
|
||||
+11
-2
@@ -17,6 +17,7 @@ class CmdImportUrl(CmdBase):
|
||||
out=self.args.out,
|
||||
fname=self.args.file,
|
||||
no_exec=self.args.no_exec,
|
||||
no_download=self.args.no_download,
|
||||
remote=self.args.remote,
|
||||
to_remote=self.args.to_remote,
|
||||
desc=self.args.desc,
|
||||
@@ -66,11 +67,19 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Specify name of the .dvc file this command will generate.",
|
||||
metavar="<filename>",
|
||||
).complete = completion.DIR
|
||||
import_parser.add_argument(
|
||||
no_download_exec_group = import_parser.add_mutually_exclusive_group()
|
||||
no_download_exec_group.add_argument(
|
||||
"--no-exec",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only create .dvc file without actually downloading it.",
|
||||
help="Only create .dvc file without actually importing target data.",
|
||||
)
|
||||
no_download_exec_group.add_argument(
|
||||
"--no-download",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Create .dvc file including target data hash value(s)"
|
||||
" but do not actually download the file(s).",
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--to-remote",
|
||||
|
||||
@@ -42,7 +42,7 @@ class CmdList(CmdBaseNoRepo):
|
||||
ui.write("\n".join(entries))
|
||||
return 0
|
||||
except DvcException:
|
||||
logger.exception(f"failed to list '{self.args.url}'")
|
||||
logger.exception("failed to list '%s'", self.args.url)
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
+130
-51
@@ -24,6 +24,60 @@ def _show_json(renderers, split=False):
|
||||
ui.write_json(result)
|
||||
|
||||
|
||||
def _adjust_vega_renderers(renderers):
|
||||
from dvc.render import VERSION_FIELD
|
||||
from dvc_render import VegaRenderer
|
||||
|
||||
for r in renderers:
|
||||
if isinstance(r, VegaRenderer):
|
||||
if _data_versions_count(r) > 1:
|
||||
summary = _summarize_version_infos(r)
|
||||
for dp in r.datapoints:
|
||||
vi = dp.pop(VERSION_FIELD, {})
|
||||
keys = list(vi.keys())
|
||||
for key in keys:
|
||||
if not (len(summary.get(key, set())) > 1):
|
||||
vi.pop(key)
|
||||
if vi:
|
||||
dp["rev"] = "::".join(vi.values())
|
||||
else:
|
||||
for dp in r.datapoints:
|
||||
dp.pop(VERSION_FIELD, {})
|
||||
|
||||
|
||||
def _summarize_version_infos(renderer):
|
||||
from collections import defaultdict
|
||||
|
||||
from dvc.render import VERSION_FIELD
|
||||
|
||||
result = defaultdict(set)
|
||||
|
||||
for dp in renderer.datapoints:
|
||||
for key, value in dp.get(VERSION_FIELD, {}).items():
|
||||
result[key].add(value)
|
||||
return dict(result)
|
||||
|
||||
|
||||
def _data_versions_count(renderer):
|
||||
from itertools import product
|
||||
|
||||
summary = _summarize_version_infos(renderer)
|
||||
x = product(summary.get("filename", {None}), summary.get("field", {None}))
|
||||
return len(set(x))
|
||||
|
||||
|
||||
def _filter_unhandled_renderers(renderers):
|
||||
# filtering out renderers currently unhandled by vscode extension
|
||||
from dvc_render import VegaRenderer
|
||||
|
||||
def _is_json_viable(r):
|
||||
return not (
|
||||
isinstance(r, VegaRenderer) and _data_versions_count(r) > 1
|
||||
)
|
||||
|
||||
return list(filter(_is_json_viable, renderers))
|
||||
|
||||
|
||||
class CmdPlots(CmdBase):
|
||||
def _func(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
@@ -35,13 +89,30 @@ class CmdPlots(CmdBase):
|
||||
props = {p: getattr(self.args, p) for p in PLOT_PROPS}
|
||||
return {k: v for k, v in props.items() if v is not None}
|
||||
|
||||
def _config_files(self):
|
||||
config_files = None
|
||||
if self.args.from_config:
|
||||
config_files = {self.args.from_config}
|
||||
return config_files
|
||||
|
||||
def _html_template_path(self):
|
||||
html_template_path = self.args.html_template
|
||||
if not html_template_path:
|
||||
html_template_path = self.repo.config.get("plots", {}).get(
|
||||
"html_template", None
|
||||
)
|
||||
if html_template_path and not os.path.isabs(html_template_path):
|
||||
html_template_path = os.path.join(
|
||||
self.repo.dvc_dir, html_template_path
|
||||
)
|
||||
return html_template_path
|
||||
|
||||
def run(self):
|
||||
from pathlib import Path
|
||||
|
||||
from dvc.render.match import match_defs_renderers
|
||||
from dvc_render import render_html
|
||||
|
||||
from dvc.render.match import match_renderers
|
||||
|
||||
if self.args.show_vega:
|
||||
if not self.args.targets:
|
||||
logger.error("please specify a target for `--show-vega`")
|
||||
@@ -59,9 +130,10 @@ class CmdPlots(CmdBase):
|
||||
return 1
|
||||
|
||||
try:
|
||||
|
||||
plots_data = self._func(
|
||||
targets=self.args.targets, props=self._props()
|
||||
targets=self.args.targets,
|
||||
props=self._props(),
|
||||
config_files=self._config_files(),
|
||||
)
|
||||
|
||||
if not plots_data:
|
||||
@@ -70,54 +142,51 @@ class CmdPlots(CmdBase):
|
||||
"visualization file will not be created."
|
||||
)
|
||||
|
||||
out: str = self.args.out or "dvc_plots"
|
||||
out: str = self.args.out or self.repo.config.get("plots", {}).get(
|
||||
"out_dir", "dvc_plots"
|
||||
)
|
||||
|
||||
renderers_out = (
|
||||
out if self.args.json else os.path.join(out, "static")
|
||||
)
|
||||
renderers = match_renderers(
|
||||
plots_data=plots_data, out=renderers_out
|
||||
|
||||
renderers = match_defs_renderers(
|
||||
data=plots_data,
|
||||
out=renderers_out,
|
||||
templates_dir=self.repo.plots.templates_dir,
|
||||
)
|
||||
if self.args.json:
|
||||
renderers = _filter_unhandled_renderers(renderers)
|
||||
_show_json(renderers, self.args.split)
|
||||
return 0
|
||||
|
||||
_adjust_vega_renderers(renderers)
|
||||
|
||||
if self.args.show_vega:
|
||||
renderer = first(filter(lambda r: r.TYPE == "vega", renderers))
|
||||
if renderer:
|
||||
ui.write_json(json.loads(renderer.get_filled_template()))
|
||||
return 0
|
||||
if self.args.json:
|
||||
_show_json(renderers, self.args.split)
|
||||
return 0
|
||||
|
||||
html_template_path = self.args.html_template
|
||||
if not html_template_path:
|
||||
html_template_path = self.repo.config.get("plots", {}).get(
|
||||
"html_template", None
|
||||
)
|
||||
if html_template_path and not os.path.isabs(
|
||||
html_template_path
|
||||
):
|
||||
html_template_path = os.path.join(
|
||||
self.repo.dvc_dir, html_template_path
|
||||
)
|
||||
|
||||
output_file: Path = (Path.cwd() / out).resolve() / "index.html"
|
||||
|
||||
render_html(
|
||||
renderers=renderers,
|
||||
output_file=output_file,
|
||||
template_path=html_template_path,
|
||||
)
|
||||
if renderers:
|
||||
render_html(
|
||||
renderers=renderers,
|
||||
output_file=output_file,
|
||||
template_path=self._html_template_path(),
|
||||
)
|
||||
|
||||
ui.write(output_file.as_uri())
|
||||
auto_open = self.repo.config["plots"].get("auto_open", False)
|
||||
if self.args.open or auto_open:
|
||||
if not auto_open:
|
||||
ui.write(
|
||||
"To enable auto opening, you can run:\n"
|
||||
"\n"
|
||||
"\tdvc config plots.auto_open true"
|
||||
)
|
||||
return ui.open_browser(output_file)
|
||||
ui.write(output_file.as_uri())
|
||||
auto_open = self.repo.config["plots"].get("auto_open", False)
|
||||
if self.args.open or auto_open:
|
||||
if not auto_open:
|
||||
ui.write(
|
||||
"To enable auto opening, you can run:\n"
|
||||
"\n"
|
||||
"\tdvc config plots.auto_open true"
|
||||
)
|
||||
return ui.open_browser(output_file)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -161,6 +230,8 @@ class CmdPlotsTemplates(CmdBase):
|
||||
"confusion_normalized",
|
||||
"scatter",
|
||||
"smooth",
|
||||
"bar_horizontal_sorted",
|
||||
"bar_horizontal",
|
||||
]
|
||||
|
||||
def run(self):
|
||||
@@ -185,10 +256,7 @@ class CmdPlotsTemplates(CmdBase):
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
PLOTS_HELP = (
|
||||
"Commands to visualize and compare plot metrics in structured files "
|
||||
"(JSON, YAML, CSV, TSV)."
|
||||
)
|
||||
PLOTS_HELP = "Commands to visualize and compare plot data."
|
||||
|
||||
plots_parser = subparsers.add_parser(
|
||||
"plots",
|
||||
@@ -204,7 +272,10 @@ def add_parser(subparsers, parent_parser):
|
||||
|
||||
fix_subparsers(plots_subparsers)
|
||||
|
||||
SHOW_HELP = "Generate plots from metrics files."
|
||||
SHOW_HELP = (
|
||||
"Generate plots from target files or from `plots`"
|
||||
" definitions in `dvc.yaml`."
|
||||
)
|
||||
plots_show_parser = plots_subparsers.add_parser(
|
||||
"show",
|
||||
parents=[parent_parser],
|
||||
@@ -215,9 +286,10 @@ def add_parser(subparsers, parent_parser):
|
||||
plots_show_parser.add_argument(
|
||||
"targets",
|
||||
nargs="*",
|
||||
help="Files to visualize (supports any file, "
|
||||
"even when not found as `plots` in `dvc.yaml`). "
|
||||
"Shows all plots by default.",
|
||||
help=(
|
||||
"Plots files or plot IDs from `dvc.yaml` to visualize. "
|
||||
"Shows all plots by default."
|
||||
),
|
||||
).complete = completion.FILE
|
||||
_add_props_arguments(plots_show_parser)
|
||||
_add_output_argument(plots_show_parser)
|
||||
@@ -225,8 +297,8 @@ def add_parser(subparsers, parent_parser):
|
||||
plots_show_parser.set_defaults(func=CmdPlotsShow)
|
||||
|
||||
PLOTS_DIFF_HELP = (
|
||||
"Show multiple versions of plot metrics "
|
||||
"by plotting them in a single image."
|
||||
"Show multiple versions of a plot by overlaying them "
|
||||
"in a single image."
|
||||
)
|
||||
plots_diff_parser = plots_subparsers.add_parser(
|
||||
"diff",
|
||||
@@ -239,8 +311,8 @@ def add_parser(subparsers, parent_parser):
|
||||
"--targets",
|
||||
nargs="*",
|
||||
help=(
|
||||
"Specific plots file(s) to visualize "
|
||||
"(even if not found as `plots` in `dvc.yaml`). "
|
||||
"Specific plots to visualize. "
|
||||
"Accepts any file path or plot name from `dvc.yaml` file. "
|
||||
"Shows all tracked plots by default."
|
||||
),
|
||||
metavar="<paths>",
|
||||
@@ -262,7 +334,7 @@ def add_parser(subparsers, parent_parser):
|
||||
|
||||
PLOTS_MODIFY_HELP = (
|
||||
"Modify display properties of data-series plots "
|
||||
"(has no effect on image-type plots)."
|
||||
"defined in stages (has no effect on image plots)."
|
||||
)
|
||||
plots_modify_parser = plots_subparsers.add_parser(
|
||||
"modify",
|
||||
@@ -272,7 +344,8 @@ def add_parser(subparsers, parent_parser):
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
plots_modify_parser.add_argument(
|
||||
"target", help="Metric file to set properties to"
|
||||
"target",
|
||||
help="Plots file to set properties for (defined at the stage level).",
|
||||
).complete = completion.FILE
|
||||
_add_props_arguments(plots_modify_parser)
|
||||
plots_modify_parser.add_argument(
|
||||
@@ -382,3 +455,9 @@ def _add_ui_arguments(parser):
|
||||
help="Custom HTML template for VEGA visualization.",
|
||||
metavar="<path>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--from-config",
|
||||
default=None,
|
||||
metavar="<path>",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import argparse
|
||||
|
||||
from dvc.cli.utils import append_doc_link, fix_subparsers
|
||||
from dvc.commands.queue import kill, logs, remove, start, status, stop
|
||||
|
||||
SUB_COMMANDS = [
|
||||
start,
|
||||
stop,
|
||||
status,
|
||||
logs,
|
||||
remove,
|
||||
kill,
|
||||
]
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
QUEUE_HELP = "Commands to manage experiments queue."
|
||||
|
||||
queue_parser = subparsers.add_parser(
|
||||
"queue",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_HELP, "queue"),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
help=QUEUE_HELP,
|
||||
)
|
||||
|
||||
queue_subparsers = queue_parser.add_subparsers(
|
||||
dest="cmd",
|
||||
help="Use `dvc queue CMD --help` to display " "command-specific help.",
|
||||
)
|
||||
|
||||
fix_subparsers(queue_subparsers)
|
||||
for cmd in SUB_COMMANDS:
|
||||
cmd.add_parser(queue_subparsers, parent_parser)
|
||||
@@ -0,0 +1,34 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueKill(CmdBase):
|
||||
"""Kill exp task in queue."""
|
||||
|
||||
def run(self):
|
||||
self.repo.experiments.celery_queue.kill(revs=self.args.task)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
QUEUE_KILL_HELP = "Kill actively running experiment queue tasks."
|
||||
queue_kill_parser = queue_subparsers.add_parser(
|
||||
"kill",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_KILL_HELP, "queue/kill"),
|
||||
help=QUEUE_KILL_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_kill_parser.add_argument(
|
||||
"task",
|
||||
nargs="*",
|
||||
help="Tasks in queue to kill.",
|
||||
metavar="<task>",
|
||||
)
|
||||
queue_kill_parser.set_defaults(func=CmdQueueKill)
|
||||
@@ -0,0 +1,56 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueLogs(CmdBase):
|
||||
"""Show output logs for a queued experiment."""
|
||||
|
||||
def run(self):
|
||||
self.repo.experiments.celery_queue.logs(
|
||||
rev=self.args.task,
|
||||
encoding=self.args.encoding,
|
||||
follow=self.args.follow,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
QUEUE_LOGS_HELP = (
|
||||
"Show output logs for running and completed experiment queue tasks."
|
||||
)
|
||||
queue_logs_parser = queue_subparsers.add_parser(
|
||||
"logs",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_LOGS_HELP, "queue/logs"),
|
||||
help=QUEUE_LOGS_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_logs_parser.add_argument(
|
||||
"-e",
|
||||
"--encoding",
|
||||
help=(
|
||||
"Text encoding for log output. Defaults to system locale encoding."
|
||||
),
|
||||
metavar="<encoding>",
|
||||
)
|
||||
queue_logs_parser.add_argument(
|
||||
"-f",
|
||||
"--follow",
|
||||
help=(
|
||||
"Attach to task and follow additional live output. Only "
|
||||
"applicable if the task is still running."
|
||||
),
|
||||
action="store_true",
|
||||
)
|
||||
queue_logs_parser.add_argument(
|
||||
"task",
|
||||
help="Task to show.",
|
||||
metavar="<task>",
|
||||
)
|
||||
queue_logs_parser.set_defaults(func=CmdQueueLogs)
|
||||
@@ -0,0 +1,95 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.exceptions import InvalidArgumentError
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueRemove(CmdBase):
|
||||
"""Remove exp in queue."""
|
||||
|
||||
def check_arguments(self):
|
||||
clear_flag = any(
|
||||
[
|
||||
self.args.all,
|
||||
self.args.queued,
|
||||
self.args.failed,
|
||||
self.args.success,
|
||||
]
|
||||
)
|
||||
if not (clear_flag ^ bool(self.args.task)):
|
||||
raise InvalidArgumentError(
|
||||
"Either provide an `tasks` argument, or use the "
|
||||
"`--all`, `--queued`, `--failed`, `--success` flag."
|
||||
)
|
||||
|
||||
def run(self):
|
||||
|
||||
self.check_arguments()
|
||||
|
||||
if self.args.all:
|
||||
self.args.queued = True
|
||||
self.args.failed = True
|
||||
self.args.success = True
|
||||
|
||||
if self.args.queued or self.args.failed or self.args.success:
|
||||
removed_list = self.repo.experiments.celery_queue.clear(
|
||||
success=self.args.success,
|
||||
queued=self.args.queued,
|
||||
failed=self.args.failed,
|
||||
)
|
||||
else:
|
||||
removed_list = self.repo.experiments.celery_queue.remove(
|
||||
revs=self.args.task,
|
||||
)
|
||||
|
||||
if removed_list:
|
||||
removed = ", ".join(removed_list)
|
||||
ui.write(f"Removed tasks in queue: {removed}")
|
||||
else:
|
||||
ui.write(f"No tasks found named {self.args.task}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
|
||||
QUEUE_REMOVE_HELP = "Remove queued and completed tasks from the queue."
|
||||
queue_remove_parser = queue_subparsers.add_parser(
|
||||
"remove",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_REMOVE_HELP, "queue/remove"),
|
||||
help=QUEUE_REMOVE_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_remove_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Remove all queued and completed tasks from the queue.",
|
||||
)
|
||||
queue_remove_parser.add_argument(
|
||||
"--queued",
|
||||
action="store_true",
|
||||
help="Remove all queued tasks from the queue.",
|
||||
)
|
||||
queue_remove_parser.add_argument(
|
||||
"--success",
|
||||
action="store_true",
|
||||
help="Remove all successful tasks from the queue.",
|
||||
)
|
||||
queue_remove_parser.add_argument(
|
||||
"--failed",
|
||||
action="store_true",
|
||||
help="Remove all failed tasks from the queue.",
|
||||
)
|
||||
queue_remove_parser.add_argument(
|
||||
"task",
|
||||
nargs="*",
|
||||
help="Tasks to remove.",
|
||||
metavar="<task>",
|
||||
)
|
||||
queue_remove_parser.set_defaults(func=CmdQueueRemove)
|
||||
@@ -0,0 +1,48 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueStart(CmdBase):
|
||||
"""Start exp queue workers."""
|
||||
|
||||
def run(self):
|
||||
started = self.repo.experiments.celery_queue.start_workers(
|
||||
self.args.jobs
|
||||
)
|
||||
|
||||
suffix = "s" if started > 1 else ""
|
||||
ui.write(
|
||||
f"Started '{started}' new experiments task queue worker{suffix}."
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
|
||||
QUEUE_START_HELP = "Start the experiments task queue worker."
|
||||
queue_start_parser = queue_subparsers.add_parser(
|
||||
"start",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_START_HELP, "queue/start"),
|
||||
help=QUEUE_START_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_start_parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=1,
|
||||
help=(
|
||||
"Maximum number of concurrent queue workers to start. "
|
||||
"Defaults to 1."
|
||||
),
|
||||
metavar="<number>",
|
||||
)
|
||||
queue_start_parser.set_defaults(func=CmdQueueStart)
|
||||
@@ -0,0 +1,62 @@
|
||||
import argparse
|
||||
import logging
|
||||
from typing import List, Mapping, Optional
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.compare import TabularData
|
||||
from dvc.ui import ui
|
||||
|
||||
from ..experiments.show import format_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueStatus(CmdBase):
|
||||
"""Show queue task and worker status."""
|
||||
|
||||
def run(self):
|
||||
result: List[
|
||||
Mapping[str, Optional[str]]
|
||||
] = self.repo.experiments.celery_queue.status()
|
||||
if result:
|
||||
all_headers = ["Task", "Name", "Created", "Status"]
|
||||
td = TabularData(all_headers)
|
||||
for exp in result:
|
||||
created = format_time(exp.get("timestamp"))
|
||||
td.append(
|
||||
[
|
||||
exp["rev"][:7],
|
||||
exp.get("name", ""),
|
||||
created,
|
||||
exp["status"],
|
||||
]
|
||||
)
|
||||
td.render()
|
||||
else:
|
||||
ui.write("No experiment tasks in the queue.")
|
||||
ui.write()
|
||||
|
||||
worker_status = self.repo.experiments.celery_queue.worker_status()
|
||||
active_count = len(
|
||||
[name for name, task in worker_status.items() if task]
|
||||
)
|
||||
idle_count = len(worker_status) - active_count
|
||||
|
||||
ui.write(f"Worker status: {active_count} active, {idle_count} idle")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
QUEUE_STATUS_HELP = (
|
||||
"Show the status of experiments queue tasks and workers."
|
||||
)
|
||||
queue_status_parser = queue_subparsers.add_parser(
|
||||
"status",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_STATUS_HELP, "queue/status"),
|
||||
help=QUEUE_STATUS_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_status_parser.set_defaults(func=CmdQueueStatus)
|
||||
@@ -0,0 +1,43 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from dvc.cli.command import CmdBase
|
||||
from dvc.cli.utils import append_doc_link
|
||||
from dvc.ui import ui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdQueueStop(CmdBase):
|
||||
"""Stop exp queue workers."""
|
||||
|
||||
def run(self):
|
||||
self.repo.experiments.celery_queue.shutdown(kill=self.args.kill)
|
||||
|
||||
if self.args.kill:
|
||||
ui.write(
|
||||
"All running tasks in the queue have been killed."
|
||||
"Queue workers are stopping."
|
||||
)
|
||||
else:
|
||||
ui.write("Queue workers will stop after running tasks finish.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def add_parser(queue_subparsers, parent_parser):
|
||||
|
||||
QUEUE_STOP_HELP = "Stop all experiments task queue workers."
|
||||
queue_stop_parser = queue_subparsers.add_parser(
|
||||
"stop",
|
||||
parents=[parent_parser],
|
||||
description=append_doc_link(QUEUE_STOP_HELP, "queue/stop"),
|
||||
help=QUEUE_STOP_HELP,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
queue_stop_parser.add_argument(
|
||||
"--kill",
|
||||
action="store_true",
|
||||
help="Kill all running tasks before stopping the queue workers.",
|
||||
)
|
||||
queue_stop_parser.set_defaults(func=CmdQueueStop)
|
||||
+32
-34
@@ -16,9 +16,7 @@ class CmdRepro(CmdBase):
|
||||
if len(stages) == 0:
|
||||
ui.write(CmdDataStatus.UP_TO_DATE_MSG)
|
||||
else:
|
||||
ui.write(
|
||||
"Use `dvc push` to send your updates to " "remote storage."
|
||||
)
|
||||
ui.write("Use `dvc push` to send your updates to remote storage.")
|
||||
|
||||
if self.args.metrics:
|
||||
from dvc.compare import show_metrics
|
||||
@@ -66,6 +64,13 @@ def add_arguments(repro_parser):
|
||||
default=False,
|
||||
help="Reproduce even if dependencies were not changed.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ask for confirmation before reproducing each stage.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-s",
|
||||
"--single-item",
|
||||
@@ -74,27 +79,6 @@ def add_arguments(repro_parser):
|
||||
help="Reproduce only single data item without recursive dependencies "
|
||||
"check.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Show metrics after reproduction.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only print the commands that would be executed without "
|
||||
"actually executing.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Ask for confirmation before reproducing each stage.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"-p",
|
||||
"--pipeline",
|
||||
@@ -118,11 +102,11 @@ def add_arguments(repro_parser):
|
||||
help="Reproduce all stages in the specified directory.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--force-downstream",
|
||||
"-m",
|
||||
"--metrics",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce all descendants of a changed stage even if their "
|
||||
"direct dependencies didn't change.",
|
||||
help="Show metrics after reproduction.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--downstream",
|
||||
@@ -130,6 +114,13 @@ def add_arguments(repro_parser):
|
||||
default=False,
|
||||
help="Start from the specified stages when reproducing pipelines.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--force-downstream",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Reproduce all descendants of a changed stage even if their "
|
||||
"direct dependencies didn't change.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--pull",
|
||||
action="store_true",
|
||||
@@ -139,6 +130,13 @@ def add_arguments(repro_parser):
|
||||
"from the run-cache."
|
||||
),
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only print the commands that would be executed without "
|
||||
"actually executing.",
|
||||
)
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
@@ -156,13 +154,10 @@ def add_parser(subparsers, parent_parser):
|
||||
add_arguments(repro_parser)
|
||||
# repro only args
|
||||
repro_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
"--glob",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Execute stage commands even if they have already been run with "
|
||||
"the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--no-commit",
|
||||
@@ -171,9 +166,12 @@ def add_parser(subparsers, parent_parser):
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
repro_parser.add_argument(
|
||||
"--glob",
|
||||
"--no-run-cache",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allows targets containing shell-style wildcards.",
|
||||
help=(
|
||||
"Execute stage commands even if they have already been run with "
|
||||
"the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
)
|
||||
repro_parser.set_defaults(func=CmdRepro)
|
||||
|
||||
+13
-13
@@ -69,12 +69,25 @@ def add_parser(subparsers, parent_parser):
|
||||
run_parser.add_argument(
|
||||
"--file", metavar="<filename>", help=argparse.SUPPRESS
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--single-stage",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
_add_common_args(run_parser)
|
||||
run_parser.add_argument(
|
||||
"--no-exec",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Only create dvc.yaml without actually running it.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-run-cache",
|
||||
action="store_true",
|
||||
@@ -84,17 +97,4 @@ def add_parser(subparsers, parent_parser):
|
||||
"with the same command/dependencies/outputs/etc before."
|
||||
),
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Don't put files/directories into cache.",
|
||||
)
|
||||
run_parser.add_argument(
|
||||
"--single-stage",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
_add_common_args(run_parser)
|
||||
run_parser.set_defaults(func=CmdRun)
|
||||
|
||||
+42
-42
@@ -137,6 +137,13 @@ class CmdStageAdd(CmdBase):
|
||||
|
||||
|
||||
def _add_common_args(parser):
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite existing stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--deps",
|
||||
@@ -145,6 +152,14 @@ def _add_common_args(parser):
|
||||
help="Declare dependencies for reproducible cmd.",
|
||||
metavar="<path>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--params",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare parameter to use as additional dependency.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--outs",
|
||||
@@ -163,13 +178,36 @@ def _add_common_args(parser):
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--params",
|
||||
"-c",
|
||||
"--checkpoints",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare parameter to use as additional dependency.",
|
||||
metavar="[<filename>:]<params_list>",
|
||||
help="Declare checkpoint output file or directory for 'dvc exp run'. "
|
||||
"Not compatible with 'dvc repro'.",
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"--external",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allow outputs that are outside of the DVC repository.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist-no-cache",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro (do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--metrics",
|
||||
@@ -226,50 +264,12 @@ def _add_common_args(parser):
|
||||
help="Directory within your repo to run your command in.",
|
||||
metavar="<path>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Overwrite existing stage",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro.",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--outs-persist-no-cache",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare output file or directory that will not be "
|
||||
"removed upon repro (do not put into DVC cache).",
|
||||
metavar="<filename>",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--checkpoints",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Declare checkpoint output file or directory for 'dvc exp run'. "
|
||||
"Not compatible with 'dvc repro'.",
|
||||
metavar="<filename>",
|
||||
).complete = completion.FILE
|
||||
parser.add_argument(
|
||||
"--always-changed",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Always consider this DVC-file as changed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--external",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Allow outputs that are outside of the DVC repository.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--desc",
|
||||
type=str,
|
||||
|
||||
+12
-1
@@ -18,6 +18,7 @@ class CmdUpdate(CmdBase):
|
||||
rev=self.args.rev,
|
||||
recursive=self.args.recursive,
|
||||
to_remote=self.args.to_remote,
|
||||
no_download=self.args.no_download,
|
||||
remote=self.args.remote,
|
||||
jobs=self.args.jobs,
|
||||
)
|
||||
@@ -28,7 +29,10 @@ class CmdUpdate(CmdBase):
|
||||
|
||||
|
||||
def add_parser(subparsers, parent_parser):
|
||||
UPDATE_HELP = "Update data artifacts imported from other DVC repositories."
|
||||
UPDATE_HELP = (
|
||||
"Update data artifact imported (via dvc import or dvc import-url) "
|
||||
"from an external DVC repository or URL."
|
||||
)
|
||||
update_parser = subparsers.add_parser(
|
||||
"update",
|
||||
parents=[parent_parser],
|
||||
@@ -52,6 +56,13 @@ def add_parser(subparsers, parent_parser):
|
||||
default=False,
|
||||
help="Update all stages in the specified directory.",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--no-download",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Update .dvc file hash value(s)"
|
||||
" but do not download the file(s).",
|
||||
)
|
||||
update_parser.add_argument(
|
||||
"--to-remote",
|
||||
action="store_true",
|
||||
|
||||
+8
-9
@@ -86,9 +86,11 @@ class Config(dict):
|
||||
def __init__(
|
||||
self, dvc_dir=None, validate=True, fs=None, config=None
|
||||
): # pylint: disable=super-init-not-called
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.fs import LocalFileSystem
|
||||
|
||||
self.dvc_dir = dvc_dir
|
||||
self.wfs = LocalFileSystem()
|
||||
self.fs = fs or self.wfs
|
||||
|
||||
if not dvc_dir:
|
||||
try:
|
||||
@@ -98,10 +100,7 @@ class Config(dict):
|
||||
except NotDvcRepoError:
|
||||
self.dvc_dir = None
|
||||
else:
|
||||
self.dvc_dir = os.path.abspath(os.path.realpath(dvc_dir))
|
||||
|
||||
self.wfs = LocalFileSystem(url=self.dvc_dir)
|
||||
self.fs = fs or self.wfs
|
||||
self.dvc_dir = self.fs.path.abspath(self.fs.path.realpath(dvc_dir))
|
||||
|
||||
self.load(validate=validate, config=config)
|
||||
|
||||
@@ -124,8 +123,8 @@ class Config(dict):
|
||||
}
|
||||
|
||||
if self.dvc_dir is not None:
|
||||
files["repo"] = os.path.join(self.dvc_dir, self.CONFIG)
|
||||
files["local"] = os.path.join(self.dvc_dir, self.CONFIG_LOCAL)
|
||||
files["repo"] = self.fs.path.join(self.dvc_dir, self.CONFIG)
|
||||
files["local"] = self.fs.path.join(self.dvc_dir, self.CONFIG_LOCAL)
|
||||
|
||||
return files
|
||||
|
||||
@@ -188,7 +187,7 @@ class Config(dict):
|
||||
filename = self.files[level]
|
||||
fs = self._get_fs(level)
|
||||
|
||||
logger.debug(f"Writing '{filename}'.")
|
||||
logger.debug("Writing '%s'.", filename)
|
||||
|
||||
fs.makedirs(os.path.dirname(filename))
|
||||
|
||||
@@ -228,7 +227,7 @@ class Config(dict):
|
||||
|
||||
@staticmethod
|
||||
def _to_relpath(conf_dir, path):
|
||||
from dvc.fs.local import localfs
|
||||
from dvc.fs import localfs
|
||||
from dvc.utils import relpath
|
||||
|
||||
from .config_schema import RelPath
|
||||
|
||||
@@ -152,6 +152,8 @@ SCHEMA = {
|
||||
"ssl_verify": Any(Bool, str),
|
||||
"sse": str,
|
||||
"sse_kms_key_id": str,
|
||||
"sse_customer_algorithm": str,
|
||||
"sse_customer_key": str,
|
||||
"acl": str,
|
||||
"grant_read": str,
|
||||
"grant_read_acp": str,
|
||||
@@ -166,6 +168,7 @@ SCHEMA = {
|
||||
"gs": {
|
||||
"projectname": str,
|
||||
"credentialpath": str,
|
||||
"endpointurl": str,
|
||||
Optional("verify", default=False): Bool,
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
@@ -217,6 +220,7 @@ SCHEMA = {
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
"gdrive": {
|
||||
"profile": str,
|
||||
"gdrive_use_service_account": Bool,
|
||||
"gdrive_client_id": str,
|
||||
"gdrive_client_secret": str,
|
||||
@@ -224,6 +228,7 @@ SCHEMA = {
|
||||
"gdrive_service_account_user_email": str,
|
||||
"gdrive_service_account_json_file_path": str,
|
||||
Optional("gdrive_trash_only", default=False): Bool,
|
||||
Optional("gdrive_acknowledge_abuse", default=False): Bool,
|
||||
Optional("verify", default=True): Bool,
|
||||
**REMOTE_COMMON,
|
||||
},
|
||||
@@ -269,6 +274,7 @@ SCHEMA = {
|
||||
"plots": {
|
||||
"html_template": str,
|
||||
Optional("auto_open", default=False): Bool,
|
||||
"out_dir": str,
|
||||
},
|
||||
"exp": {
|
||||
"code": str,
|
||||
@@ -279,4 +285,8 @@ SCHEMA = {
|
||||
"plots": str,
|
||||
"live": str,
|
||||
},
|
||||
"parsing": {
|
||||
"bool": All(Lower, Choices("store_true", "boolean_optional")),
|
||||
"list": All(Lower, Choices("nargs", "append")),
|
||||
},
|
||||
}
|
||||
|
||||
+7
-4
@@ -6,6 +6,7 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
from subprocess import Popen
|
||||
from typing import List
|
||||
|
||||
from dvc.env import DVC_DAEMON
|
||||
from dvc.utils import fix_env, is_binary
|
||||
@@ -81,7 +82,7 @@ def _spawn_posix(cmd, env):
|
||||
|
||||
|
||||
def _spawn(cmd, env):
|
||||
logger.debug(f"Trying to spawn '{cmd}'")
|
||||
logger.debug("Trying to spawn '%s'", cmd)
|
||||
|
||||
if os.name == "nt":
|
||||
_spawn_windows(cmd, env)
|
||||
@@ -90,7 +91,7 @@ def _spawn(cmd, env):
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
logger.debug(f"Spawned '{cmd}'")
|
||||
logger.debug("Spawned '%s'", cmd)
|
||||
|
||||
|
||||
def daemon(args):
|
||||
@@ -99,12 +100,14 @@ def daemon(args):
|
||||
Args:
|
||||
args (list): list of arguments to append to `dvc daemon` command.
|
||||
"""
|
||||
daemonize(["daemon", "-q"] + args)
|
||||
|
||||
|
||||
def daemonize(cmd: List[str]):
|
||||
if os.environ.get(DVC_DAEMON):
|
||||
logger.debug("skipping launching a new daemon.")
|
||||
return
|
||||
|
||||
cmd = ["daemon", "-q"] + args
|
||||
|
||||
env = fix_env()
|
||||
if not is_binary():
|
||||
file_path = os.path.abspath(inspect.stack()[0][1])
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterator, Union
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check(odb: "ObjectDB", obj: "HashFile", **kwargs):
|
||||
if isinstance(obj, Tree):
|
||||
for _, _, oid in obj:
|
||||
odb.check(oid, **kwargs)
|
||||
|
||||
odb.check(obj.hash_info, **kwargs)
|
||||
|
||||
|
||||
def load(odb: "ObjectDB", hash_info: "HashInfo") -> "HashFile":
|
||||
if hash_info.isdir:
|
||||
return Tree.load(odb, hash_info)
|
||||
return odb.get(hash_info)
|
||||
|
||||
|
||||
def iterobjs(
|
||||
obj: Union["Tree", "HashFile"]
|
||||
) -> Iterator[Union["Tree", "HashFile"]]:
|
||||
if isinstance(obj, Tree):
|
||||
yield from (entry_obj for _, entry_obj in obj)
|
||||
yield obj
|
||||
@@ -1,33 +0,0 @@
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
|
||||
class QueryingProgress(Tqdm):
|
||||
def __init__(self, iterable=None, total=None, name=None, phase="Querying"):
|
||||
msg_part = "cache in " + f"'{name}'" if name else "remote cache"
|
||||
msg_fmt = "{phase} " + msg_part
|
||||
|
||||
self._estimating_msg = msg_fmt.format(phase="Estimating size of")
|
||||
self._listing_msg = msg_fmt.format(phase="Querying")
|
||||
self.desc = desc = msg_fmt.format(phase=phase)
|
||||
super().__init__(
|
||||
iterable=iterable,
|
||||
desc=desc,
|
||||
total=total,
|
||||
unit="files",
|
||||
unit_scale=False,
|
||||
bar_format=self.BAR_FMT_DEFAULT,
|
||||
)
|
||||
|
||||
def callback(self, phase, *args):
|
||||
total = args[0] if args else self.total
|
||||
completed = args[1] if len(args) > 1 else self.n
|
||||
desc = self.desc
|
||||
if phase == "estimating":
|
||||
desc = self._estimating_msg
|
||||
elif phase == "querying":
|
||||
desc = self._listing_msg
|
||||
|
||||
if desc != self.desc:
|
||||
# let Tqdm take care of when to update
|
||||
self.set_description_str(desc, refresh=False)
|
||||
self.update_to(completed, total)
|
||||
@@ -1,265 +0,0 @@
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.exceptions import CacheLinkError, CheckoutError, ConfirmRemoveError
|
||||
from dvc.fs.utils import test_links, transfer
|
||||
from dvc.ignore import DvcIgnoreFilter
|
||||
from dvc.types import Optional
|
||||
|
||||
from .diff import ROOT
|
||||
from .diff import diff as odiff
|
||||
from .slow_link_detection import slow_link_guard # type: ignore[attr-defined]
|
||||
from .stage import stage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _remove(fs_path, fs, in_cache, force=False):
|
||||
if not fs.exists(fs_path):
|
||||
return
|
||||
|
||||
if force:
|
||||
fs.remove(fs_path)
|
||||
return
|
||||
|
||||
if not in_cache:
|
||||
msg = (
|
||||
f"file/directory '{fs_path}' is going to be removed. "
|
||||
"Are you sure you want to proceed?"
|
||||
)
|
||||
|
||||
if not prompt.confirm(msg):
|
||||
raise ConfirmRemoveError(fs_path)
|
||||
|
||||
fs.remove(fs_path)
|
||||
|
||||
|
||||
def _relink(link, cache, cache_info, fs, path, in_cache, force):
|
||||
_remove(path, fs, in_cache, force=force)
|
||||
link(cache, cache_info, fs, path)
|
||||
# NOTE: Depending on a file system (e.g. on NTFS), `_remove` might reset
|
||||
# read-only permissions in order to delete a hardlink to protected object,
|
||||
# which will also reset it for the object itself, making it unprotected,
|
||||
# so we need to protect it back.
|
||||
cache.protect(cache_info)
|
||||
|
||||
|
||||
def _checkout_file(
|
||||
link,
|
||||
fs_path,
|
||||
fs,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
state=None,
|
||||
):
|
||||
"""The file is changed we need to checkout a new copy"""
|
||||
modified = False
|
||||
|
||||
cache_fs_path = cache.hash_to_path(change.new.oid.value)
|
||||
if change.old.oid:
|
||||
if relink:
|
||||
if fs.iscopy(fs_path) and cache.cache_types[0] == "copy":
|
||||
cache.unprotect(fs_path)
|
||||
else:
|
||||
_relink(
|
||||
link,
|
||||
cache,
|
||||
cache_fs_path,
|
||||
fs,
|
||||
fs_path,
|
||||
change.old.in_cache,
|
||||
force=force,
|
||||
)
|
||||
else:
|
||||
modified = True
|
||||
_relink(
|
||||
link,
|
||||
cache,
|
||||
cache_fs_path,
|
||||
fs,
|
||||
fs_path,
|
||||
change.old.in_cache,
|
||||
force=force,
|
||||
)
|
||||
else:
|
||||
link(cache, cache_fs_path, fs, fs_path)
|
||||
modified = True
|
||||
|
||||
if state:
|
||||
state.save(fs_path, fs, change.new.oid)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(fs_path)
|
||||
|
||||
return modified
|
||||
|
||||
|
||||
def _diff(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
relink=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
):
|
||||
old = None
|
||||
try:
|
||||
_, _, old = stage(
|
||||
cache,
|
||||
fs_path,
|
||||
fs,
|
||||
obj.hash_info.name if obj else cache.fs.PARAM_CHECKSUM,
|
||||
dry_run=True,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
diff = odiff(old, obj, cache)
|
||||
|
||||
if relink:
|
||||
diff.modified.extend(diff.unchanged)
|
||||
|
||||
return diff
|
||||
|
||||
|
||||
class Link:
|
||||
def __init__(self, links):
|
||||
self._links = links
|
||||
|
||||
@slow_link_guard
|
||||
def __call__(self, cache, from_path, to_fs, to_path):
|
||||
if to_fs.exists(to_path):
|
||||
to_fs.remove(to_path) # broken symlink
|
||||
|
||||
cache.makedirs(cache.fs.path.parent(to_path))
|
||||
try:
|
||||
transfer(cache.fs, from_path, to_fs, to_path, links=self._links)
|
||||
except FileNotFoundError as exc:
|
||||
raise CheckoutError([to_path]) from exc
|
||||
except OSError as exc:
|
||||
raise CacheLinkError([to_path]) from exc
|
||||
|
||||
|
||||
def _checkout(
|
||||
diff,
|
||||
fs_path,
|
||||
fs,
|
||||
cache,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
state=None,
|
||||
):
|
||||
if not diff:
|
||||
return
|
||||
|
||||
links = test_links(cache.cache_types, cache.fs, cache.fs_path, fs, fs_path)
|
||||
if not links:
|
||||
raise CacheLinkError([fs_path])
|
||||
link = Link(links)
|
||||
|
||||
for change in diff.deleted:
|
||||
entry_path = (
|
||||
fs.path.join(fs_path, *change.old.key)
|
||||
if change.old.key != ROOT
|
||||
else fs_path
|
||||
)
|
||||
_remove(entry_path, fs, change.old.in_cache, force=force)
|
||||
|
||||
failed = []
|
||||
for change in chain(diff.added, diff.modified):
|
||||
entry_path = (
|
||||
fs.path.join(fs_path, *change.new.key)
|
||||
if change.new.key != ROOT
|
||||
else fs_path
|
||||
)
|
||||
if change.new.oid.isdir:
|
||||
fs.makedirs(entry_path)
|
||||
continue
|
||||
|
||||
try:
|
||||
_checkout_file(
|
||||
link,
|
||||
entry_path,
|
||||
fs,
|
||||
change,
|
||||
cache,
|
||||
force,
|
||||
progress_callback,
|
||||
relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
|
||||
|
||||
def checkout(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
force=False,
|
||||
progress_callback=None,
|
||||
relink=False,
|
||||
quiet=False,
|
||||
dvcignore: Optional[DvcIgnoreFilter] = None,
|
||||
state=None,
|
||||
):
|
||||
# if scheme(fs_path) not in ["local", cache.fs.scheme]:
|
||||
# raise NotImplementedError
|
||||
|
||||
diff = _diff(
|
||||
fs_path,
|
||||
fs,
|
||||
obj,
|
||||
cache,
|
||||
relink=relink,
|
||||
dvcignore=dvcignore,
|
||||
)
|
||||
|
||||
failed = []
|
||||
if not obj:
|
||||
if not quiet:
|
||||
logger.warning(
|
||||
"No file hash info found for '%s'. It won't be created.",
|
||||
fs_path,
|
||||
)
|
||||
failed.append(fs_path)
|
||||
elif not diff:
|
||||
logger.trace("Data '%s' didn't change.", fs_path) # type: ignore
|
||||
|
||||
try:
|
||||
_checkout(
|
||||
diff,
|
||||
fs_path,
|
||||
fs,
|
||||
cache,
|
||||
force=force,
|
||||
progress_callback=progress_callback,
|
||||
relink=relink,
|
||||
state=state,
|
||||
)
|
||||
except CheckoutError as exc:
|
||||
failed.extend(exc.target_infos)
|
||||
|
||||
if diff and state:
|
||||
state.save_link(fs_path, fs)
|
||||
if not failed:
|
||||
state.save(fs_path, fs, obj.hash_info)
|
||||
|
||||
if failed or not diff:
|
||||
if progress_callback and obj:
|
||||
progress_callback(fs_path, len(obj))
|
||||
if failed:
|
||||
raise CheckoutError(failed)
|
||||
return
|
||||
|
||||
return bool(diff) and not relink
|
||||
@@ -1,149 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Iterable, Set
|
||||
|
||||
from dvc.objects.errors import ObjectDBError
|
||||
from dvc.utils.decorators import with_diskcache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ObjectDBIndexBase(ABC):
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __iter__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __contains__(self, hash_):
|
||||
pass
|
||||
|
||||
def hashes(self):
|
||||
return iter(self)
|
||||
|
||||
@abstractmethod
|
||||
def dir_hashes(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def intersection(self, hashes: Set[str]):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectDBIndexNoop(ObjectDBIndexBase):
|
||||
"""No-op class for ODBs which are not indexed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
): # pylint: disable=super-init-not-called
|
||||
pass
|
||||
|
||||
def __iter__(self):
|
||||
return iter([])
|
||||
|
||||
def __contains__(self, hash_):
|
||||
return False
|
||||
|
||||
def dir_hashes(self):
|
||||
return []
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
pass
|
||||
|
||||
def intersection(self, hashes: Set[str]):
|
||||
return []
|
||||
|
||||
|
||||
class ObjectDBIndex(ObjectDBIndexBase):
|
||||
"""Class for indexing hashes in an ODB."""
|
||||
|
||||
INDEX_SUFFIX = ".idx"
|
||||
INDEX_DIR = "index"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tmp_dir: "StrPath",
|
||||
name: str,
|
||||
): # pylint: disable=super-init-not-called
|
||||
from diskcache import Cache, Index
|
||||
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
self.index_dir = os.path.join(tmp_dir, self.INDEX_DIR, name)
|
||||
makedirs(self.index_dir, exist_ok=True)
|
||||
self.fs = LocalFileSystem()
|
||||
self.index = Index.fromcache(
|
||||
Cache(
|
||||
self.index_dir,
|
||||
disk_pickle_protocol=4,
|
||||
eviction_policy="none",
|
||||
)
|
||||
)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def __iter__(self):
|
||||
return iter(self.index)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def __contains__(self, hash_):
|
||||
return hash_ in self.index
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def dir_hashes(self):
|
||||
"""Iterate over .dir hashes stored in the index."""
|
||||
yield from (hash_ for hash_, is_dir in self.index.items() if is_dir)
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def clear(self):
|
||||
"""Clear this index (to force re-indexing later)."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
self.index.clear()
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to clear ODB index") from exc
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def update(self, dir_hashes: Iterable[str], file_hashes: Iterable[str]):
|
||||
"""Update this index, adding the specified hashes."""
|
||||
from diskcache import Timeout
|
||||
|
||||
try:
|
||||
with self.index.transact():
|
||||
for hash_ in dir_hashes:
|
||||
self.index[hash_] = True
|
||||
with self.index.transact():
|
||||
for hash_ in file_hashes:
|
||||
self.index[hash_] = False
|
||||
except Timeout as exc:
|
||||
raise ObjectDBError("Failed to update ODB index") from exc
|
||||
|
||||
@with_diskcache(name="index")
|
||||
def intersection(self, hashes: Set[str]):
|
||||
"""Iterate over values from `hashes` which exist in the index."""
|
||||
yield from hashes.intersection(self.index.keys())
|
||||
@@ -1,166 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from functools import partial
|
||||
|
||||
from funcy import cached_property
|
||||
from shortuuid import uuid
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB, noop, wrap_iter
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import copyfile, remove, umask, walk_files
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalObjectDB(ObjectDB):
|
||||
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
|
||||
CACHE_MODE = 0o444
|
||||
UNPACKED_DIR_SUFFIX = ".unpacked"
|
||||
|
||||
def __init__(self, fs, fs_path, **config):
|
||||
super().__init__(fs, fs_path, **config)
|
||||
self.cache_dir = fs_path
|
||||
|
||||
shared = config.get("shared")
|
||||
if shared:
|
||||
self._file_mode = 0o664
|
||||
self._dir_mode = 0o2775
|
||||
else:
|
||||
self._file_mode = 0o666 & ~umask
|
||||
self._dir_mode = 0o777 & ~umask
|
||||
|
||||
@property
|
||||
def cache_dir(self):
|
||||
return self.fs_path if self.fs_path else None
|
||||
|
||||
@cache_dir.setter
|
||||
def cache_dir(self, value):
|
||||
self.fs_path = value
|
||||
|
||||
@cached_property
|
||||
def cache_path(self):
|
||||
return os.path.abspath(self.cache_dir)
|
||||
|
||||
def move(self, from_info, to_info):
|
||||
super().move(from_info, to_info)
|
||||
os.chmod(to_info, self._file_mode)
|
||||
|
||||
def makedirs(self, fs_path):
|
||||
from dvc.utils.fs import makedirs
|
||||
|
||||
makedirs(fs_path, exist_ok=True, mode=self._dir_mode)
|
||||
|
||||
def hash_to_path(self, hash_):
|
||||
# NOTE: `self.cache_path` is already normalized so we can simply use
|
||||
# `os.sep` instead of `os.path.join`. This results in this helper
|
||||
# being ~5.5 times faster.
|
||||
return f"{self.cache_path}{os.sep}{hash_[0:2]}{os.sep}{hash_[2:]}"
|
||||
|
||||
def hashes_exist(
|
||||
self, hashes, jobs=None, progress=noop
|
||||
): # pylint: disable=unused-argument
|
||||
ret = []
|
||||
progress = partial(progress, "querying", len(hashes))
|
||||
|
||||
for hash_ in wrap_iter(hashes, progress):
|
||||
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, hash_)
|
||||
try:
|
||||
self.check(hash_info)
|
||||
ret.append(hash_)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
|
||||
return ret
|
||||
|
||||
def _list_paths(self, prefix=None):
|
||||
assert self.fs_path is not None
|
||||
if prefix:
|
||||
fs_path = self.fs.path.join(self.fs_path, prefix[:2])
|
||||
if not self.fs.exists(fs_path):
|
||||
return
|
||||
else:
|
||||
fs_path = self.fs_path
|
||||
|
||||
# NOTE: use utils.fs walk_files since fs.walk_files will not follow
|
||||
# symlinks
|
||||
yield from walk_files(fs_path)
|
||||
|
||||
def _remove_unpacked_dir(self, hash_):
|
||||
hash_fs_path = self.hash_to_path(hash_)
|
||||
fs_path = self.fs.path.with_name(
|
||||
hash_fs_path,
|
||||
self.fs.path.name(hash_fs_path) + self.UNPACKED_DIR_SUFFIX,
|
||||
)
|
||||
self.fs.remove(fs_path)
|
||||
|
||||
def _unprotect_file(self, path):
|
||||
if self.fs.is_symlink(path) or self.fs.is_hardlink(path):
|
||||
logger.debug("Unprotecting '%s'", path)
|
||||
tmp = os.path.join(os.path.dirname(path), "." + uuid())
|
||||
|
||||
# The operations order is important here - if some application
|
||||
# would access the file during the process of copyfile then it
|
||||
# would get only the part of file. So, at first, the file should be
|
||||
# copied with the temporary name, and then original file should be
|
||||
# replaced by new.
|
||||
copyfile(path, tmp, name=f"Unprotecting '{relpath(path)}'")
|
||||
remove(path)
|
||||
os.rename(tmp, path)
|
||||
|
||||
else:
|
||||
logger.debug(
|
||||
"Skipping copying for '%s', since it is not "
|
||||
"a symlink or a hardlink.",
|
||||
path,
|
||||
)
|
||||
|
||||
os.chmod(path, self._file_mode)
|
||||
|
||||
def _unprotect_dir(self, path):
|
||||
for fname in self.fs.find(path):
|
||||
self._unprotect_file(fname)
|
||||
|
||||
def unprotect(self, fs_path):
|
||||
if not os.path.exists(fs_path):
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
raise DvcException(
|
||||
f"can't unprotect non-existing data '{fs_path}'"
|
||||
)
|
||||
|
||||
if os.path.isdir(fs_path):
|
||||
self._unprotect_dir(fs_path)
|
||||
else:
|
||||
self._unprotect_file(fs_path)
|
||||
|
||||
def protect(self, fs_path):
|
||||
try:
|
||||
os.chmod(fs_path, self.CACHE_MODE)
|
||||
except OSError:
|
||||
# NOTE: not being able to protect cache file is not fatal, it
|
||||
# might happen on funky filesystems (e.g. Samba, see #5255),
|
||||
# read-only filesystems or in a shared cache scenario.
|
||||
logger.trace("failed to protect '%s'", fs_path, exc_info=True)
|
||||
|
||||
def is_protected(self, fs_path):
|
||||
try:
|
||||
mode = os.stat(fs_path).st_mode
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
return stat.S_IMODE(mode) == self.CACHE_MODE
|
||||
|
||||
def set_exec(self, fs_path):
|
||||
mode = os.stat(fs_path).st_mode | stat.S_IEXEC
|
||||
try:
|
||||
os.chmod(fs_path, mode)
|
||||
except OSError:
|
||||
logger.trace(
|
||||
"failed to chmod '%s' '%s'",
|
||||
oct(mode),
|
||||
fs_path,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -1,84 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ..reference import ReferenceHashFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import AnyFSPath, FileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceObjectDB(ObjectDB):
|
||||
"""Reference ODB.
|
||||
|
||||
File objects are stored as ReferenceHashFiles which reference paths outside
|
||||
of the staging ODB fs. Tree objects are stored natively.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._fs_cache: Dict[tuple, "FileSystem"] = {}
|
||||
self._obj_cache: Dict["HashInfo", "ReferenceHashFile"] = {}
|
||||
|
||||
def get(self, hash_info: "HashInfo"):
|
||||
if hash_info.isdir:
|
||||
return super().get(hash_info)
|
||||
try:
|
||||
return self._obj_cache[hash_info]
|
||||
except KeyError:
|
||||
pass
|
||||
fs_path = self.hash_to_path(hash_info.value)
|
||||
try:
|
||||
ref_file = ReferenceHashFile.from_bytes(
|
||||
self.fs.cat_file(fs_path),
|
||||
fs_cache=self._fs_cache,
|
||||
)
|
||||
except OSError:
|
||||
raise FileNotFoundError
|
||||
try:
|
||||
ref_file.check(self, check_hash=False)
|
||||
except ObjectFormatError:
|
||||
self.fs.remove(fs_path)
|
||||
raise
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
return ref_file
|
||||
|
||||
def _add_file(
|
||||
self,
|
||||
from_fs: "FileSystem",
|
||||
from_info: "AnyFSPath",
|
||||
to_info: "AnyFSPath",
|
||||
hash_info: "HashInfo",
|
||||
hardlink: bool = False,
|
||||
):
|
||||
self.makedirs(self.fs.path.parent(to_info))
|
||||
if hash_info.isdir:
|
||||
return super()._add_file(
|
||||
from_fs,
|
||||
from_info,
|
||||
to_info,
|
||||
hash_info,
|
||||
hardlink=hardlink,
|
||||
)
|
||||
ref_file = ReferenceHashFile(from_info, from_fs, hash_info)
|
||||
self._obj_cache[hash_info] = ref_file
|
||||
try:
|
||||
self.fs.pipe_file(to_info, ref_file.to_bytes())
|
||||
except OSError as exc:
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_info)
|
||||
else:
|
||||
raise
|
||||
if from_fs.scheme != Schemes.LOCAL:
|
||||
self._fs_cache[ReferenceHashFile.config_tuple(from_fs)] = from_fs
|
||||
@@ -1,133 +0,0 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, List, Optional, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
ADD = "add"
|
||||
MODIFY = "modify"
|
||||
DELETE = "delete"
|
||||
UNCHANGED = "unchanged"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeEntry:
|
||||
in_cache: bool
|
||||
key: Tuple[str]
|
||||
oid: Optional["HashInfo"] = field(default=None)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.oid)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, TreeEntry):
|
||||
return False
|
||||
|
||||
if self.key != other.key:
|
||||
return False
|
||||
|
||||
return self.oid == other.oid
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
old: TreeEntry
|
||||
new: TreeEntry
|
||||
|
||||
@property
|
||||
def typ(self):
|
||||
if not self.old and not self.new:
|
||||
return UNCHANGED
|
||||
|
||||
if self.old and not self.new:
|
||||
return DELETE
|
||||
|
||||
if not self.old and self.new:
|
||||
return ADD
|
||||
|
||||
if self.old != self.new:
|
||||
return MODIFY
|
||||
|
||||
return UNCHANGED
|
||||
|
||||
def __bool__(self):
|
||||
return self.typ != UNCHANGED
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffResult:
|
||||
added: List[Change] = field(default_factory=list, compare=True)
|
||||
modified: List[Change] = field(default_factory=list, compare=True)
|
||||
deleted: List[Change] = field(default_factory=list, compare=True)
|
||||
unchanged: List[Change] = field(default_factory=list, compare=True)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.added or self.modified or self.deleted)
|
||||
|
||||
|
||||
ROOT = ("",)
|
||||
|
||||
|
||||
def diff(
|
||||
old: Optional["HashFile"], new: Optional["HashFile"], cache
|
||||
) -> DiffResult:
|
||||
from .tree import Tree
|
||||
|
||||
if old is None and new is None:
|
||||
return DiffResult()
|
||||
|
||||
def _get_keys(obj):
|
||||
if not obj:
|
||||
return []
|
||||
return [ROOT] + (
|
||||
[key for key, _, _ in obj] if isinstance(obj, Tree) else []
|
||||
)
|
||||
|
||||
old_keys = set(_get_keys(old))
|
||||
new_keys = set(_get_keys(new))
|
||||
|
||||
def _get_oid(obj, key):
|
||||
if not obj or key == ROOT:
|
||||
return obj.hash_info if obj else None
|
||||
|
||||
entry_obj = obj.get(cache, key)
|
||||
return entry_obj.hash_info if entry_obj else None
|
||||
|
||||
def _in_cache(oid, cache):
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
if not oid:
|
||||
return False
|
||||
|
||||
try:
|
||||
cache.check(oid)
|
||||
return True
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
return False
|
||||
|
||||
ret = DiffResult()
|
||||
for key in old_keys | new_keys:
|
||||
old_oid = _get_oid(old, key)
|
||||
new_oid = _get_oid(new, key)
|
||||
|
||||
change = Change(
|
||||
old=TreeEntry(_in_cache(old_oid, cache), key, old_oid),
|
||||
new=TreeEntry(_in_cache(new_oid, cache), key, new_oid),
|
||||
)
|
||||
|
||||
if change.typ == ADD:
|
||||
ret.added.append(change)
|
||||
elif change.typ == MODIFY:
|
||||
ret.modified.append(change)
|
||||
elif change.typ == DELETE:
|
||||
ret.deleted.append(change)
|
||||
else:
|
||||
assert change.typ == UNCHANGED
|
||||
if not change.new.in_cache and not (
|
||||
change.new.oid and change.new.oid.isdir
|
||||
):
|
||||
ret.modified.append(change)
|
||||
else:
|
||||
ret.unchanged.append(change)
|
||||
return ret
|
||||
@@ -1,40 +0,0 @@
|
||||
def gc(odb, used, jobs=None, cache_odb=None, shallow=True):
|
||||
from dvc.data.tree import Tree
|
||||
from dvc.objects.errors import ObjectDBPermissionError
|
||||
|
||||
from ._progress import QueryingProgress
|
||||
|
||||
if odb.read_only:
|
||||
raise ObjectDBPermissionError("Cannot gc read-only ODB")
|
||||
if not cache_odb:
|
||||
cache_odb = odb
|
||||
used_hashes = set()
|
||||
for hash_info in used:
|
||||
used_hashes.add(hash_info.value)
|
||||
if hash_info.isdir and not shallow:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
used_hashes.update(
|
||||
entry_obj.hash_info.value for _, entry_obj in tree
|
||||
)
|
||||
|
||||
def _is_dir_hash(_hash):
|
||||
from dvc.hash_info import HASH_DIR_SUFFIX
|
||||
|
||||
return _hash.endswith(HASH_DIR_SUFFIX)
|
||||
|
||||
removed = False
|
||||
# hashes must be sorted to ensure we always remove .dir files first
|
||||
|
||||
hashes = QueryingProgress(odb.all(jobs), name=odb.fs_path)
|
||||
for hash_ in sorted(hashes, key=_is_dir_hash, reverse=True):
|
||||
if hash_ in used_hashes:
|
||||
continue
|
||||
fs_path = odb.hash_to_path(hash_)
|
||||
if _is_dir_hash(hash_):
|
||||
# backward compatibility
|
||||
# pylint: disable=protected-access
|
||||
odb._remove_unpacked_dir(hash_)
|
||||
odb.fs.remove(fs_path)
|
||||
removed = True
|
||||
|
||||
return removed
|
||||
@@ -1,47 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meta:
|
||||
PARAM_SIZE = "size"
|
||||
PARAM_NFILES = "nfiles"
|
||||
PARAM_ISEXEC = "isexec"
|
||||
|
||||
size: Optional[int] = field(default=None)
|
||||
nfiles: Optional[int] = field(default=None)
|
||||
isexec: Optional[bool] = field(default=False)
|
||||
|
||||
obj: Optional["HashFile"] = field(default=None)
|
||||
odb: Optional["ObjectDB"] = field(default=None)
|
||||
remote: Optional[str] = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
if not d:
|
||||
return cls()
|
||||
|
||||
size = d.pop(cls.PARAM_SIZE, None)
|
||||
nfiles = d.pop(cls.PARAM_NFILES, None)
|
||||
isexec = d.pop(cls.PARAM_ISEXEC, False)
|
||||
|
||||
return cls(size=size, nfiles=nfiles, isexec=isexec)
|
||||
|
||||
def to_dict(self):
|
||||
ret = OrderedDict()
|
||||
|
||||
if self.size is not None:
|
||||
ret[self.PARAM_SIZE] = self.size
|
||||
|
||||
if self.nfiles is not None:
|
||||
ret[self.PARAM_NFILES] = self.nfiles
|
||||
|
||||
if self.isexec:
|
||||
ret[self.PARAM_ISEXEC] = self.isexec
|
||||
|
||||
return ret
|
||||
@@ -1,113 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import AnyFSPath, FileSystem
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReferenceHashFile(HashFile):
|
||||
PARAM_PATH = "path"
|
||||
PARAM_HASH = "hash"
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
PARAM_FS_CONFIG = "fs_config"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fs_path: "AnyFSPath",
|
||||
fs: "FileSystem",
|
||||
hash_info: "HashInfo",
|
||||
checksum: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(fs_path, fs, hash_info, **kwargs)
|
||||
self.checksum = checksum or fs.checksum(fs_path)
|
||||
|
||||
def __str__(self):
|
||||
return f"ref object {self.hash_info} -> {self.fs_path}"
|
||||
|
||||
def check(self, odb: "ObjectDB", check_hash: bool = True):
|
||||
assert self.fs
|
||||
if not self.fs.exists(self.fs_path):
|
||||
raise FileNotFoundError(
|
||||
errno.ENOENT, os.strerror(errno.ENOENT), self.fs_path
|
||||
)
|
||||
if self.checksum != self._get_checksum():
|
||||
raise ObjectFormatError(f"{self} is changed")
|
||||
if check_hash:
|
||||
self._check_hash(odb)
|
||||
|
||||
def _get_checksum(self) -> str:
|
||||
assert self.fs
|
||||
return self.fs.checksum(self.fs_path)
|
||||
|
||||
def to_bytes(self):
|
||||
# NOTE: dumping reference FS's this way is insecure, as the
|
||||
# fully parsed remote FS config will include credentials
|
||||
#
|
||||
# ReferenceHashFiles should currently only be serialized in
|
||||
# memory and not to disk
|
||||
fs_path = self.fs_path
|
||||
dict_ = {
|
||||
self.PARAM_PATH: fs_path,
|
||||
self.PARAM_HASH: self.hash_info,
|
||||
self.PARAM_CHECKSUM: self.checksum,
|
||||
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
|
||||
}
|
||||
try:
|
||||
return pickle.dumps(dict_)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError(f"Could not pickle {self}") from exc
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, data: bytes, fs_cache: Optional[dict] = None):
|
||||
from dvc.fs import get_fs_cls
|
||||
from dvc.fs.repo import RepoFileSystem, _RepoFileSystem
|
||||
|
||||
try:
|
||||
dict_ = pickle.loads(data)
|
||||
except pickle.PickleError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
try:
|
||||
fs_path = dict_[cls.PARAM_PATH]
|
||||
hash_info = dict_[cls.PARAM_HASH]
|
||||
except KeyError as exc:
|
||||
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
|
||||
|
||||
scheme, config_pairs = dict_.get(cls.PARAM_FS_CONFIG)
|
||||
fs = fs_cache.get((scheme, config_pairs)) if fs_cache else None
|
||||
if not fs:
|
||||
config = dict(config_pairs)
|
||||
if _RepoFileSystem.PARAM_REPO_URL in config:
|
||||
fs_cls = RepoFileSystem
|
||||
else:
|
||||
fs_cls = get_fs_cls(config, scheme=scheme)
|
||||
fs = fs_cls(**config)
|
||||
return ReferenceHashFile(
|
||||
fs_path,
|
||||
fs,
|
||||
hash_info,
|
||||
checksum=dict_.get(cls.PARAM_CHECKSUM),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def config_tuple(fs: "FileSystem"):
|
||||
return (
|
||||
fs.scheme,
|
||||
tuple(
|
||||
(key, value)
|
||||
for key, value in sorted(
|
||||
fs.config.items(), key=lambda item: item[0]
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# type: ignore
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
import colorama
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
this = sys.modules[__name__]
|
||||
|
||||
this.timeout_seconds = 10.0
|
||||
this.already_displayed = False
|
||||
this.message = (
|
||||
"You can cut execution time considerably by using a different "
|
||||
"`cache.type` configuration.\n"
|
||||
"See {blue}https://dvc.org/doc/commands-reference/config#cache{reset} "
|
||||
"for more information.\n"
|
||||
"To disable this message, run:\n"
|
||||
"`dvc config cache.slow_link_warning false`".format(
|
||||
blue=colorama.Fore.BLUE, reset=colorama.Fore.RESET
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def slow_link_guard(f):
|
||||
@wraps(f)
|
||||
def wrapper(self, odb, *args, **kwargs):
|
||||
if this.already_displayed:
|
||||
return f(self, odb, *args, **kwargs)
|
||||
|
||||
if not odb.slow_link_warning or odb.cache_types:
|
||||
return f(self, odb, *args, **kwargs)
|
||||
|
||||
start = time.time()
|
||||
result = f(self, odb, *args, **kwargs)
|
||||
delta = time.time() - start
|
||||
|
||||
if delta >= this.timeout_seconds:
|
||||
logger.warning(this.message)
|
||||
this.already_displayed = True
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
@@ -1,352 +0,0 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Tuple
|
||||
|
||||
from dvc.exceptions import DvcIgnoreInCollectedDirError
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.ignore import DvcIgnore
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils import file_md5, is_exec
|
||||
|
||||
from .db.reference import ReferenceObjectDB
|
||||
from .meta import Meta
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.base import AnyFSPath, FileSystem
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_STAGING_MEMFS_PATH = "dvc-staging"
|
||||
|
||||
|
||||
def _upload_file(from_fs_path, fs, odb, upload_odb, callback=None):
|
||||
from dvc.fs._callback import FsspecCallback
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.stream import HashedStreamReader
|
||||
|
||||
fs_path = upload_odb.fs.path
|
||||
tmp_info = fs_path.join(upload_odb.fs_path, tmp_fname())
|
||||
with fs.open(from_fs_path, mode="rb") as stream:
|
||||
stream = HashedStreamReader(stream)
|
||||
size = fs.size(from_fs_path)
|
||||
with FsspecCallback.as_tqdm_callback(
|
||||
callback,
|
||||
desc=fs_path.name(from_fs_path),
|
||||
bytes=True,
|
||||
total=size,
|
||||
) as cb:
|
||||
upload_odb.fs.upload(stream, tmp_info, size=size, callback=cb)
|
||||
|
||||
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
|
||||
meta = Meta(size=size)
|
||||
return from_fs_path, meta, odb.get(stream.hash_info)
|
||||
|
||||
|
||||
def _adapt_info(info, scheme):
|
||||
if scheme == "s3" and "ETag" in info:
|
||||
info["etag"] = info["ETag"].strip('"')
|
||||
elif scheme == "gs" and "etag" in info:
|
||||
import base64
|
||||
|
||||
info["etag"] = base64.b64decode(info["etag"]).hex()
|
||||
elif scheme.startswith("http") and (
|
||||
"ETag" in info or "Content-MD5" in info
|
||||
):
|
||||
info["checksum"] = info.get("ETag") or info.get("Content-MD5")
|
||||
return info
|
||||
|
||||
|
||||
def _get_file_hash(fs_path, fs, name):
|
||||
info = _adapt_info(fs.info(fs_path), fs.scheme)
|
||||
|
||||
if name in info:
|
||||
assert not info[name].endswith(".dir")
|
||||
hash_value = info[name]
|
||||
elif hasattr(fs, name):
|
||||
func = getattr(fs, name)
|
||||
hash_value = func(fs_path)
|
||||
elif name == "md5":
|
||||
hash_value = file_md5(fs_path, fs)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
meta = Meta(size=info["size"], isexec=is_exec(info.get("mode", 0)))
|
||||
hash_info = HashInfo(name, hash_value)
|
||||
return meta, hash_info
|
||||
|
||||
|
||||
def get_file_hash(fs_path, fs, name, state=None):
|
||||
if state:
|
||||
meta, hash_info = state.get( # pylint: disable=assignment-from-none
|
||||
fs_path, fs
|
||||
)
|
||||
if hash_info:
|
||||
return meta, hash_info
|
||||
|
||||
meta, hash_info = _get_file_hash(fs_path, fs, name)
|
||||
|
||||
if state:
|
||||
assert ".dir" not in hash_info.value
|
||||
state.save(fs_path, fs, hash_info)
|
||||
|
||||
return meta, hash_info
|
||||
|
||||
|
||||
def _stage_file(fs_path, fs, name, odb=None, upload_odb=None, dry_run=False):
|
||||
state = odb.state if odb else None
|
||||
meta, hash_info = get_file_hash(fs_path, fs, name, state=state)
|
||||
if upload_odb and not dry_run:
|
||||
assert odb and name == "md5"
|
||||
return _upload_file(fs_path, fs, odb, upload_odb)
|
||||
|
||||
if dry_run:
|
||||
obj = HashFile(fs_path, fs, hash_info)
|
||||
else:
|
||||
odb.add(fs_path, fs, hash_info, hardlink=False)
|
||||
obj = odb.get(hash_info)
|
||||
|
||||
return fs_path, meta, obj
|
||||
|
||||
|
||||
def _build_objects(
|
||||
fs_path,
|
||||
fs,
|
||||
name,
|
||||
dvcignore=None,
|
||||
jobs=None,
|
||||
no_progress_bar=False,
|
||||
**kwargs,
|
||||
):
|
||||
if dvcignore:
|
||||
walk_iterator = dvcignore.find(fs, fs_path)
|
||||
else:
|
||||
walk_iterator = fs.find(fs_path)
|
||||
with Tqdm(
|
||||
unit="md5",
|
||||
desc="Computing file/dir hashes (only done once)",
|
||||
disable=no_progress_bar,
|
||||
) as pbar:
|
||||
worker = pbar.wrap_fn(
|
||||
partial(
|
||||
_stage_file,
|
||||
fs=fs,
|
||||
name=name,
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=jobs if jobs is not None else fs.hash_jobs
|
||||
) as executor:
|
||||
yield from executor.map(worker, walk_iterator)
|
||||
|
||||
|
||||
def _iter_objects(fs_path, fs, name, **kwargs):
|
||||
yield from _build_objects(fs_path, fs, name, **kwargs)
|
||||
|
||||
|
||||
def _build_tree(fs_path, fs, name, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
tree_meta = Meta(size=0, nfiles=0)
|
||||
tree = Tree(None, None, None)
|
||||
for file_fs_path, meta, obj in _iter_objects(fs_path, fs, name, **kwargs):
|
||||
if DvcIgnore.DVCIGNORE_FILE == fs.path.name(file_fs_path):
|
||||
raise DvcIgnoreInCollectedDirError(fs.path.parent(file_fs_path))
|
||||
|
||||
# NOTE: this is lossy transformation:
|
||||
# "hey\there" -> "hey/there"
|
||||
# "hey/there" -> "hey/there"
|
||||
# The latter is fine filename on Windows, which
|
||||
# will transform to dir/file on back transform.
|
||||
#
|
||||
# Yes, this is a BUG, as long as we permit "/" in
|
||||
# filenames on Windows and "\" on Unix
|
||||
|
||||
key = fs.path.relparts(file_fs_path, fs_path)
|
||||
assert key
|
||||
tree.add(key, meta, obj.hash_info)
|
||||
|
||||
tree_meta.size += meta.size or 0
|
||||
tree_meta.nfiles += 1
|
||||
|
||||
return tree_meta, tree
|
||||
|
||||
|
||||
def _stage_tree(fs_path, fs, fs_info, name, odb=None, **kwargs):
|
||||
from .tree import Tree
|
||||
|
||||
value = fs_info.get(name)
|
||||
if odb and value:
|
||||
hash_info = HashInfo(name, value)
|
||||
try:
|
||||
tree = Tree.load(odb, hash_info)
|
||||
return Meta(nfiles=len(tree)), tree
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
meta, tree = _build_tree(fs_path, fs, name, odb=odb, **kwargs)
|
||||
state = odb.state if odb and odb.state else None
|
||||
hash_info = None
|
||||
if state:
|
||||
_, hash_info = state.get( # pylint: disable=assignment-from-none
|
||||
fs_path, fs
|
||||
)
|
||||
tree.digest(hash_info=hash_info)
|
||||
odb.add(tree.fs_path, tree.fs, tree.hash_info, hardlink=False)
|
||||
raw = odb.get(tree.hash_info)
|
||||
# cleanup unneeded memfs tmpfile and return tree based on the
|
||||
# ODB fs/path
|
||||
if odb.fs != tree.fs:
|
||||
tree.fs.remove(tree.fs_path)
|
||||
tree.fs = raw.fs
|
||||
tree.fs_path = raw.fs_path
|
||||
return meta, tree
|
||||
|
||||
|
||||
_url_cache: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _make_staging_url(
|
||||
fs: "FileSystem", odb: "ObjectDB", fs_path: Optional[str]
|
||||
):
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
url = f"{Schemes.MEMORY}://{_STAGING_MEMFS_PATH}"
|
||||
|
||||
if fs_path is not None:
|
||||
if odb.fs.scheme == Schemes.LOCAL:
|
||||
fs_path = os.path.abspath(fs_path)
|
||||
|
||||
if fs_path not in _url_cache:
|
||||
_url_cache[fs_path] = hashlib.sha256(
|
||||
fs_path.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
url = fs.path.join(url, _url_cache[fs_path])
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def _get_staging(odb: "ObjectDB") -> "ObjectDB":
|
||||
"""Return an ODB that can be used for staging objects.
|
||||
|
||||
Staging will be a reference ODB stored in the the global memfs.
|
||||
"""
|
||||
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
|
||||
fs = MemoryFileSystem()
|
||||
fs_path = _make_staging_url(fs, odb, odb.fs_path)
|
||||
state = odb.state
|
||||
return ReferenceObjectDB(fs, fs_path, state=state)
|
||||
|
||||
|
||||
def _load_from_state(odb, staging, fs_path, fs, name):
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
from . import check, load
|
||||
from .tree import Tree
|
||||
|
||||
state = odb.state
|
||||
meta, hash_info = state.get(fs_path, fs)
|
||||
if hash_info:
|
||||
for odb_ in (odb, staging):
|
||||
if odb_.exists(hash_info):
|
||||
try:
|
||||
obj = load(odb_, hash_info)
|
||||
check(odb_, obj, check_hash=False)
|
||||
if isinstance(obj, Tree):
|
||||
meta.nfiles = len(obj)
|
||||
assert obj.hash_info.name == name
|
||||
return odb_, meta, obj
|
||||
except (ObjectFormatError, FileNotFoundError):
|
||||
pass
|
||||
raise FileNotFoundError
|
||||
|
||||
|
||||
def _stage_external_tree_info(odb, tree, name):
|
||||
# NOTE: used only for external outputs. Initial reasoning was to be
|
||||
# able to validate .dir files right in the workspace (e.g. check s3
|
||||
# etag), but could be dropped for manual validation with regular md5,
|
||||
# that would be universal for all clouds.
|
||||
assert odb and name != "md5"
|
||||
|
||||
odb.add(tree.fs_path, tree.fs, tree.hash_info)
|
||||
raw = odb.get(tree.hash_info)
|
||||
_, hash_info = get_file_hash(raw.fs_path, raw.fs, name, state=odb.state)
|
||||
tree.fs_path = raw.fs_path
|
||||
tree.fs = raw.fs
|
||||
tree.hash_info.name = hash_info.name
|
||||
tree.hash_info.value = hash_info.value
|
||||
if not tree.hash_info.value.endswith(".dir"):
|
||||
tree.hash_info.value += ".dir"
|
||||
return tree
|
||||
|
||||
|
||||
def stage(
|
||||
odb: "ObjectDB",
|
||||
fs_path: "AnyFSPath",
|
||||
fs: "FileSystem",
|
||||
name: str,
|
||||
upload: bool = False,
|
||||
dry_run: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple["ObjectDB", "Meta", "HashFile"]:
|
||||
"""Stage (prepare) objects from the given path for addition to an ODB.
|
||||
|
||||
Returns at tuple of (staging_odb, object) where addition to the ODB can
|
||||
be completed by transferring the object from staging to the dest ODB.
|
||||
|
||||
If dry_run is True, object hashes will be computed and returned, but file
|
||||
objects themselves will not be added to the staging ODB (i.e. the resulting
|
||||
file objects cannot transferred from staging to another ODB).
|
||||
|
||||
If upload is True, files will be uploaded to a temporary path on the dest
|
||||
ODB filesystem, and staged objects will reference the uploaded path rather
|
||||
than the original source path.
|
||||
"""
|
||||
assert fs_path
|
||||
# assert scheme(fs_path) == fs.scheme
|
||||
|
||||
details = fs.info(fs_path)
|
||||
staging = _get_staging(odb)
|
||||
if odb:
|
||||
try:
|
||||
return _load_from_state(odb, staging, fs_path, fs, name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if details["type"] == "directory":
|
||||
meta, obj = _stage_tree(
|
||||
fs_path,
|
||||
fs,
|
||||
details,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
**kwargs,
|
||||
)
|
||||
logger.debug("staged tree '%s'", obj)
|
||||
if name != "md5":
|
||||
obj = _stage_external_tree_info(odb, obj, name)
|
||||
else:
|
||||
_, meta, obj = _stage_file(
|
||||
fs_path,
|
||||
fs,
|
||||
name,
|
||||
odb=staging,
|
||||
upload_odb=odb if upload else None,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if odb and odb.state and obj.hash_info:
|
||||
odb.state.save(fs_path, fs, obj.hash_info)
|
||||
|
||||
return staging, meta, obj
|
||||
@@ -1,207 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, NamedTuple, Optional, Set
|
||||
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
from .db.index import ObjectDBIndexBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatusResult(NamedTuple):
|
||||
exists: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
|
||||
|
||||
class CompareStatusResult(NamedTuple):
|
||||
ok: Set["HashInfo"]
|
||||
missing: Set["HashInfo"]
|
||||
new: Set["HashInfo"]
|
||||
deleted: Set["HashInfo"]
|
||||
|
||||
|
||||
def _indexed_dir_hashes(odb, index, dir_objs, name, cache_odb, jobs=None):
|
||||
# Validate our index by verifying all indexed .dir hashes
|
||||
# still exist on the remote
|
||||
from ._progress import QueryingProgress
|
||||
|
||||
dir_hashes = set(dir_objs.keys())
|
||||
indexed_dirs = set(index.dir_hashes())
|
||||
indexed_dir_exists = set()
|
||||
if indexed_dirs:
|
||||
hashes = QueryingProgress(
|
||||
odb.list_hashes_exists(indexed_dirs, jobs=jobs),
|
||||
total=len(indexed_dirs),
|
||||
)
|
||||
indexed_dir_exists.update(hashes)
|
||||
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
|
||||
if missing_dirs:
|
||||
logger.debug(
|
||||
"Remote cache missing indexed .dir hashes '%s', "
|
||||
"clearing remote index",
|
||||
", ".join(missing_dirs),
|
||||
)
|
||||
index.clear()
|
||||
|
||||
# Check if non-indexed (new) dir hashes exist on remote
|
||||
dir_exists = dir_hashes.intersection(indexed_dir_exists)
|
||||
dir_missing = dir_hashes - dir_exists
|
||||
dir_exists.update(
|
||||
QueryingProgress(
|
||||
odb.list_hashes_exists(dir_missing, jobs=jobs),
|
||||
total=len(dir_missing),
|
||||
)
|
||||
)
|
||||
|
||||
# If .dir hash exists in the ODB, assume directory contents
|
||||
# also exists
|
||||
for dir_hash in dir_exists:
|
||||
tree = dir_objs.get(dir_hash)
|
||||
if not tree:
|
||||
try:
|
||||
tree = Tree.load(cache_odb, HashInfo(name, dir_hash))
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
file_hashes = [oid.value for _, _, oid in tree]
|
||||
if dir_hash not in index:
|
||||
logger.debug(
|
||||
"Indexing new .dir '%s' with '%s' nested files",
|
||||
dir_hash,
|
||||
len(file_hashes),
|
||||
)
|
||||
index.update([dir_hash], file_hashes)
|
||||
yield from file_hashes
|
||||
yield tree.hash_info.value
|
||||
|
||||
|
||||
def status(
|
||||
odb: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
name: Optional[str] = None,
|
||||
index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
shallow: bool = True,
|
||||
jobs: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> "StatusResult":
|
||||
"""Return status of whether or not the specified objects exist odb.
|
||||
|
||||
If cache_odb is set, trees will be loaded from cache_odb instead of odb
|
||||
when needed.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
exists: objs that exist in odb
|
||||
missing: objs that do not exist in ODB
|
||||
"""
|
||||
logger.debug("Preparing to collect status from '%s'", odb.fs_path)
|
||||
if not name:
|
||||
name = odb.fs.PARAM_CHECKSUM
|
||||
|
||||
if cache_odb is None:
|
||||
cache_odb = odb
|
||||
|
||||
hash_infos: Dict[str, "HashInfo"] = {}
|
||||
dir_objs: Dict[str, Optional["HashFile"]] = {}
|
||||
for hash_info in obj_ids:
|
||||
assert hash_info.value
|
||||
if hash_info.isdir:
|
||||
if shallow:
|
||||
tree = None
|
||||
else:
|
||||
tree = Tree.load(cache_odb, hash_info)
|
||||
for _, _, oid in tree:
|
||||
assert oid and oid.value
|
||||
hash_infos[oid.value] = oid
|
||||
if index:
|
||||
dir_objs[hash_info.value] = tree
|
||||
hash_infos[hash_info.value] = hash_info
|
||||
|
||||
if odb.fs.scheme == Schemes.MEMORY:
|
||||
# assume memfs staged objects already exist
|
||||
return StatusResult(set(hash_infos.values()), set())
|
||||
|
||||
hashes: Set[str] = set(hash_infos.keys())
|
||||
exists: Set[str] = set()
|
||||
|
||||
logger.debug("Collecting status from '%s'", odb.fs_path)
|
||||
if index and hashes:
|
||||
if dir_objs:
|
||||
exists = hashes.intersection(
|
||||
_indexed_dir_hashes(
|
||||
odb, index, dir_objs, name, cache_odb, jobs=jobs
|
||||
)
|
||||
)
|
||||
hashes.difference_update(exists)
|
||||
if hashes:
|
||||
exists.update(index.intersection(hashes))
|
||||
hashes.difference_update(exists)
|
||||
|
||||
if hashes:
|
||||
from ._progress import QueryingProgress
|
||||
|
||||
with QueryingProgress(phase="Checking", name=odb.fs_path) as pbar:
|
||||
exists.update(
|
||||
odb.hashes_exist(hashes, jobs=jobs, progress=pbar.callback)
|
||||
)
|
||||
return StatusResult(
|
||||
{hash_infos[hash_] for hash_ in exists},
|
||||
{hash_infos[hash_] for hash_ in (hashes - exists)},
|
||||
)
|
||||
|
||||
|
||||
def compare_status(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
log_missing: bool = True,
|
||||
check_deleted: bool = True,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
jobs: Optional[int] = None,
|
||||
**kwargs,
|
||||
) -> "CompareStatusResult":
|
||||
"""Compare status for the specified objects between two ODBs.
|
||||
|
||||
Status is returned as a tuple of:
|
||||
ok: hashes that exist in both src and dest
|
||||
missing: hashes that do not exist in neither src nor dest
|
||||
new: hashes that only exist in src
|
||||
deleted: hashes that only exist in dest
|
||||
"""
|
||||
if "cache_odb" not in kwargs:
|
||||
kwargs["cache_odb"] = src
|
||||
dest_exists, dest_missing = status(
|
||||
dest, obj_ids, index=dest_index, jobs=jobs, **kwargs
|
||||
)
|
||||
# for transfer operations we can skip src status check when all objects
|
||||
# already exist in dest
|
||||
if dest_missing or check_deleted:
|
||||
src_exists, src_missing = status(
|
||||
src, obj_ids, index=src_index, jobs=jobs, **kwargs
|
||||
)
|
||||
else:
|
||||
src_exists = dest_exists
|
||||
src_missing = set()
|
||||
result = CompareStatusResult(
|
||||
src_exists & dest_exists,
|
||||
src_missing & dest_missing,
|
||||
src_exists - dest_exists,
|
||||
dest_exists - src_exists,
|
||||
)
|
||||
if log_missing and result.missing:
|
||||
missing_desc = "\n".join(
|
||||
f"name: {hash_info.obj_name}, {hash_info}"
|
||||
for hash_info in result.missing
|
||||
)
|
||||
logger.warning(
|
||||
"Some of the cache files do not exist neither locally "
|
||||
f"nor on remote. Missing cache files:\n{missing_desc}"
|
||||
)
|
||||
return result
|
||||
@@ -1,179 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
from functools import partial, wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
|
||||
|
||||
from funcy import split
|
||||
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.utils.threadpool import ThreadPoolExecutor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
from .db.index import ObjectDBIndexBase
|
||||
from .tree import Tree
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_exceptions(func):
|
||||
@wraps(func)
|
||||
def wrapper(fs_path, *args, **kwargs):
|
||||
try:
|
||||
func(fs_path, *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 transfer '%s'", fs_path)
|
||||
return 1
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def find_tree_by_obj_id(
|
||||
odbs: Iterable[Optional["ObjectDB"]], obj_id: "HashInfo"
|
||||
) -> Optional["Tree"]:
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
|
||||
from .tree import Tree
|
||||
|
||||
for odb in odbs:
|
||||
if odb is not None:
|
||||
try:
|
||||
return Tree.load(odb, obj_id)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _do_transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
missing_ids: Iterable["HashInfo"],
|
||||
processor: Callable,
|
||||
src_index: Optional["ObjectDBIndexBase"] = None,
|
||||
dest_index: Optional["ObjectDBIndexBase"] = None,
|
||||
cache_odb: Optional["ObjectDB"] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
|
||||
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, obj_ids)
|
||||
total_fails = 0
|
||||
succeeded_dir_objs = []
|
||||
all_file_ids = set(file_ids)
|
||||
|
||||
for dir_hash in dir_ids:
|
||||
dir_obj = find_tree_by_obj_id([cache_odb, src], dir_hash)
|
||||
assert dir_obj
|
||||
|
||||
entry_ids = {oid for _, _, oid in dir_obj}
|
||||
bound_file_ids = all_file_ids & entry_ids
|
||||
all_file_ids -= entry_ids
|
||||
|
||||
dir_fails = sum(processor(bound_file_ids))
|
||||
if dir_fails:
|
||||
logger.debug(
|
||||
"failed to upload full contents of '%s', "
|
||||
"aborting .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
logger.error(
|
||||
"failed to upload '%s' to '%s'",
|
||||
src.get(dir_obj.hash_info).fs_path,
|
||||
dest.get(dir_obj.hash_info).fs_path,
|
||||
)
|
||||
total_fails += dir_fails + 1
|
||||
elif entry_ids.intersection(missing_ids):
|
||||
# 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
|
||||
logger.debug(
|
||||
"directory '%s' contains missing files,"
|
||||
"skipping .dir file upload",
|
||||
dir_obj.name,
|
||||
)
|
||||
else:
|
||||
is_dir_failed = sum(processor([dir_obj.hash_info]))
|
||||
total_fails += is_dir_failed
|
||||
if not is_dir_failed:
|
||||
succeeded_dir_objs.append(dir_obj)
|
||||
|
||||
# insert the rest
|
||||
total_fails += sum(processor(all_file_ids))
|
||||
if total_fails:
|
||||
if src_index:
|
||||
src_index.clear()
|
||||
raise FileTransferError(total_fails)
|
||||
|
||||
# index successfully pushed dirs
|
||||
if dest_index:
|
||||
for dir_obj in succeeded_dir_objs:
|
||||
file_hashes = {oid.value for _, _, oid in dir_obj}
|
||||
logger.debug(
|
||||
"Indexing pushed dir '%s' with '%s' nested files",
|
||||
dir_obj.hash_info,
|
||||
len(file_hashes),
|
||||
)
|
||||
assert dir_obj.hash_info and dir_obj.hash_info.value
|
||||
dest_index.update([dir_obj.hash_info.value], file_hashes)
|
||||
|
||||
|
||||
def transfer(
|
||||
src: "ObjectDB",
|
||||
dest: "ObjectDB",
|
||||
obj_ids: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
verify: bool = False,
|
||||
hardlink: bool = False,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""Transfer (copy) the specified objects from one ODB to another.
|
||||
|
||||
Returns the number of successfully transferred objects
|
||||
"""
|
||||
from .status import compare_status
|
||||
|
||||
logger.debug(
|
||||
"Preparing to transfer data from '%s' to '%s'",
|
||||
src.fs_path,
|
||||
dest.fs_path,
|
||||
)
|
||||
if src == dest:
|
||||
return 0
|
||||
|
||||
status = compare_status(
|
||||
src, dest, obj_ids, check_deleted=False, jobs=jobs, **kwargs
|
||||
)
|
||||
if not status.new:
|
||||
return 0
|
||||
|
||||
def func(hash_info: "HashInfo") -> None:
|
||||
obj = src.get(hash_info)
|
||||
return dest.add(
|
||||
obj.fs_path,
|
||||
obj.fs,
|
||||
obj.hash_info,
|
||||
verify=verify,
|
||||
hardlink=hardlink,
|
||||
)
|
||||
|
||||
total = len(status.new)
|
||||
jobs = jobs or dest.fs.jobs
|
||||
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
wrapped_func = pbar.wrap_fn(_log_exceptions(func))
|
||||
processor = partial(executor.imap_unordered, wrapped_func)
|
||||
_do_transfer(
|
||||
src, dest, status.new, status.missing, processor, **kwargs
|
||||
)
|
||||
return total
|
||||
@@ -1,293 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import posixpath
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.objects.errors import ObjectFormatError
|
||||
from dvc.objects.file import HashFile
|
||||
|
||||
from .stage import get_file_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
|
||||
from .meta import Meta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TreeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _try_load(
|
||||
odbs: Iterable["ObjectDB"],
|
||||
hash_info: "HashInfo",
|
||||
) -> Optional["HashFile"]:
|
||||
for odb in odbs:
|
||||
if not odb:
|
||||
continue
|
||||
|
||||
try:
|
||||
return Tree.load(odb, hash_info)
|
||||
except (FileNotFoundError, ObjectFormatError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Tree(HashFile):
|
||||
PARAM_RELPATH = "relpath"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._dict: Dict[Tuple[str], Tuple["Meta", "HashInfo"]] = {}
|
||||
|
||||
@cached_property
|
||||
def _trie(self):
|
||||
from pygtrie import Trie
|
||||
|
||||
return Trie(self._dict)
|
||||
|
||||
def add(self, key: Tuple[str], meta: "Meta", oid: "HashInfo"):
|
||||
self.__dict__.pop("trie", None)
|
||||
self._dict[key] = (meta, oid)
|
||||
|
||||
def digest(self, hash_info: Optional["HashInfo"] = None):
|
||||
from dvc.fs.memory import MemoryFileSystem
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
memfs = MemoryFileSystem()
|
||||
fs_path = "memory://{}".format(tmp_fname(""))
|
||||
memfs.pipe_file(fs_path, self.as_bytes())
|
||||
self.fs = memfs
|
||||
self.fs_path = fs_path
|
||||
if hash_info:
|
||||
self.hash_info = hash_info
|
||||
else:
|
||||
_, self.hash_info = get_file_hash(fs_path, memfs, "md5")
|
||||
assert self.hash_info.value
|
||||
self.hash_info.value += ".dir"
|
||||
|
||||
def _load(self, key, meta, hash_info):
|
||||
if hash_info and hash_info.isdir and not meta.obj:
|
||||
meta.obj = _try_load([meta.odb, meta.remote], hash_info)
|
||||
if meta.obj:
|
||||
for ikey, value in meta.obj.iteritems():
|
||||
self._trie[key + ikey] = value
|
||||
self._dict[key + ikey] = value
|
||||
|
||||
def iteritems(self, prefix=None):
|
||||
kwargs = {}
|
||||
if prefix:
|
||||
kwargs = {"prefix": prefix}
|
||||
item = self._trie.longest_prefix(prefix)
|
||||
if item:
|
||||
key, (meta, hash_info) = item
|
||||
self._load(key, meta, hash_info)
|
||||
|
||||
for key, (meta, hash_info) in self._trie.iteritems(**kwargs):
|
||||
self._load(key, meta, hash_info)
|
||||
yield key, (meta, hash_info)
|
||||
|
||||
def shortest_prefix(self, *args, **kwargs):
|
||||
return self._trie.shortest_prefix(*args, **kwargs)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._dict)
|
||||
|
||||
def __iter__(self):
|
||||
yield from (
|
||||
(key, value[0], value[1]) for key, value in self._dict.items()
|
||||
)
|
||||
|
||||
def as_dict(self):
|
||||
return self._dict.copy()
|
||||
|
||||
def as_list(self):
|
||||
from operator import itemgetter
|
||||
|
||||
# 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.
|
||||
oid.name: oid.value,
|
||||
self.PARAM_RELPATH: posixpath.sep.join(parts),
|
||||
}
|
||||
for parts, _, oid in self # noqa: B301
|
||||
),
|
||||
key=itemgetter(self.PARAM_RELPATH),
|
||||
)
|
||||
|
||||
def as_bytes(self):
|
||||
return json.dumps(self.as_list(), sort_keys=True).encode("utf-8")
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, lst):
|
||||
from dvc.hash_info import HashInfo
|
||||
|
||||
tree = cls(None, None, None)
|
||||
for _entry in lst:
|
||||
entry = _entry.copy()
|
||||
relpath = entry.pop(cls.PARAM_RELPATH)
|
||||
parts = tuple(relpath.split(posixpath.sep))
|
||||
hash_info = HashInfo.from_dict(entry)
|
||||
tree.add(parts, None, hash_info)
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
def load(cls, odb, hash_info):
|
||||
obj = odb.get(hash_info)
|
||||
|
||||
try:
|
||||
with obj.fs.open(obj.fs_path, "r") as fobj:
|
||||
raw = json.load(fobj)
|
||||
except ValueError as exc:
|
||||
raise ObjectFormatError(f"{obj} is corrupted") from exc
|
||||
|
||||
if not isinstance(raw, list):
|
||||
logger.error(
|
||||
"dir cache file format error '%s' [skipping the file]",
|
||||
obj.fs_path,
|
||||
)
|
||||
raise ObjectFormatError(f"{obj} is corrupted")
|
||||
|
||||
tree = cls.from_list(raw)
|
||||
tree.fs_path = obj.fs_path
|
||||
tree.fs = obj.fs
|
||||
tree.hash_info = hash_info
|
||||
|
||||
return tree
|
||||
|
||||
def filter(self, prefix: Tuple[str]) -> Optional["Tree"]:
|
||||
"""Return a filtered copy of this tree that only contains entries
|
||||
inside prefix.
|
||||
|
||||
The returned tree will contain the original tree's hash_info and
|
||||
fs_path.
|
||||
|
||||
Returns an empty tree if no object exists at the specified prefix.
|
||||
"""
|
||||
tree = Tree(self.fs_path, self.fs, self.hash_info)
|
||||
try:
|
||||
for key, (meta, oid) in self._trie.items(prefix):
|
||||
tree.add(key, meta, oid)
|
||||
except KeyError:
|
||||
pass
|
||||
return tree
|
||||
|
||||
def get(self, odb, prefix: Tuple[str]) -> Optional[HashFile]:
|
||||
"""Return object at the specified prefix in this tree.
|
||||
|
||||
Returns None if no object exists at the specified prefix.
|
||||
"""
|
||||
_, oid = self._dict.get(prefix) or (None, None)
|
||||
if oid:
|
||||
return odb.get(oid)
|
||||
|
||||
tree = Tree(None, None, None)
|
||||
depth = len(prefix)
|
||||
try:
|
||||
for key, (meta, entry_oid) in self._trie.items(prefix):
|
||||
tree.add(key[depth:], meta, entry_oid)
|
||||
except KeyError:
|
||||
return None
|
||||
tree.digest()
|
||||
return tree
|
||||
|
||||
def ls(self, prefix=None):
|
||||
kwargs = {}
|
||||
if prefix:
|
||||
kwargs["prefix"] = prefix
|
||||
|
||||
meta, hash_info = self._trie.get(prefix, (None, None))
|
||||
if hash_info and hash_info.isdir and meta and not meta.obj:
|
||||
raise TreeError
|
||||
|
||||
ret = []
|
||||
|
||||
def node_factory(_, key, children, *args):
|
||||
if key == prefix:
|
||||
list(children)
|
||||
else:
|
||||
ret.append(key[-1])
|
||||
|
||||
self._trie.traverse(node_factory, **kwargs)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def du(odb, tree):
|
||||
try:
|
||||
return sum(
|
||||
odb.fs.size(odb.hash_to_path(oid.value)) for _, _, oid in tree
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def merge(odb, ancestor_info, our_info, their_info):
|
||||
from . import load
|
||||
|
||||
assert our_info
|
||||
assert their_info
|
||||
|
||||
if ancestor_info:
|
||||
ancestor = load(odb, ancestor_info)
|
||||
else:
|
||||
ancestor = Tree(None, None, None)
|
||||
|
||||
our = load(odb, our_info)
|
||||
their = load(odb, their_info)
|
||||
|
||||
merged_dict = _merge(ancestor.as_dict(), our.as_dict(), their.as_dict())
|
||||
|
||||
merged = Tree(None, None, None)
|
||||
for key, (meta, oid) in merged_dict.items():
|
||||
merged.add(key, meta, oid)
|
||||
merged.digest()
|
||||
|
||||
return merged
|
||||
+43
-20
@@ -3,11 +3,12 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Iterable, Optional
|
||||
|
||||
from dvc.data.db import get_index
|
||||
from dvc_data.db import get_index
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc_data.hashfile.db import HashFileDB
|
||||
from dvc_data.hashfile.hash_info import HashInfo
|
||||
from dvc_data.status import CompareStatusResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +31,7 @@ class DataCloud:
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
command: str = "<command>",
|
||||
) -> "ObjectDB":
|
||||
) -> "HashFileDB":
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
if not name:
|
||||
@@ -55,19 +56,45 @@ class DataCloud:
|
||||
raise NoRemoteError(error_msg)
|
||||
|
||||
def _init_odb(self, name):
|
||||
from dvc.data.db import get_odb
|
||||
from dvc.fs import get_cloud_fs
|
||||
from dvc_data.db import get_odb
|
||||
|
||||
cls, config, fs_path = get_cloud_fs(self.repo, name=name)
|
||||
config["tmp_dir"] = self.repo.index_db_dir
|
||||
return get_odb(cls(**config), fs_path, **config)
|
||||
|
||||
def _log_missing(self, status: "CompareStatusResult"):
|
||||
if status.missing:
|
||||
missing_desc = "\n".join(
|
||||
f"name: {hash_info.obj_name}, {hash_info}"
|
||||
for hash_info in status.missing
|
||||
)
|
||||
logger.warning(
|
||||
"Some of the cache files do not exist neither locally "
|
||||
f"nor on remote. Missing cache files:\n{missing_desc}"
|
||||
)
|
||||
|
||||
def transfer(
|
||||
self,
|
||||
src_odb: "HashFileDB",
|
||||
dest_odb: "HashFileDB",
|
||||
objs: Iterable["HashInfo"],
|
||||
**kwargs,
|
||||
):
|
||||
from dvc.exceptions import FileTransferError
|
||||
from dvc_data.transfer import TransferError, transfer
|
||||
|
||||
try:
|
||||
return transfer(src_odb, dest_odb, objs, **kwargs)
|
||||
except TransferError as exc:
|
||||
raise FileTransferError(exc.fails) from exc
|
||||
|
||||
def push(
|
||||
self,
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
odb: Optional["HashFileDB"] = None,
|
||||
):
|
||||
"""Push data items in a cloud-agnostic way.
|
||||
|
||||
@@ -78,17 +105,15 @@ class DataCloud:
|
||||
By default remote from core.remote config option is used.
|
||||
odb: optional ODB to push to. Overrides remote.
|
||||
"""
|
||||
from dvc.data.transfer import transfer
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "push")
|
||||
return transfer(
|
||||
odb = odb or self.get_remote_odb(remote, "push")
|
||||
return self.transfer(
|
||||
self.repo.odb.local,
|
||||
odb,
|
||||
objs,
|
||||
jobs=jobs,
|
||||
dest_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
validate_status=self._log_missing,
|
||||
)
|
||||
|
||||
def pull(
|
||||
@@ -96,7 +121,7 @@ class DataCloud:
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
odb: Optional["HashFileDB"] = None,
|
||||
):
|
||||
"""Pull data items in a cloud-agnostic way.
|
||||
|
||||
@@ -107,11 +132,8 @@ class DataCloud:
|
||||
By default remote from core.remote config option is used.
|
||||
odb: optional ODB to pull from. Overrides remote.
|
||||
"""
|
||||
from dvc.data.transfer import transfer
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "pull")
|
||||
return transfer(
|
||||
odb = odb or self.get_remote_odb(remote, "pull")
|
||||
return self.transfer(
|
||||
odb,
|
||||
self.repo.odb.local,
|
||||
objs,
|
||||
@@ -119,6 +141,7 @@ class DataCloud:
|
||||
src_index=get_index(odb),
|
||||
cache_odb=self.repo.odb.local,
|
||||
verify=odb.verify,
|
||||
validate_status=self._log_missing,
|
||||
)
|
||||
|
||||
def status(
|
||||
@@ -126,7 +149,7 @@ class DataCloud:
|
||||
objs: Iterable["HashInfo"],
|
||||
jobs: Optional[int] = None,
|
||||
remote: Optional[str] = None,
|
||||
odb: Optional["ObjectDB"] = None,
|
||||
odb: Optional["HashFileDB"] = None,
|
||||
log_missing: bool = True,
|
||||
):
|
||||
"""Check status of data items in a cloud-agnostic way.
|
||||
@@ -141,7 +164,7 @@ class DataCloud:
|
||||
log_missing: log warning messages if file doesn't exist
|
||||
neither in cache, neither in cloud.
|
||||
"""
|
||||
from dvc.data.status import compare_status
|
||||
from dvc_data.status import compare_status
|
||||
|
||||
if not odb:
|
||||
odb = self.get_remote_odb(remote, "status")
|
||||
@@ -157,5 +180,5 @@ class DataCloud:
|
||||
|
||||
def get_url_for(self, remote, checksum):
|
||||
odb = self.get_remote_odb(remote)
|
||||
path = odb.hash_to_path(checksum)
|
||||
path = odb.oid_to_path(checksum)
|
||||
return odb.fs.unstrip_protocol(path)
|
||||
|
||||
@@ -8,8 +8,8 @@ import dpath.util
|
||||
from voluptuous import Any
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.utils.serialize import LOADERS, ParseError
|
||||
from dvc.utils.serialize import ParseError, load_path
|
||||
from dvc_data.hashfile.hash_info import HashInfo
|
||||
|
||||
from .base import Dependency
|
||||
|
||||
@@ -139,12 +139,9 @@ class ParamsDependency(Dependency):
|
||||
)
|
||||
|
||||
def read_file(self):
|
||||
_, ext = os.path.splitext(self.fs_path)
|
||||
loader = LOADERS[ext]
|
||||
|
||||
self.validate_filepath()
|
||||
try:
|
||||
return loader(self.fs_path, fs=self.repo.fs)
|
||||
return load_path(self.fs_path, self.repo.fs)
|
||||
except ParseError as exc:
|
||||
raise BadParamFileError(
|
||||
f"Unable to read parameters from '{self}'"
|
||||
|
||||
+30
-29
@@ -5,12 +5,14 @@ from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
|
||||
|
||||
from voluptuous import Required
|
||||
|
||||
from dvc.prompt import confirm
|
||||
|
||||
from .base import Dependency
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.objects.db import ObjectDB
|
||||
from dvc.objects.file import HashFile
|
||||
from dvc_data.hashfile.file import HashFile
|
||||
from dvc_data.hashfile.hash_info import HashInfo
|
||||
from dvc_objects.db import ObjectDB
|
||||
|
||||
|
||||
class RepoDependency(Dependency):
|
||||
@@ -29,7 +31,7 @@ class RepoDependency(Dependency):
|
||||
|
||||
def __init__(self, def_repo, stage, *args, **kwargs):
|
||||
self.def_repo = def_repo
|
||||
self._staged_objs: Dict[str, "HashFile"] = {}
|
||||
self._objs: Dict[str, "HashFile"] = {}
|
||||
super().__init__(stage, *args, **kwargs)
|
||||
|
||||
def _parse_path(self, fs, fs_path):
|
||||
@@ -61,7 +63,7 @@ class RepoDependency(Dependency):
|
||||
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
|
||||
|
||||
def download(self, to, jobs=None):
|
||||
from dvc.data.checkout import checkout
|
||||
from dvc_data.checkout import checkout
|
||||
|
||||
for odb, objs in self.get_used_objs().items():
|
||||
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
|
||||
@@ -72,8 +74,9 @@ class RepoDependency(Dependency):
|
||||
to.fs,
|
||||
obj,
|
||||
self.repo.odb.local,
|
||||
dvcignore=None,
|
||||
ignore=None,
|
||||
state=self.repo.state,
|
||||
prompt=confirm,
|
||||
)
|
||||
|
||||
def update(self, rev=None):
|
||||
@@ -98,16 +101,14 @@ class RepoDependency(Dependency):
|
||||
self, obj_only=False, **kwargs
|
||||
) -> Tuple[Dict[Optional["ObjectDB"], Set["HashInfo"]], "HashFile"]:
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.data.stage import stage
|
||||
from dvc.data.tree import Tree, TreeError
|
||||
from dvc.exceptions import NoOutputOrStageError, PathMissingError
|
||||
from dvc.utils import as_posix
|
||||
from dvc_data.build import build
|
||||
from dvc_data.objects.tree import Tree, TreeError
|
||||
|
||||
local_odb = self.repo.odb.local
|
||||
locked = kwargs.pop("locked", True)
|
||||
with self._make_repo(
|
||||
locked=locked, cache_dir=local_odb.cache_dir
|
||||
) as repo:
|
||||
with self._make_repo(locked=locked, cache_dir=local_odb.path) as repo:
|
||||
used_obj_ids = defaultdict(set)
|
||||
rev = repo.get_rev()
|
||||
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
|
||||
@@ -130,45 +131,45 @@ class RepoDependency(Dependency):
|
||||
pass
|
||||
|
||||
try:
|
||||
staging, _, staged_obj = stage(
|
||||
object_store, _, obj = build(
|
||||
local_odb,
|
||||
as_posix(self.def_path),
|
||||
repo.repo_fs,
|
||||
repo.dvcfs,
|
||||
local_odb.fs.PARAM_CHECKSUM,
|
||||
)
|
||||
except (FileNotFoundError, TreeError) as exc:
|
||||
raise PathMissingError(
|
||||
self.def_path, self.def_repo[self.PARAM_URL]
|
||||
) from exc
|
||||
staging = copy(staging)
|
||||
staging.read_only = True
|
||||
object_store = copy(object_store)
|
||||
object_store.read_only = True
|
||||
|
||||
self._staged_objs[rev] = staged_obj
|
||||
used_obj_ids[staging].add(staged_obj.hash_info)
|
||||
if isinstance(staged_obj, Tree):
|
||||
used_obj_ids[staging].update(oid for _, _, oid in staged_obj)
|
||||
return used_obj_ids, staged_obj
|
||||
self._objs[rev] = obj
|
||||
used_obj_ids[object_store].add(obj.hash_info)
|
||||
if isinstance(obj, Tree):
|
||||
used_obj_ids[object_store].update(oid for _, _, oid in obj)
|
||||
return used_obj_ids, obj
|
||||
|
||||
def _check_circular_import(self, odb, obj_ids):
|
||||
from dvc.data.db.reference import ReferenceObjectDB
|
||||
from dvc.data.tree import Tree
|
||||
from dvc.exceptions import CircularImportError
|
||||
from dvc.fs.repo import RepoFileSystem
|
||||
from dvc.fs.dvc import DvcFileSystem
|
||||
from dvc_data.db.reference import ReferenceHashFileDB
|
||||
from dvc_data.objects.tree import Tree
|
||||
|
||||
if not isinstance(odb, ReferenceObjectDB):
|
||||
if not isinstance(odb, ReferenceHashFileDB):
|
||||
return
|
||||
|
||||
def iter_objs():
|
||||
for hash_info in obj_ids:
|
||||
if hash_info.isdir:
|
||||
tree = Tree.load(odb, hash_info)
|
||||
yield from (odb.get(oid) for _, _, oid in tree)
|
||||
yield from (odb.get(hi.value) for _, _, hi in tree)
|
||||
else:
|
||||
yield odb.get(hash_info)
|
||||
yield odb.get(hash_info.value)
|
||||
|
||||
checked_urls = set()
|
||||
for obj in iter_objs():
|
||||
if not isinstance(obj.fs, RepoFileSystem):
|
||||
if not isinstance(obj.fs, DvcFileSystem):
|
||||
continue
|
||||
if (
|
||||
obj.fs.repo_url in checked_urls
|
||||
@@ -187,8 +188,8 @@ class RepoDependency(Dependency):
|
||||
def get_obj(self, filter_info=None, **kwargs):
|
||||
locked = kwargs.get("locked", True)
|
||||
rev = self._get_rev(locked=locked)
|
||||
if rev in self._staged_objs:
|
||||
return self._staged_objs[rev]
|
||||
if rev in self._objs:
|
||||
return self._objs[rev]
|
||||
_, obj = self._get_used_and_obj(
|
||||
obj_only=True, filter_info=filter_info, **kwargs
|
||||
)
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ def is_lock_file(path):
|
||||
|
||||
|
||||
def is_git_ignored(repo, path):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
from dvc.fs import LocalFileSystem
|
||||
from dvc.scm import NoSCMError
|
||||
|
||||
try:
|
||||
@@ -197,7 +197,7 @@ class SingleStageFile(FileMixin):
|
||||
assert not isinstance(stage, PipelineStage)
|
||||
if self.verify:
|
||||
check_dvcfile_path(self.repo, self.path)
|
||||
logger.debug(f"Saving information to '{relpath(self.path)}'.")
|
||||
logger.debug("Saving information to '%s'.", relpath(self.path))
|
||||
dump_yaml(self.path, serialize.to_single_stage_file(stage))
|
||||
self.repo.scm_context.track_file(self.relpath)
|
||||
|
||||
|
||||
@@ -202,14 +202,6 @@ class FileMissingError(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class DvcIgnoreInCollectedDirError(DvcException):
|
||||
def __init__(self, ignore_dirname):
|
||||
super().__init__(
|
||||
".dvcignore file should not be in collected dir path: "
|
||||
"'{}'".format(ignore_dirname)
|
||||
)
|
||||
|
||||
|
||||
class FileTransferError(DvcException):
|
||||
_METHOD = "transfer"
|
||||
|
||||
|
||||
+14
-10
@@ -33,7 +33,7 @@ def external_repo(
|
||||
from scmrepo.git import Git
|
||||
|
||||
from dvc.config import NoRemoteError
|
||||
from dvc.fs.git import GitFileSystem
|
||||
from dvc.fs import GitFileSystem
|
||||
|
||||
logger.debug("Creating external repo %s@%s", url, rev)
|
||||
path = _cached_clone(url, rev, for_write=for_write)
|
||||
@@ -50,19 +50,21 @@ def external_repo(
|
||||
config.update(cache_config)
|
||||
|
||||
if for_write:
|
||||
scm = None
|
||||
root_dir = path
|
||||
fs = None
|
||||
else:
|
||||
root_dir = os.path.realpath(path)
|
||||
scm = Git(root_dir)
|
||||
scm = Git(path)
|
||||
fs = GitFileSystem(scm=scm, rev=rev)
|
||||
root_dir = "/"
|
||||
|
||||
repo_kwargs = dict(
|
||||
root_dir=root_dir,
|
||||
url=url,
|
||||
fs=fs,
|
||||
config=config,
|
||||
repo_factory=erepo_factory(url, cache_config),
|
||||
repo_factory=erepo_factory(url, root_dir, cache_config),
|
||||
scm=scm,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -92,14 +94,16 @@ def external_repo(
|
||||
_remove(path)
|
||||
|
||||
|
||||
def erepo_factory(url, cache_config, *args, **kwargs):
|
||||
def make_repo(path, **_kwargs):
|
||||
def erepo_factory(url, root_dir, cache_config):
|
||||
from dvc.fs import localfs
|
||||
|
||||
def make_repo(path, fs=None, **_kwargs):
|
||||
_config = cache_config.copy()
|
||||
if os.path.isdir(url):
|
||||
rel = os.path.relpath(path, _kwargs["fs"].fs_args["scm"].root_dir)
|
||||
repo_path = os.path.join(url, rel)
|
||||
fs = fs or localfs
|
||||
repo_path = os.path.join(url, *fs.path.relparts(path, root_dir))
|
||||
_config.update(_get_remote_config(repo_path))
|
||||
return Repo(path, config=_config, **_kwargs)
|
||||
return Repo(path, fs=fs, config=_config, **_kwargs)
|
||||
|
||||
return make_repo
|
||||
|
||||
@@ -141,7 +145,7 @@ def _get_remote_config(url):
|
||||
name = "auto-generated-upstream"
|
||||
return {
|
||||
"core": {"remote": name},
|
||||
"remote": {name: {"url": repo.odb.local.cache_dir}},
|
||||
"remote": {name: {"url": repo.odb.local.path}},
|
||||
}
|
||||
|
||||
# Use original remote to make sure that we are using correct url,
|
||||
|
||||
+42
-44
@@ -1,41 +1,47 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..scheme import Schemes
|
||||
from . import utils # noqa: F401
|
||||
from .azure import AzureFileSystem
|
||||
from .gdrive import GDriveFileSystem
|
||||
from .gs import GSFileSystem
|
||||
from .hdfs import HDFSFileSystem
|
||||
from .http import HTTPFileSystem
|
||||
from .https import HTTPSFileSystem
|
||||
from .local import LocalFileSystem
|
||||
from .oss import OSSFileSystem
|
||||
from .s3 import S3FileSystem
|
||||
from .ssh import SSHFileSystem
|
||||
from .webdav import WebDAVFileSystem, WebDAVSFileSystem
|
||||
from .webhdfs import WebHDFSFileSystem
|
||||
from dvc_http import HTTPFileSystem, HTTPSFileSystem # noqa: F401
|
||||
|
||||
FS_MAP = {
|
||||
Schemes.AZURE: AzureFileSystem,
|
||||
Schemes.GDRIVE: GDriveFileSystem,
|
||||
Schemes.GS: GSFileSystem,
|
||||
Schemes.HDFS: HDFSFileSystem,
|
||||
Schemes.WEBHDFS: WebHDFSFileSystem,
|
||||
Schemes.HTTP: HTTPFileSystem,
|
||||
Schemes.HTTPS: HTTPSFileSystem,
|
||||
Schemes.S3: S3FileSystem,
|
||||
Schemes.SSH: SSHFileSystem,
|
||||
Schemes.OSS: OSSFileSystem,
|
||||
Schemes.WEBDAV: WebDAVFileSystem,
|
||||
Schemes.WEBDAVS: WebDAVSFileSystem,
|
||||
# NOTE: LocalFileSystem is the default
|
||||
}
|
||||
# pylint: disable=unused-import
|
||||
from dvc_objects.fs import utils # noqa: F401
|
||||
from dvc_objects.fs import ( # noqa: F401
|
||||
LocalFileSystem,
|
||||
MemoryFileSystem,
|
||||
Schemes,
|
||||
generic,
|
||||
get_fs_cls,
|
||||
known_implementations,
|
||||
localfs,
|
||||
registry,
|
||||
system,
|
||||
)
|
||||
from dvc_objects.fs.base import AnyFSPath, FileSystem # noqa: F401
|
||||
from dvc_objects.fs.errors import ( # noqa: F401
|
||||
AuthError,
|
||||
ConfigError,
|
||||
RemoteMissingDepsError,
|
||||
)
|
||||
from dvc_objects.fs.path import Path # noqa: F401
|
||||
|
||||
from .data import DataFileSystem # noqa: F401
|
||||
from .dvc import DvcFileSystem # noqa: F401
|
||||
from .git import GitFileSystem # noqa: F401
|
||||
|
||||
known_implementations.update(
|
||||
{
|
||||
"dvc": {
|
||||
"class": "dvc.fs.dvc.DvcFileSystem",
|
||||
"err": "dvc is supported, but requires 'dvc' to be installed",
|
||||
},
|
||||
"git": {
|
||||
"class": "dvc.fs.git.GitFileSystem",
|
||||
"err": "git is supported, but requires 'dvc' to be installed",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_fs_cls(remote_conf, scheme=None):
|
||||
if not scheme:
|
||||
scheme = urlparse(remote_conf["url"]).scheme
|
||||
return FS_MAP.get(scheme, LocalFileSystem)
|
||||
# pylint: enable=unused-import
|
||||
|
||||
|
||||
def get_fs_config(repo, config, **kwargs):
|
||||
@@ -86,7 +92,7 @@ def _resolve_remote_refs(repo, config, remote_conf):
|
||||
|
||||
|
||||
def get_cloud_fs(repo, **kwargs):
|
||||
from dvc.config import ConfigError
|
||||
from dvc.config import ConfigError as RepoConfigError
|
||||
from dvc.config_schema import SCHEMA, Invalid
|
||||
|
||||
repo_config = repo.config if repo else {}
|
||||
@@ -96,12 +102,7 @@ def get_cloud_fs(repo, **kwargs):
|
||||
try:
|
||||
remote_conf = SCHEMA["remote"][str](remote_conf)
|
||||
except Invalid as exc:
|
||||
raise ConfigError(str(exc)) from None
|
||||
|
||||
if "jobs" not in remote_conf:
|
||||
jobs = core_config.get("jobs")
|
||||
if jobs:
|
||||
remote_conf["jobs"] = jobs
|
||||
raise RepoConfigError(str(exc)) from None
|
||||
|
||||
if "checksum_jobs" not in remote_conf:
|
||||
checksum_jobs = core_config.get("checksum_jobs")
|
||||
@@ -110,11 +111,8 @@ def get_cloud_fs(repo, **kwargs):
|
||||
|
||||
cls = get_fs_cls(remote_conf)
|
||||
|
||||
if cls == GDriveFileSystem and repo:
|
||||
remote_conf["gdrive_credentials_tmp_dir"] = repo.tmp_dir
|
||||
|
||||
url = remote_conf.pop("url")
|
||||
if issubclass(cls, WebDAVFileSystem):
|
||||
if cls.protocol in ["webdav", "webdavs"]:
|
||||
# For WebDAVFileSystem, provided url is the base path itself, so it
|
||||
# should be treated as being a root path.
|
||||
fs_path = cls.root_marker
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
from functools import wraps
|
||||
from typing import IO, TYPE_CHECKING, Any, Optional, TypeVar, cast
|
||||
|
||||
import fsspec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable
|
||||
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from dvc.fs.base import FileSystem
|
||||
from dvc.progress import Tqdm
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
|
||||
class FsspecCallback(fsspec.Callback):
|
||||
"""FsspecCallback usable as a context manager, and a few helper methods."""
|
||||
|
||||
def wrap_attr(self, fobj: IO, method: str = "read") -> IO:
|
||||
from tqdm.utils import CallbackIOWrapper
|
||||
|
||||
wrapped = CallbackIOWrapper(self.relative_update, fobj, method)
|
||||
return cast(IO, wrapped)
|
||||
|
||||
def wrap_fn(self, fn: "Callable[_P, _R]") -> "Callable[_P, _R]":
|
||||
@wraps(fn)
|
||||
def wrapped(*args: "_P.args", **kwargs: "_P.kwargs") -> "_R":
|
||||
res = fn(*args, **kwargs)
|
||||
self.relative_update()
|
||||
return res
|
||||
|
||||
return wrapped
|
||||
|
||||
def wrap_and_branch(
|
||||
self, fn: "Callable", fs: "FileSystem" = None
|
||||
) -> "Callable":
|
||||
"""
|
||||
Wraps a function, and pass a new child callback to it.
|
||||
When the function completes, we increment the parent callback by 1.
|
||||
"""
|
||||
from .local import localfs
|
||||
|
||||
fs = fs or localfs
|
||||
wrapped = self.wrap_fn(fn)
|
||||
|
||||
def make_callback(path1, path2):
|
||||
# pylint: disable=assignment-from-none
|
||||
child = self.branch(fs.path.name(path1), path2, {})
|
||||
return self.as_callback(child)
|
||||
|
||||
@wraps(fn)
|
||||
def func(path1, path2, **kwargs):
|
||||
with make_callback(path1, path2) as callback:
|
||||
return wrapped(path1, path2, callback=callback, **kwargs)
|
||||
|
||||
return func
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_args):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Handle here on exit."""
|
||||
|
||||
@classmethod
|
||||
def as_callback(
|
||||
cls, maybe_callback: Optional["FsspecCallback"] = None
|
||||
) -> "FsspecCallback":
|
||||
if maybe_callback is None:
|
||||
return DEFAULT_CALLBACK
|
||||
return maybe_callback
|
||||
|
||||
@classmethod
|
||||
def as_tqdm_callback(
|
||||
cls,
|
||||
callback: Optional["FsspecCallback"] = None,
|
||||
**tqdm_kwargs: Any,
|
||||
) -> "FsspecCallback":
|
||||
return callback or TqdmCallback(**tqdm_kwargs)
|
||||
|
||||
@classmethod
|
||||
def as_rich_callback(
|
||||
cls, callback: Optional["FsspecCallback"] = None, **rich_kwargs
|
||||
):
|
||||
return callback or RichCallback(**rich_kwargs)
|
||||
|
||||
|
||||
class NoOpCallback(FsspecCallback, fsspec.callbacks.NoOpCallback):
|
||||
pass
|
||||
|
||||
|
||||
class TqdmCallback(FsspecCallback):
|
||||
def __init__(self, progress_bar: "Tqdm" = None, **tqdm_kwargs):
|
||||
from dvc.progress import Tqdm
|
||||
|
||||
self.progress_bar = progress_bar or Tqdm(**tqdm_kwargs)
|
||||
super().__init__()
|
||||
|
||||
def __enter__(self):
|
||||
self.progress_bar.__enter__()
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
self.progress_bar.close()
|
||||
|
||||
def set_size(self, size):
|
||||
if size is not None:
|
||||
self.progress_bar.total = size
|
||||
self.progress_bar.refresh()
|
||||
super().set_size(size)
|
||||
|
||||
def relative_update(self, inc=1):
|
||||
self.progress_bar.update(inc)
|
||||
super().relative_update(inc)
|
||||
|
||||
def absolute_update(self, value):
|
||||
self.progress_bar.update_to(value)
|
||||
super().absolute_update(value)
|
||||
|
||||
def branch(self, path_1: str, path_2: str, kwargs):
|
||||
return TqdmCallback(bytes=True, total=-1, desc=path_1, **kwargs)
|
||||
|
||||
|
||||
class RichCallback(FsspecCallback):
|
||||
def __init__(
|
||||
self,
|
||||
progress: "RichTransferProgress" = None,
|
||||
desc: str = None,
|
||||
total: int = None,
|
||||
bytes: bool = False, # pylint: disable=redefined-builtin
|
||||
unit: str = None,
|
||||
disable: bool = False,
|
||||
) -> None:
|
||||
from dvc.ui import ui
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
self.progress = progress or RichTransferProgress(
|
||||
transient=True,
|
||||
disable=disable,
|
||||
console=ui.error_console,
|
||||
)
|
||||
self.visible = not disable
|
||||
self._newly_created = progress is None
|
||||
total = 0 if total is None or total < 0 else total
|
||||
self.task = self.progress.add_task(
|
||||
description=desc or "",
|
||||
total=total,
|
||||
bytes=bytes,
|
||||
visible=False,
|
||||
unit=unit,
|
||||
progress_type=None if bytes else "summary",
|
||||
)
|
||||
super().__init__()
|
||||
|
||||
def __enter__(self):
|
||||
if self._newly_created:
|
||||
self.progress.__enter__()
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
if self._newly_created:
|
||||
self.progress.stop()
|
||||
try:
|
||||
self.progress.remove_task(self.task)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def set_size(self, size: int = None) -> None:
|
||||
if size is not None:
|
||||
self.progress.update(self.task, total=size, visible=self.visible)
|
||||
super().set_size(size)
|
||||
|
||||
def relative_update(self, inc: int = 1) -> None:
|
||||
self.progress.update(self.task, advance=inc)
|
||||
super().relative_update(inc)
|
||||
|
||||
def absolute_update(self, value: int) -> None:
|
||||
self.progress.update(self.task, completed=value)
|
||||
super().absolute_update(value)
|
||||
|
||||
def branch(self, path_1, path_2, kwargs):
|
||||
return RichCallback(self.progress, desc=path_1, bytes=True, total=-1)
|
||||
|
||||
|
||||
DEFAULT_CALLBACK = NoOpCallback()
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from fsspec.asyn import fsspec_loop
|
||||
from fsspec.utils import infer_storage_options
|
||||
from funcy import cached_property, memoize, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link
|
||||
|
||||
from .base import ObjectFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_DEFAULT_CREDS_STEPS = (
|
||||
"https://azuresdkdocs.blob.core.windows.net/$web/python/"
|
||||
"azure-identity/1.4.0/azure.identity.html#azure.identity"
|
||||
".DefaultAzureCredential"
|
||||
)
|
||||
|
||||
|
||||
class AzureAuthError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
@memoize
|
||||
def _az_config():
|
||||
# NOTE: ideally we would've used get_default_cli().config from
|
||||
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
|
||||
# dependencies. So instead we are just use knack directly
|
||||
from knack.config import CLIConfig
|
||||
|
||||
config_dir = os.getenv(
|
||||
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
|
||||
)
|
||||
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class AzureFileSystem(ObjectFileSystem):
|
||||
scheme = Schemes.AZURE
|
||||
PARAM_CHECKSUM = "etag"
|
||||
REQUIRES = {
|
||||
"adlfs": "adlfs",
|
||||
"knack": "knack",
|
||||
"azure-identity": "azure.identity",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str):
|
||||
opts = infer_storage_options(path)
|
||||
if opts.get("host"):
|
||||
return "{host}{path}".format(**opts)
|
||||
|
||||
return _az_config().get("storage", "container_name", None)
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return "azure://" + path.lstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
ops = infer_storage_options(urlpath)
|
||||
if "host" in ops:
|
||||
return {"bucket": ops["host"]}
|
||||
return {}
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Disable spam from failed cred types for DefaultAzureCredential
|
||||
logging.getLogger("azure.identity.aio").setLevel(logging.ERROR)
|
||||
|
||||
login_info = {}
|
||||
login_info["connection_string"] = config.get(
|
||||
"connection_string",
|
||||
_az_config().get("storage", "connection_string", None),
|
||||
)
|
||||
login_info["account_name"] = config.get(
|
||||
"account_name", _az_config().get("storage", "account", None)
|
||||
)
|
||||
login_info["account_key"] = config.get(
|
||||
"account_key", _az_config().get("storage", "key", None)
|
||||
)
|
||||
login_info["sas_token"] = config.get(
|
||||
"sas_token", _az_config().get("storage", "sas_token", None)
|
||||
)
|
||||
login_info["tenant_id"] = config.get("tenant_id")
|
||||
login_info["client_id"] = config.get("client_id")
|
||||
login_info["client_secret"] = config.get("client_secret")
|
||||
|
||||
if not (login_info["account_name"] or login_info["connection_string"]):
|
||||
raise AzureAuthError(
|
||||
"Authentication to Azure Blob Storage requires either "
|
||||
"account_name or connection_string.\nLearn more about "
|
||||
"configuration settings at "
|
||||
+ format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
|
||||
any_secondary = any(
|
||||
value for key, value in login_info.items() if key != "account_name"
|
||||
)
|
||||
if (
|
||||
login_info["account_name"]
|
||||
and not any_secondary
|
||||
and not config.get("allow_anonymous_login", False)
|
||||
):
|
||||
with fsspec_loop():
|
||||
login_info["credential"] = DefaultAzureCredential(
|
||||
exclude_interactive_browser_credential=False,
|
||||
exclude_environment_credential=config.get(
|
||||
"exclude_environment_credential", False
|
||||
),
|
||||
exclude_visual_studio_code_credential=config.get(
|
||||
"exclude_visual_studio_code_credential", False
|
||||
),
|
||||
exclude_shared_token_cache_credential=config.get(
|
||||
"exclude_shared_token_cache_credential", False
|
||||
),
|
||||
exclude_managed_identity_credential=config.get(
|
||||
"exclude_managed_identity_credential", False
|
||||
),
|
||||
)
|
||||
|
||||
for login_method, required_keys in [ # noqa
|
||||
("connection string", ["connection_string"]),
|
||||
(
|
||||
"AD service principal",
|
||||
["tenant_id", "client_id", "client_secret"],
|
||||
),
|
||||
("account key", ["account_name", "account_key"]),
|
||||
("SAS token", ["account_name", "sas_token"]),
|
||||
(
|
||||
f"default credentials ({_DEFAULT_CREDS_STEPS})",
|
||||
["account_name", "credential"],
|
||||
),
|
||||
("anonymous login", ["account_name"]),
|
||||
]:
|
||||
if all(login_info.get(key) is not None for key in required_keys):
|
||||
break
|
||||
else:
|
||||
login_method = None
|
||||
|
||||
self.login_method = login_method
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from adlfs import AzureBlobFileSystem
|
||||
from azure.core.exceptions import AzureError
|
||||
|
||||
try:
|
||||
return AzureBlobFileSystem(**self.fs_args)
|
||||
except (ValueError, AzureError) as e:
|
||||
raise AzureAuthError(
|
||||
f"Authentication to Azure Blob Storage via {self.login_method}"
|
||||
" failed.\nLearn more about configuration settings at"
|
||||
f" {format_link('https://man.dvc.org/remote/modify')}"
|
||||
) from e
|
||||
-504
@@ -1,504 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from multiprocessing import cpu_count
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.utils.fs import as_atomic, makedirs
|
||||
from dvc.utils.threadpool import ThreadPoolExecutor
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK, FsspecCallback
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fsspec.spec import AbstractFileSystem
|
||||
from typing_extensions import Literal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FSPath = str
|
||||
AnyFSPath = str
|
||||
|
||||
# An info() entry, might evolve to a TypedDict
|
||||
# in the future (e.g for properly type 'size' etc).
|
||||
Entry = Dict[str, Any]
|
||||
|
||||
|
||||
class RemoteActionNotImplemented(DvcException):
|
||||
def __init__(self, action, scheme):
|
||||
m = f"{action} is not supported for {scheme} remotes"
|
||||
super().__init__(m)
|
||||
|
||||
|
||||
class RemoteMissingDepsError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class FileSystem:
|
||||
sep = "/"
|
||||
|
||||
scheme = "base"
|
||||
REQUIRES: ClassVar[Dict[str, str]] = {}
|
||||
_JOBS = 4 * cpu_count()
|
||||
|
||||
HASH_JOBS = max(1, min(4, cpu_count() // 2))
|
||||
LIST_OBJECT_PAGE_SIZE = 1000
|
||||
TRAVERSE_WEIGHT_MULTIPLIER = 5
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
TRAVERSE_THRESHOLD_SIZE = 500000
|
||||
CAN_TRAVERSE = True
|
||||
|
||||
PARAM_CHECKSUM: ClassVar[Optional[str]] = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._check_requires(**kwargs)
|
||||
|
||||
self.jobs = kwargs.get("jobs") or self._JOBS
|
||||
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
|
||||
self._config = kwargs
|
||||
self.fs_args = {"skip_instance_cache": True}
|
||||
self.fs_args.update(self._prepare_credentials(**kwargs))
|
||||
|
||||
@property
|
||||
def config(self) -> Dict[str, Any]:
|
||||
return self._config
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
from .path import Path
|
||||
|
||||
return Path(self.sep)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
return path
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return path
|
||||
|
||||
@cached_property
|
||||
def fs(self) -> "AbstractFileSystem":
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath: str) -> "Dict[str, Any]":
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
options = infer_storage_options(urlpath)
|
||||
options.pop("path", None)
|
||||
options.pop("protocol", None)
|
||||
return options
|
||||
|
||||
def _prepare_credentials(
|
||||
self, **config: Dict[str, Any] # pylint: disable=unused-argument
|
||||
) -> Dict[str, Any]:
|
||||
"""Prepare the arguments for authentication to the
|
||||
host filesystem"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
def get_missing_deps(cls) -> List[str]:
|
||||
import importlib
|
||||
|
||||
missing: List[str] = []
|
||||
for package, module in cls.REQUIRES.items():
|
||||
try:
|
||||
importlib.import_module(module)
|
||||
except ImportError:
|
||||
missing.append(package)
|
||||
|
||||
return missing
|
||||
|
||||
def _check_requires(self, **kwargs):
|
||||
from ..scheme import Schemes
|
||||
from ..utils import format_link
|
||||
from ..utils.pkg import PKG
|
||||
|
||||
missing = self.get_missing_deps()
|
||||
if not missing:
|
||||
return
|
||||
|
||||
url = kwargs.get("url", f"{self.scheme}://")
|
||||
|
||||
scheme = self.scheme
|
||||
if scheme == Schemes.WEBDAVS:
|
||||
scheme = Schemes.WEBDAV
|
||||
|
||||
by_pkg = {
|
||||
"pip": f"pip install 'dvc[{scheme}]'",
|
||||
"conda": f"conda install -c conda-forge dvc-{scheme}",
|
||||
}
|
||||
|
||||
cmd = by_pkg.get(PKG)
|
||||
if cmd:
|
||||
link = format_link("https://dvc.org/doc/install")
|
||||
hint = (
|
||||
f"To install dvc with those dependencies, run:\n"
|
||||
"\n"
|
||||
f"\t{cmd}\n"
|
||||
"\n"
|
||||
f"See {link} for more info."
|
||||
)
|
||||
else:
|
||||
link = format_link("https://github.com/iterative/dvc/issues")
|
||||
hint = f"Please report this bug to {link}. Thank you!"
|
||||
|
||||
raise RemoteMissingDepsError(
|
||||
f"URL '{url}' is supported but requires these missing "
|
||||
f"dependencies: {missing}. {hint}"
|
||||
)
|
||||
|
||||
def isdir(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.isdir(path)
|
||||
|
||||
def isfile(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.isfile(path)
|
||||
|
||||
def is_empty(self, path: AnyFSPath) -> bool:
|
||||
entry = self.info(path)
|
||||
if entry["type"] == "directory":
|
||||
return not self.fs.ls(path)
|
||||
return entry["size"] == 0
|
||||
|
||||
def open(
|
||||
self,
|
||||
path: AnyFSPath,
|
||||
mode: str = "r",
|
||||
encoding: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> "IO": # pylint: disable=arguments-differ
|
||||
return self.fs.open(path, mode=mode, encoding=encoding, **kwargs)
|
||||
|
||||
def cat(
|
||||
self,
|
||||
path: Union[AnyFSPath, List[AnyFSPath]],
|
||||
recursive: bool = False,
|
||||
on_error: "Literal['raise', 'omit', 'return']" = "raise",
|
||||
**kwargs: Any,
|
||||
) -> Union[bytes, Dict[AnyFSPath, bytes]]:
|
||||
return self.fs.cat(
|
||||
path, recursive=recursive, on_error=on_error, **kwargs
|
||||
)
|
||||
|
||||
def cat_ranges(
|
||||
self,
|
||||
paths: List[AnyFSPath],
|
||||
starts: List[int],
|
||||
ends: List[int],
|
||||
max_gap: int = None,
|
||||
**kwargs,
|
||||
) -> List[bytes]:
|
||||
return self.fs.cat_ranges(
|
||||
paths, starts, ends, max_gap=max_gap, **kwargs
|
||||
)
|
||||
|
||||
def cat_file(
|
||||
self,
|
||||
path: AnyFSPath,
|
||||
start: int = None,
|
||||
end: int = None,
|
||||
**kwargs: Any,
|
||||
) -> bytes:
|
||||
return self.fs.cat_file(path, start=start, end=end, **kwargs)
|
||||
|
||||
def pipe_file(self, path: AnyFSPath, value: bytes, **kwargs: Any) -> None:
|
||||
return self.fs.pipe_file(path, value, **kwargs)
|
||||
|
||||
def pipe(
|
||||
self,
|
||||
path: Union[AnyFSPath, Dict[AnyFSPath, bytes]],
|
||||
value: Optional[bytes] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
return self.fs.pipe(path, value=value, **kwargs)
|
||||
|
||||
def touch(
|
||||
self, path: AnyFSPath, truncate: bool = True, **kwargs: Any
|
||||
) -> None:
|
||||
return self.fs.touch(path, truncate=truncate, **kwargs)
|
||||
|
||||
def checksum(self, path: AnyFSPath) -> str:
|
||||
return self.fs.checksum(path)
|
||||
|
||||
def copy(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
self.fs.copy(from_info, to_info)
|
||||
|
||||
def exists(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.exists(path)
|
||||
|
||||
def lexists(self, path: AnyFSPath) -> bool:
|
||||
return self.fs.lexists(path)
|
||||
|
||||
def symlink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
try:
|
||||
return self.fs.symlink(from_info, to_info)
|
||||
except AttributeError:
|
||||
raise RemoteActionNotImplemented("symlink", self.scheme)
|
||||
|
||||
def hardlink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
try:
|
||||
return self.fs.hardlink(from_info, to_info)
|
||||
except AttributeError:
|
||||
raise RemoteActionNotImplemented("hardlink", self.scheme)
|
||||
|
||||
def reflink(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
try:
|
||||
return self.fs.reflink(from_info, to_info)
|
||||
except AttributeError:
|
||||
raise RemoteActionNotImplemented("reflink", self.scheme)
|
||||
|
||||
def is_symlink(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return self.fs.is_symlink(path)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
def is_hardlink(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return self.fs.is_hardlink(path)
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
def iscopy(self, path: AnyFSPath) -> bool:
|
||||
return self.is_symlink(path) or self.is_hardlink(path)
|
||||
|
||||
@overload
|
||||
def ls(
|
||||
self, path: AnyFSPath, detail: "Literal[True]"
|
||||
) -> "Iterator[Entry]":
|
||||
...
|
||||
|
||||
@overload
|
||||
def ls(self, path: AnyFSPath, detail: "Literal[False]") -> Iterator[str]:
|
||||
...
|
||||
|
||||
def ls(self, path, detail=False, **kwargs):
|
||||
return self.fs.ls(path, detail=detail)
|
||||
|
||||
def find(
|
||||
self,
|
||||
path: AnyFSPath,
|
||||
prefix: bool = False, # pylint: disable=unused-argument
|
||||
) -> Iterator[str]:
|
||||
yield from self.fs.find(path)
|
||||
|
||||
def move(self, from_info: AnyFSPath, to_info: AnyFSPath) -> None:
|
||||
self.fs.move(from_info, to_info)
|
||||
|
||||
def remove(self, path: AnyFSPath) -> None:
|
||||
self.fs.rm(path, recursive=True)
|
||||
|
||||
def info(self, path: AnyFSPath) -> "Entry":
|
||||
return self.fs.info(path)
|
||||
|
||||
def makedirs(self, path: AnyFSPath, **kwargs: Any) -> None:
|
||||
self.fs.makedirs(path, exist_ok=kwargs.pop("exist_ok", True))
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file: Union[AnyFSPath, IO],
|
||||
to_info: AnyFSPath,
|
||||
callback: FsspecCallback = DEFAULT_CALLBACK,
|
||||
size: int = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if size:
|
||||
callback.set_size(size)
|
||||
if hasattr(from_file, "read"):
|
||||
stream = callback.wrap_attr(cast("IO", from_file))
|
||||
self.upload_fobj(stream, to_info, size=size)
|
||||
else:
|
||||
assert isinstance(from_file, str)
|
||||
self.fs.put_file(
|
||||
os.fspath(from_file), to_info, callback=callback, **kwargs
|
||||
)
|
||||
self.fs.invalidate_cache(self.path.parent(to_info))
|
||||
|
||||
def get_file(
|
||||
self,
|
||||
from_info: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: FsspecCallback = DEFAULT_CALLBACK,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.fs.get_file(from_info, to_info, callback=callback, **kwargs)
|
||||
|
||||
def upload_fobj(self, fobj: IO, to_info: AnyFSPath, **kwargs) -> None:
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
with self.open(to_info, "wb") as fdest:
|
||||
shutil.copyfileobj(
|
||||
fobj,
|
||||
fdest,
|
||||
length=getattr(fdest, "blocksize", None), # type: ignore
|
||||
)
|
||||
|
||||
def walk(
|
||||
self,
|
||||
path: AnyFSPath,
|
||||
topdown: bool = True,
|
||||
**kwargs: Any,
|
||||
):
|
||||
return self.fs.walk(path, topdown=topdown, **kwargs)
|
||||
|
||||
def size(self, path: AnyFSPath) -> Optional[int]:
|
||||
return self.fs.size(path)
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
def upload(
|
||||
self,
|
||||
from_info: Union[AnyFSPath, IO],
|
||||
to_info: AnyFSPath,
|
||||
size: int = None,
|
||||
callback: FsspecCallback = None,
|
||||
):
|
||||
from .local import localfs
|
||||
|
||||
if not hasattr(from_info, "read"):
|
||||
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
|
||||
desc = localfs.path.name(from_info)
|
||||
else:
|
||||
desc = self.path.name(to_info)
|
||||
|
||||
with FsspecCallback.as_tqdm_callback(
|
||||
callback,
|
||||
desc=desc,
|
||||
bytes=True,
|
||||
total=size or -1,
|
||||
) as cb:
|
||||
return self.put_file(from_info, to_info, callback=cb, size=size)
|
||||
|
||||
def download(
|
||||
self,
|
||||
from_info: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: "FsspecCallback" = None,
|
||||
jobs: int = None,
|
||||
):
|
||||
if not self.isdir(from_info):
|
||||
return self.download_file(from_info, to_info, callback=callback)
|
||||
|
||||
from .local import localfs
|
||||
|
||||
pairs = {
|
||||
info: localfs.path.join(
|
||||
to_info, *self.path.relparts(info, from_info)
|
||||
)
|
||||
for info in self.find(from_info)
|
||||
}
|
||||
if not pairs:
|
||||
makedirs(to_info, exist_ok=True)
|
||||
return
|
||||
|
||||
with FsspecCallback.as_tqdm_callback(
|
||||
callback,
|
||||
total=-1,
|
||||
desc=f"Downloading directory {self.path.name(from_info)}",
|
||||
unit="files",
|
||||
) as cb:
|
||||
cb.set_size(len(pairs))
|
||||
download_files = cb.wrap_and_branch(self.download_file, fs=self)
|
||||
max_workers = jobs or self.jobs
|
||||
# NOTE: unlike pulling/fetching cache, where we need to
|
||||
# download everything we can, not raising an error here might
|
||||
# turn very ugly, as the user might think that he has
|
||||
# downloaded a complete directory, while having a partial one,
|
||||
# which might cause unexpected results in his pipeline.
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=max_workers, cancel_on_error=True
|
||||
) as executor:
|
||||
list(
|
||||
executor.imap_unordered(
|
||||
lambda args: download_files(*args), pairs.items()
|
||||
)
|
||||
)
|
||||
|
||||
def download_file(
|
||||
self,
|
||||
from_info: AnyFSPath,
|
||||
to_info: AnyFSPath,
|
||||
callback: FsspecCallback = None,
|
||||
):
|
||||
from .local import localfs
|
||||
|
||||
with FsspecCallback.as_tqdm_callback(
|
||||
callback,
|
||||
total=-1,
|
||||
desc=self.path.name(from_info),
|
||||
bytes=True,
|
||||
) as cb:
|
||||
with as_atomic(localfs, to_info, create_parents=True) as tmp_file:
|
||||
self.get_file(from_info, tmp_file, callback=cb)
|
||||
|
||||
|
||||
class ObjectFileSystem(FileSystem): # pylint: disable=abstract-method
|
||||
TRAVERSE_PREFIX_LEN = 3
|
||||
|
||||
def makedirs(self, path: AnyFSPath, **kwargs: Any) -> None:
|
||||
# For object storages make this method a no-op. The original
|
||||
# fs.makedirs() method will only check if the bucket exists
|
||||
# and create if it doesn't though we don't want to support
|
||||
# that behavior, and the check will cost some time so we'll
|
||||
# simply ignore all mkdir()/makedirs() calls.
|
||||
return None
|
||||
|
||||
def _isdir(self, path: AnyFSPath) -> bool:
|
||||
# Directory in object storages are interpreted differently
|
||||
# among different fsspec providers, so this logic is a temporary
|
||||
# measure for us to adapt as of now. It checks whether it is a
|
||||
# directory (as in a prefix with contents) or whether it is an empty
|
||||
# file where it's name ends with a forward slash
|
||||
|
||||
entry = self.info(path)
|
||||
return entry["type"] == "directory" or (
|
||||
entry["size"] == 0
|
||||
and entry["type"] == "file"
|
||||
and entry["name"].endswith("/")
|
||||
)
|
||||
|
||||
def isdir(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return self._isdir(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def isfile(self, path: AnyFSPath) -> bool:
|
||||
try:
|
||||
return not self._isdir(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def find(self, path: AnyFSPath, prefix: bool = False) -> Iterator[str]:
|
||||
if prefix:
|
||||
with_prefix = self.path.parent(path)
|
||||
files = self.fs.find(with_prefix, prefix=self.path.parts(path)[-1])
|
||||
else:
|
||||
with_prefix = path
|
||||
files = self.fs.find(path)
|
||||
|
||||
# When calling find() on a file, it returns the same file in a list.
|
||||
# For object-based storages, the same behavior applies to empty
|
||||
# directories since they are represented as files. This condition
|
||||
# checks whether we should yield an empty list (if it is an empty
|
||||
# directory) or just yield the file itself.
|
||||
if len(files) == 1 and files[0] == with_prefix and self.isdir(path):
|
||||
return None
|
||||
|
||||
yield from files
|
||||
@@ -0,0 +1,79 @@
|
||||
# pylint: disable=unused-import
|
||||
from contextlib import ExitStack
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from funcy import cached_property
|
||||
|
||||
from dvc_objects.fs.callbacks import ( # noqa: F401
|
||||
DEFAULT_CALLBACK,
|
||||
Callback,
|
||||
TqdmCallback,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
|
||||
class RichCallback(Callback):
|
||||
def __init__(
|
||||
self,
|
||||
size: Optional[int] = None,
|
||||
value: int = 0,
|
||||
progress: "RichTransferProgress" = None,
|
||||
desc: str = None,
|
||||
bytes: bool = False, # pylint: disable=redefined-builtin
|
||||
unit: str = None,
|
||||
disable: bool = False,
|
||||
) -> None:
|
||||
self._progress = progress
|
||||
self.disable = disable
|
||||
self._task_kwargs = {
|
||||
"description": desc or "",
|
||||
"bytes": bytes,
|
||||
"unit": unit,
|
||||
"total": size or 0,
|
||||
"visible": False,
|
||||
"progress_type": None if bytes else "summary",
|
||||
}
|
||||
self._stack = ExitStack()
|
||||
super().__init__(size=size, value=value)
|
||||
|
||||
@cached_property
|
||||
def progress(self):
|
||||
from dvc.ui import ui
|
||||
from dvc.ui._rich_progress import RichTransferProgress
|
||||
|
||||
if self._progress is not None:
|
||||
return self._progress
|
||||
|
||||
progress = RichTransferProgress(
|
||||
transient=True,
|
||||
disable=self.disable,
|
||||
console=ui.error_console,
|
||||
)
|
||||
return self._stack.enter_context(progress)
|
||||
|
||||
@cached_property
|
||||
def task(self):
|
||||
return self.progress.add_task(**self._task_kwargs)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
self.progress.clear_task(self.task)
|
||||
self._stack.close()
|
||||
|
||||
def call(self, hook_name=None, **kwargs):
|
||||
self.progress.update(
|
||||
self.task,
|
||||
completed=self.value,
|
||||
total=self.size,
|
||||
visible=not self.disable,
|
||||
)
|
||||
|
||||
def branch(self, path_1, path_2, kwargs, child: Optional[Callback] = None):
|
||||
child = child or RichCallback(
|
||||
progress=self.progress, desc=path_1, bytes=True
|
||||
)
|
||||
return super().branch(path_1, path_2, kwargs, child=child)
|
||||
@@ -0,0 +1,34 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.utils import as_posix
|
||||
from dvc_data.fs import DataFileSystem as _DataFileSystem
|
||||
from dvc_objects.fs.base import FileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataFileSystem(FileSystem):
|
||||
protocol = "local"
|
||||
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
return _DataFileSystem(**self.fs_args)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
return self.fs.isdvc(path, **kwargs)
|
||||
|
||||
def from_os_path(self, path):
|
||||
if os.path.isabs(path):
|
||||
path = os.path.splitdrive(path)[1]
|
||||
|
||||
return as_posix(path)
|
||||
+430
-140
@@ -1,175 +1,447 @@
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import threading
|
||||
import typing
|
||||
from contextlib import suppress
|
||||
from itertools import takewhile
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type, Union
|
||||
|
||||
from fsspec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop
|
||||
from fsspec.spec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop, wrap_with
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
from dvc_objects.fs.base import FileSystem
|
||||
from dvc_objects.fs.callbacks import DEFAULT_CALLBACK
|
||||
from dvc_objects.fs.path import Path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from dvc.types import AnyPath
|
||||
from .data import DataFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
|
||||
Key = Tuple[str, ...]
|
||||
|
||||
|
||||
def as_posix(path: str) -> str:
|
||||
return path.replace(ntpath.sep, posixpath.sep)
|
||||
|
||||
|
||||
def _wrap_walk(dvc_fs, *args, **kwargs):
|
||||
for root, dnames, fnames in dvc_fs.walk(*args, **kwargs):
|
||||
yield dvc_fs.path.join(dvc_fs.repo.root_dir, root), dnames, fnames
|
||||
|
||||
|
||||
# NOT the same as dvc.dvcfile.is_dvc_file()!
|
||||
def _is_dvc_file(fname):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.ignore import DvcIgnore
|
||||
|
||||
return is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
|
||||
|
||||
|
||||
def _ls(fs, path):
|
||||
dnames = []
|
||||
fnames = []
|
||||
|
||||
for entry in fs.ls(path, detail=True):
|
||||
name = fs.path.name(entry["name"])
|
||||
if entry["type"] == "directory":
|
||||
dnames.append(name)
|
||||
else:
|
||||
fnames.append(name)
|
||||
|
||||
return dnames, fnames
|
||||
|
||||
|
||||
def _merge_info(repo, fs_info, dvc_info):
|
||||
from . import utils
|
||||
|
||||
ret = {"repo": repo}
|
||||
|
||||
if dvc_info:
|
||||
ret["dvc_info"] = dvc_info
|
||||
ret["type"] = dvc_info["type"]
|
||||
ret["size"] = dvc_info["size"]
|
||||
if not fs_info and "md5" in dvc_info:
|
||||
ret["md5"] = dvc_info["md5"]
|
||||
|
||||
if fs_info:
|
||||
ret["type"] = fs_info["type"]
|
||||
ret["size"] = fs_info["size"]
|
||||
isexec = False
|
||||
if fs_info["type"] == "file":
|
||||
isexec = utils.is_exec(fs_info["mode"])
|
||||
ret["isexec"] = isexec
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class _DvcFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
|
||||
"""DVC repo fs.
|
||||
"""DVC + git-tracked files fs.
|
||||
|
||||
Args:
|
||||
repo: DVC repo.
|
||||
repo: DVC or git repo.
|
||||
subrepos: traverse to subrepos (by default, it ignores subrepos)
|
||||
repo_factory: A function to initialize subrepo with, default is Repo.
|
||||
kwargs: Additional keyword arguments passed to the `DataFileSystem()`.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.repo = kwargs["repo"]
|
||||
root_marker = "/"
|
||||
|
||||
PARAM_REPO_URL = "repo_url"
|
||||
PARAM_REPO_ROOT = "repo_root"
|
||||
PARAM_REV = "rev"
|
||||
PARAM_CACHE_DIR = "cache_dir"
|
||||
PARAM_CACHE_TYPES = "cache_types"
|
||||
PARAM_SUBREPOS = "subrepos"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Optional["Repo"] = None,
|
||||
subrepos=False,
|
||||
repo_factory: RepoFactory = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
from pygtrie import Trie
|
||||
|
||||
if repo is None:
|
||||
repo, repo_factory = self._repo_from_fs_config(
|
||||
subrepos=subrepos, **kwargs
|
||||
)
|
||||
|
||||
if not repo_factory:
|
||||
from dvc.repo import Repo
|
||||
|
||||
self.repo_factory: RepoFactory = Repo
|
||||
else:
|
||||
self.repo_factory = repo_factory
|
||||
|
||||
def _getcwd():
|
||||
relparts = ()
|
||||
if repo.fs.path.isin(repo.fs.path.getcwd(), repo.root_dir):
|
||||
relparts = repo.fs.path.relparts(
|
||||
repo.fs.path.getcwd(), repo.root_dir
|
||||
)
|
||||
return self.root_marker + self.sep.join(relparts)
|
||||
|
||||
self.path = Path(self.sep, getcwd=_getcwd)
|
||||
self.repo = repo
|
||||
self.hash_jobs = repo.fs.hash_jobs
|
||||
self._traverse_subrepos = subrepos
|
||||
|
||||
self._subrepos_trie = Trie()
|
||||
"""Keeps track of each and every path with the corresponding repo."""
|
||||
|
||||
key = self._get_key(self.repo.root_dir)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
self._datafss = {}
|
||||
"""Keep a datafs instance of each repo."""
|
||||
|
||||
if hasattr(repo, "dvc_dir"):
|
||||
self._datafss[key] = DataFileSystem(index=repo.index.data["repo"])
|
||||
|
||||
def _get_key(self, path) -> Key:
|
||||
parts = self.repo.fs.path.relparts(path, self.repo.root_dir)
|
||||
if parts == (".",):
|
||||
parts = ()
|
||||
return parts
|
||||
|
||||
def _get_key_from_relative(self, path) -> Key:
|
||||
parts = self.path.relparts(path, self.root_marker)
|
||||
if parts and parts[0] == os.curdir:
|
||||
parts = parts[1:]
|
||||
return parts
|
||||
|
||||
def _from_key(self, parts: Key) -> str:
|
||||
return self.repo.fs.path.join(self.repo.root_dir, *parts)
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
if self.repo is None:
|
||||
return None
|
||||
return self.repo.url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_key(self, path):
|
||||
from dvc.fs.local import LocalFileSystem
|
||||
|
||||
from . import get_cloud_fs
|
||||
|
||||
cls, kwargs, fs_path = get_cloud_fs(None, url=path)
|
||||
|
||||
if cls != LocalFileSystem or os.path.isabs(path):
|
||||
fs = cls(**kwargs)
|
||||
return (cls.scheme, *fs.path.parts(fs_path))
|
||||
|
||||
fs_key = "repo"
|
||||
key = path.split(self.sep)
|
||||
if key == ["."] or key == [""]:
|
||||
key = ()
|
||||
|
||||
return (fs_key, *key)
|
||||
|
||||
def _get_fs_path(self, path: "AnyPath", remote=None):
|
||||
from dvc.config import NoRemoteError
|
||||
|
||||
info = self.info(path)
|
||||
if info["type"] == "directory":
|
||||
raise IsADirectoryError
|
||||
|
||||
value = info.get("md5")
|
||||
if not value:
|
||||
raise FileNotFoundError
|
||||
|
||||
cache_path = self.repo.odb.local.hash_to_path(value)
|
||||
|
||||
if self.repo.odb.local.fs.exists(cache_path):
|
||||
return self.repo.odb.local.fs, cache_path
|
||||
|
||||
try:
|
||||
remote_odb = self.repo.cloud.get_remote_odb(remote)
|
||||
except NoRemoteError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
remote_fs_path = remote_odb.hash_to_path(value)
|
||||
return remote_odb.fs, remote_fs_path
|
||||
|
||||
def open( # type: ignore
|
||||
self, path: str, mode="r", encoding=None, **kwargs
|
||||
): # pylint: disable=arguments-renamed, arguments-differ
|
||||
fs, fspath = self._get_fs_path(path, **kwargs)
|
||||
return fs.open(fspath, mode=mode, encoding=encoding)
|
||||
|
||||
def ls(self, path, detail=True, **kwargs):
|
||||
info = self.info(path)
|
||||
if info["type"] != "directory":
|
||||
return [info] if detail else [path]
|
||||
|
||||
root_key = self._get_key(path)
|
||||
try:
|
||||
entries = [
|
||||
self.sep.join((path, name)) if path else name
|
||||
for name in self.repo.index.tree.ls(prefix=root_key)
|
||||
]
|
||||
except KeyError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
if not detail:
|
||||
return entries
|
||||
|
||||
return [self.info(epath) for epath in entries]
|
||||
|
||||
def isdvc(self, path, recursive=False, strict=True):
|
||||
try:
|
||||
info = self.info(path)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
recurse = recursive or not strict
|
||||
return bool(info.get("outs") if recurse else info.get("isout"))
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
from dvc.data.meta import Meta
|
||||
|
||||
key = self._get_key(path)
|
||||
|
||||
try:
|
||||
outs = list(self.repo.index.tree.iteritems(key))
|
||||
except KeyError as exc:
|
||||
raise FileNotFoundError from exc
|
||||
|
||||
ret = {
|
||||
"type": "file",
|
||||
"size": 0,
|
||||
"isexec": False,
|
||||
"isdvc": False,
|
||||
"outs": outs,
|
||||
"name": path,
|
||||
return {
|
||||
self.PARAM_REPO_URL: self.repo_url,
|
||||
self.PARAM_REPO_ROOT: self.repo.root_dir,
|
||||
self.PARAM_REV: getattr(self.repo.fs, "rev", None),
|
||||
self.PARAM_CACHE_DIR: os.path.abspath(self.repo.odb.local.path),
|
||||
self.PARAM_CACHE_TYPES: self.repo.odb.local.cache_types,
|
||||
self.PARAM_SUBREPOS: self._traverse_subrepos,
|
||||
}
|
||||
|
||||
if len(outs) > 1 and outs[0][0] != key:
|
||||
shortest = self.repo.index.tree.shortest_prefix(key)
|
||||
if shortest:
|
||||
assert shortest[1][1].isdir
|
||||
if len(shortest[0]) <= len(key):
|
||||
ret["isdvc"] = True
|
||||
@classmethod
|
||||
def _repo_from_fs_config(
|
||||
cls, **config
|
||||
) -> Tuple["Repo", Optional["RepoFactory"]]:
|
||||
from dvc.external_repo import erepo_factory, external_repo
|
||||
from dvc.repo import Repo
|
||||
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
url = config.get(cls.PARAM_REPO_URL)
|
||||
root = config.get(cls.PARAM_REPO_ROOT)
|
||||
assert url or root
|
||||
|
||||
item_key, (meta, hash_info) = outs[0]
|
||||
def _open(*args, **kwargs):
|
||||
# NOTE: if original repo was an erepo (and has a URL),
|
||||
# we cannot use Repo.open() since it will skip erepo
|
||||
# cache/remote setup for local URLs
|
||||
if url is None:
|
||||
return Repo.open(*args, **kwargs)
|
||||
return external_repo(*args, **kwargs)
|
||||
|
||||
meta = meta or Meta()
|
||||
cache_dir = config.get(cls.PARAM_CACHE_DIR)
|
||||
cache_config = (
|
||||
{}
|
||||
if not cache_dir
|
||||
else {
|
||||
"cache": {
|
||||
"dir": cache_dir,
|
||||
"type": config.get(cls.PARAM_CACHE_TYPES),
|
||||
}
|
||||
}
|
||||
)
|
||||
repo_kwargs: dict = {
|
||||
"rev": config.get(cls.PARAM_REV),
|
||||
"subrepos": config.get(cls.PARAM_SUBREPOS, False),
|
||||
"uninitialized": True,
|
||||
}
|
||||
factory: Optional["RepoFactory"] = None
|
||||
if url is None:
|
||||
repo_kwargs["config"] = cache_config
|
||||
else:
|
||||
repo_kwargs["cache_dir"] = cache_dir
|
||||
factory = erepo_factory(url, root, cache_config)
|
||||
|
||||
if key != item_key:
|
||||
assert item_key[: len(key)] == key
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
with _open(
|
||||
url if url else root,
|
||||
**repo_kwargs,
|
||||
) as repo:
|
||||
return repo, factory
|
||||
|
||||
ret["size"] = meta.size
|
||||
ret["isexec"] = meta.isexec
|
||||
ret[hash_info.name] = hash_info.value
|
||||
ret["isdvc"] = True
|
||||
ret["isout"] = True
|
||||
ret["meta"] = meta
|
||||
if hash_info and hash_info.isdir:
|
||||
ret["type"] = "directory"
|
||||
return ret
|
||||
def _get_repo(self, key: Key) -> "Repo":
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
def get_file(self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs):
|
||||
fs, path = self._get_fs_path(rpath)
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
path, lpath, callback=callback, **kwargs
|
||||
If the path is already tracked/collected, it just returns the repo.
|
||||
|
||||
Otherwise, it collects the repos that might be in the path's parents
|
||||
and then returns the appropriate one.
|
||||
"""
|
||||
repo = self._subrepos_trie.get(key)
|
||||
if repo:
|
||||
return repo
|
||||
|
||||
prefix_key, repo = self._subrepos_trie.longest_prefix(key)
|
||||
prefix = self.repo.fs.path.join(
|
||||
self.repo.root_dir,
|
||||
*prefix_key, # pylint: disable=not-an-iterable
|
||||
)
|
||||
|
||||
path = self._from_key(key)
|
||||
parents = (parent for parent in self.repo.fs.path.parents(path))
|
||||
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
|
||||
dirs.reverse()
|
||||
self._update(dirs, starting_repo=repo)
|
||||
return self._subrepos_trie.get(key) or self.repo
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _update(self, dirs, starting_repo):
|
||||
"""Checks for subrepo in directories and updates them."""
|
||||
repo = starting_repo
|
||||
for d in dirs:
|
||||
key = self._get_key(d)
|
||||
if self._is_dvc_repo(d):
|
||||
repo = self.repo_factory(
|
||||
d,
|
||||
fs=self.repo.fs,
|
||||
scm=self.repo.scm,
|
||||
repo_factory=self.repo_factory,
|
||||
)
|
||||
self._datafss[key] = DataFileSystem(
|
||||
index=repo.index.data["repo"]
|
||||
)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
def _is_dvc_repo(self, dir_path):
|
||||
"""Check if the directory is a dvc repo."""
|
||||
if not self._traverse_subrepos:
|
||||
return False
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
repo_path = self.repo.fs.path.join(dir_path, Repo.DVC_DIR)
|
||||
return self.repo.fs.isdir(repo_path)
|
||||
|
||||
def _get_fs_pair(
|
||||
self, path
|
||||
) -> Tuple[
|
||||
Optional["Repo"],
|
||||
Optional[FileSystem],
|
||||
Optional[str],
|
||||
Optional[DataFileSystem],
|
||||
Optional[str],
|
||||
]:
|
||||
"""
|
||||
Returns a pair of fss based on repo the path falls in, using prefix.
|
||||
"""
|
||||
key = self._get_key_from_relative(path)
|
||||
|
||||
fs_path = self._from_key(key)
|
||||
repo = self._get_repo(key)
|
||||
fs = repo.fs
|
||||
if repo is self.repo:
|
||||
dvc_parts = key
|
||||
dvc_fs = self._datafss.get(())
|
||||
else:
|
||||
repo_parts = fs.path.relparts(repo.root_dir, self.repo.root_dir)
|
||||
dvc_parts = key[len(repo_parts) :]
|
||||
key = self._get_key(repo.root_dir)
|
||||
dvc_fs = self._datafss.get(key)
|
||||
|
||||
if dvc_fs:
|
||||
dvc_path = dvc_fs.path.join(*dvc_parts) if dvc_parts else ""
|
||||
else:
|
||||
dvc_path = None
|
||||
|
||||
return repo, fs, fs_path, dvc_fs, dvc_path
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding="utf-8", **kwargs
|
||||
): # pylint: disable=arguments-renamed, arguments-differ
|
||||
if "b" in mode:
|
||||
encoding = None
|
||||
|
||||
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
try:
|
||||
return fs.open(fs_path, mode=mode, encoding=encoding)
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
return dvc_fs.open(dvc_path, mode=mode, encoding=encoding, **kwargs)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
_, _, _, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
|
||||
|
||||
def ls( # pylint: disable=arguments-differ
|
||||
self, path, detail=True, dvc_only=False, **kwargs
|
||||
):
|
||||
repo, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
names = set()
|
||||
if dvc_fs:
|
||||
with suppress(FileNotFoundError):
|
||||
for entry in dvc_fs.ls(dvc_path, detail=False):
|
||||
names.add(dvc_fs.path.name(entry))
|
||||
|
||||
if not dvc_only and fs:
|
||||
try:
|
||||
for entry in dvcignore.ls(
|
||||
fs, fs_path, detail=False, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
names.add(fs.path.name(entry))
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
dvcfiles = kwargs.get("dvcfiles", False)
|
||||
if not dvcfiles:
|
||||
names = (name for name in names if not _is_dvc_file(name))
|
||||
|
||||
infos = []
|
||||
paths = []
|
||||
for name in names:
|
||||
entry_path = self.path.join(path, name)
|
||||
try:
|
||||
info = self.info(entry_path, ignore_subrepos=ignore_subrepos)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
infos.append(info)
|
||||
paths.append(entry_path)
|
||||
|
||||
if not detail:
|
||||
return paths
|
||||
|
||||
return infos
|
||||
|
||||
def get_file( # pylint: disable=arguments-differ
|
||||
self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs
|
||||
):
|
||||
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(rpath)
|
||||
|
||||
if fs:
|
||||
try:
|
||||
fs.get_file(fs_path, lpath, callback=callback, **kwargs)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
dvc_fs.get_file(dvc_path, lpath, callback=callback, **kwargs)
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
repo, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
dvc_info = None
|
||||
if dvc_fs:
|
||||
try:
|
||||
dvc_info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
fs_info = None
|
||||
if fs:
|
||||
try:
|
||||
fs_info = fs.info(fs_path)
|
||||
if dvcignore.is_ignored(
|
||||
fs, fs_path, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
fs_info = None
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
if not dvc_info:
|
||||
raise
|
||||
|
||||
# NOTE: if some parent in fs_path turns out to be a file, it means
|
||||
# that the whole repofs branch doesn't exist.
|
||||
if fs and not fs_info and dvc_info:
|
||||
for parent in fs.path.parents(fs_path):
|
||||
try:
|
||||
if fs.info(parent)["type"] != "directory":
|
||||
dvc_info = None
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
if not dvc_info and not fs_info:
|
||||
raise FileNotFoundError
|
||||
|
||||
info = _merge_info(repo, fs_info, dvc_info)
|
||||
info["name"] = path
|
||||
return info
|
||||
|
||||
def checksum(self, path):
|
||||
info = self.info(path)
|
||||
md5 = info.get("md5")
|
||||
if md5:
|
||||
return md5
|
||||
raise NotImplementedError
|
||||
_, fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
try:
|
||||
return fs.checksum(fs_path)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.checksum(dvc_path)
|
||||
|
||||
|
||||
class DvcFileSystem(FileSystem):
|
||||
scheme = "local"
|
||||
|
||||
protocol = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
@@ -183,6 +455,24 @@ class DvcFileSystem(FileSystem):
|
||||
def isdvc(self, path, **kwargs):
|
||||
return self.fs.isdvc(path, **kwargs)
|
||||
|
||||
@property
|
||||
def path(self): # pylint: disable=invalid-overridden-method
|
||||
return self.fs.path
|
||||
|
||||
@property
|
||||
def repo(self):
|
||||
return self.fs.repo
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
return self.fs.repo_url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self.fs.config
|
||||
|
||||
def from_os_path(self, path):
|
||||
if os.path.isabs(path):
|
||||
path = os.path.relpath(path, self.repo.root_dir)
|
||||
|
||||
return as_posix(path)
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import format_link, tmp_fname
|
||||
|
||||
from .base import FileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
|
||||
|
||||
|
||||
class GDriveAuthError(DvcException):
|
||||
def __init__(self, cred_location):
|
||||
|
||||
if cred_location:
|
||||
message = (
|
||||
"GDrive remote auth failed with credentials in '{}'.\n"
|
||||
"Backup first, remove or fix them, and run DVC again.\n"
|
||||
"It should do auth again and refresh the credentials.\n\n"
|
||||
"Details:".format(cred_location)
|
||||
)
|
||||
else:
|
||||
message = "Failed to authenticate GDrive remote"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class GDriveFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.GDRIVE
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
REQUIRES = {"pydrive2": "pydrive2"}
|
||||
# Always prefer traverse for GDrive since API usage quotas are a concern.
|
||||
TRAVERSE_WEIGHT_MULTIPLIER = 1
|
||||
|
||||
GDRIVE_CREDENTIALS_DATA = "GDRIVE_CREDENTIALS_DATA"
|
||||
DEFAULT_USER_CREDENTIALS_FILE = "gdrive-user-credentials.json"
|
||||
|
||||
DEFAULT_GDRIVE_CLIENT_ID = "710796635688-iivsgbgsb6uv1fap6635dhvuei09o66c.apps.googleusercontent.com" # noqa: E501
|
||||
DEFAULT_GDRIVE_CLIENT_SECRET = "a1Fz59uTpVNeG_VGuSKDLJXv"
|
||||
|
||||
def __init__(self, **config):
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
super().__init__(**config)
|
||||
|
||||
self.url = config["url"]
|
||||
opts = infer_storage_options(self.url)
|
||||
|
||||
if not opts["host"]:
|
||||
raise DvcException(
|
||||
"Empty GDrive URL '{}'. Learn more at {}".format(
|
||||
config["url"],
|
||||
format_link("https://man.dvc.org/remote/add"),
|
||||
)
|
||||
)
|
||||
|
||||
self._bucket = opts["host"]
|
||||
self._path = opts["path"].lstrip("/")
|
||||
self._trash_only = config.get("gdrive_trash_only")
|
||||
self._use_service_account = config.get("gdrive_use_service_account")
|
||||
self._service_account_user_email = config.get(
|
||||
"gdrive_service_account_user_email"
|
||||
)
|
||||
self._service_account_json_file_path = config.get(
|
||||
"gdrive_service_account_json_file_path"
|
||||
)
|
||||
self._client_id = config.get("gdrive_client_id")
|
||||
self._client_secret = config.get("gdrive_client_secret")
|
||||
self._validate_config()
|
||||
|
||||
tmp_dir = config["gdrive_credentials_tmp_dir"]
|
||||
assert tmp_dir
|
||||
|
||||
self._gdrive_service_credentials_path = tmp_fname(
|
||||
os.path.join(tmp_dir, "")
|
||||
)
|
||||
self._gdrive_user_credentials_path = (
|
||||
tmp_fname(os.path.join(tmp_dir, ""))
|
||||
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
|
||||
else config.get(
|
||||
"gdrive_user_credentials_file",
|
||||
os.path.join(tmp_dir, self.DEFAULT_USER_CREDENTIALS_FILE),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path):
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
opts = infer_storage_options(path)
|
||||
|
||||
return "{host}{path}".format(**opts)
|
||||
|
||||
def unstrip_protocol(self, path):
|
||||
return f"gdrive://{path}"
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
return {"url": urlpath}
|
||||
|
||||
def _validate_config(self):
|
||||
# Validate Service Account configuration
|
||||
if (
|
||||
self._use_service_account
|
||||
and not self._service_account_json_file_path
|
||||
and GDriveFileSystem.GDRIVE_CREDENTIALS_DATA not in os.environ
|
||||
):
|
||||
raise DvcException(
|
||||
"To use service account, set "
|
||||
"`gdrive_service_account_json_file_path`, and optionally"
|
||||
"`gdrive_service_account_user_email` in DVC config\n"
|
||||
"Learn more at {}".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
# Validate OAuth 2.0 Client ID configuration
|
||||
if not self._use_service_account:
|
||||
if bool(self._client_id) != bool(self._client_secret):
|
||||
raise DvcException(
|
||||
"Please specify GDrive's client ID and secret in "
|
||||
"DVC config or omit both to use the defaults.\n"
|
||||
"Learn more at {}".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def credentials_location(self):
|
||||
"""
|
||||
Helper to determine where will GDrive remote read credentials from.
|
||||
Useful for tests, exception messages, etc. Returns either env variable
|
||||
name if it's set or actual path to the credentials file.
|
||||
"""
|
||||
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
|
||||
return GDriveFileSystem.GDRIVE_CREDENTIALS_DATA
|
||||
if os.path.exists(self._gdrive_user_credentials_path):
|
||||
return self._gdrive_user_credentials_path
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _validate_credentials(auth, settings):
|
||||
"""
|
||||
Detects discrepancy in DVC config and cached credentials file.
|
||||
Usually happens when a second remote is added and it is using
|
||||
the same credentials default file. Or when someones decides to change
|
||||
DVC config client id or secret but forgets to remove the cached
|
||||
credentials file.
|
||||
"""
|
||||
if not os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
|
||||
if (
|
||||
settings["client_config"]["client_id"]
|
||||
!= auth.credentials.client_id
|
||||
or settings["client_config"]["client_secret"]
|
||||
!= auth.credentials.client_secret
|
||||
):
|
||||
logger.warning(
|
||||
"Client ID and secret configured do not match the "
|
||||
"actual ones used\nto access the remote. Do you "
|
||||
"use multiple GDrive remotes and forgot to\nset "
|
||||
"`gdrive_user_credentials_file` for one or more of them? "
|
||||
"Learn more at\n{}.\n".format(
|
||||
format_link("https://man.dvc.org/remote/modify")
|
||||
)
|
||||
)
|
||||
|
||||
@wrap_prop(threading.RLock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from pydrive2.auth import GoogleAuth
|
||||
from pydrive2.fs import GDriveFileSystem as _GDriveFileSystem
|
||||
|
||||
temporary_save_path = self._gdrive_user_credentials_path
|
||||
is_credentials_temp = os.getenv(
|
||||
GDriveFileSystem.GDRIVE_CREDENTIALS_DATA
|
||||
)
|
||||
if self._use_service_account:
|
||||
temporary_save_path = self._gdrive_service_credentials_path
|
||||
|
||||
if is_credentials_temp:
|
||||
with open(temporary_save_path, "w", encoding="utf-8") as cred_file:
|
||||
cred_file.write(
|
||||
os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
|
||||
)
|
||||
|
||||
auth_settings = {
|
||||
"client_config_backend": "settings",
|
||||
"save_credentials": True,
|
||||
"save_credentials_backend": "file",
|
||||
"save_credentials_file": self._gdrive_user_credentials_path,
|
||||
"get_refresh_token": True,
|
||||
"oauth_scope": [
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/drive.appdata",
|
||||
],
|
||||
}
|
||||
|
||||
if self._use_service_account:
|
||||
auth_settings["service_config"] = {
|
||||
"client_user_email": self._service_account_user_email,
|
||||
"client_json_file_path": self._service_account_json_file_path,
|
||||
}
|
||||
if is_credentials_temp:
|
||||
auth_settings["service_config"][
|
||||
"client_json_file_path"
|
||||
] = temporary_save_path
|
||||
|
||||
else:
|
||||
auth_settings["client_config"] = {
|
||||
"client_id": self._client_id or self.DEFAULT_GDRIVE_CLIENT_ID,
|
||||
"client_secret": self._client_secret
|
||||
or self.DEFAULT_GDRIVE_CLIENT_SECRET,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"revoke_uri": "https://oauth2.googleapis.com/revoke",
|
||||
"redirect_uri": "",
|
||||
}
|
||||
|
||||
GoogleAuth.DEFAULT_SETTINGS.update(auth_settings)
|
||||
|
||||
# Pass non existent settings path to force DEFAULT_SETTINGS loadings
|
||||
gauth = GoogleAuth(settings_file="")
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"GDrive remote auth with config '{}'.".format(
|
||||
GoogleAuth.DEFAULT_SETTINGS
|
||||
)
|
||||
)
|
||||
if self._use_service_account:
|
||||
gauth.ServiceAuth()
|
||||
else:
|
||||
gauth.CommandLineAuth()
|
||||
GDriveFileSystem._validate_credentials(gauth, auth_settings)
|
||||
|
||||
# Handle AuthenticationError, RefreshError and other auth failures
|
||||
# It's hard to come up with a narrow exception, since PyDrive throws
|
||||
# a lot of different errors - broken credentials file, refresh token
|
||||
# expired, flow failed, etc.
|
||||
except Exception as exc:
|
||||
raise GDriveAuthError(self.credentials_location) from exc
|
||||
finally:
|
||||
if is_credentials_temp:
|
||||
os.remove(temporary_save_path)
|
||||
|
||||
return _GDriveFileSystem(
|
||||
f"{self._bucket}/{self._path}",
|
||||
gauth,
|
||||
trash_only=self._trash_only,
|
||||
)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(os.path.dirname(to_info))
|
||||
return self.fs.upload_fobj(fobj, to_info, **kwargs)
|
||||
+6
-4
@@ -1,10 +1,9 @@
|
||||
import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from .base import FileSystem
|
||||
from . import FileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scmrepo.fs import GitFileSystem as FsspecGitFileSystem
|
||||
@@ -15,8 +14,7 @@ if TYPE_CHECKING:
|
||||
class GitFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
"""Proxies the repo file access methods to Git objects"""
|
||||
|
||||
sep = os.sep
|
||||
scheme = "local"
|
||||
protocol = "local"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -47,6 +45,10 @@ class GitFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
|
||||
return FsspecGitFileSystem(**self.fs_args)
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
return self.fs.path
|
||||
|
||||
@property
|
||||
def rev(self) -> str:
|
||||
return self.fs.rev
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
from .base import ObjectFileSystem
|
||||
|
||||
|
||||
class GSFileSystem(ObjectFileSystem):
|
||||
scheme = Schemes.GS
|
||||
REQUIRES = {"gcsfs": "gcsfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {"consistency": None}
|
||||
login_info["project"] = config.get("projectname")
|
||||
login_info["token"] = config.get("credentialpath")
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from gcsfs import GCSFileSystem
|
||||
|
||||
return GCSFileSystem(**self.fs_args)
|
||||
@@ -1,99 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils import fix_env
|
||||
|
||||
from .base import FileSystem
|
||||
|
||||
CHECKSUM_REGEX = re.compile(r".*\t.*\t(?P<checksum>.*)")
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class HDFSFileSystem(FileSystem):
|
||||
scheme = Schemes.HDFS
|
||||
REQUIRES = {"fsspec": "fsspec", "pyarrow": "pyarrow"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args.get("port")
|
||||
netloc = host + (f":{port}" if port else "")
|
||||
return "hdfs://" + netloc + "/" + path.lstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
# pylint:disable=protected-access
|
||||
return HadoopFileSystem._get_kwargs_from_urls(urlpath)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.arrow import HadoopFileSystem
|
||||
|
||||
return HadoopFileSystem(**self.fs_args)
|
||||
|
||||
def checksum(self, path):
|
||||
return self._checksum(path)
|
||||
|
||||
def _checksum(self, path, **kwargs):
|
||||
# PyArrow doesn't natively support retrieving the
|
||||
# checksum, so we have to use hadoop fs
|
||||
|
||||
url = self.unstrip_protocol(path)
|
||||
|
||||
result = self._run_command(
|
||||
f"checksum {url}",
|
||||
env=fix_env(os.environ),
|
||||
user=self.fs_args.get("user"),
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
|
||||
match = CHECKSUM_REGEX.match(result)
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
return match.group("checksum")
|
||||
|
||||
def _run_command(self, cmd, env=None, user=None):
|
||||
cmd = "hadoop fs -" + cmd
|
||||
if user:
|
||||
cmd = f"HADOOP_USER_NAME={user} " + cmd
|
||||
|
||||
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
|
||||
# See https://github.com/iterative/dvc/issues/1197.
|
||||
close_fds = os.name != "nt"
|
||||
|
||||
executable = os.getenv("SHELL") if os.name != "nt" else None
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
close_fds=close_fds,
|
||||
executable=executable,
|
||||
env=env or os.environ,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
out, err = p.communicate()
|
||||
|
||||
if p.returncode != 0:
|
||||
raise subprocess.CalledProcessError(p.returncode, cmd, out, err)
|
||||
else:
|
||||
return out.decode("utf-8")
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import threading
|
||||
from typing import IO, Union
|
||||
|
||||
from funcy import cached_property, memoize, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK, FsspecCallback
|
||||
from .base import AnyFSPath, FileSystem
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user):
|
||||
return prompt.password(
|
||||
"Enter a password for "
|
||||
"host '{host}' user '{user}'".format(host=host, user=user)
|
||||
)
|
||||
|
||||
|
||||
def make_context(ssl_verify):
|
||||
if isinstance(ssl_verify, bool) or ssl_verify is None:
|
||||
return ssl_verify
|
||||
|
||||
# If this is a path, then we will create an
|
||||
# SSL context for it, and load the given certificate.
|
||||
import ssl
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.load_verify_locations(ssl_verify)
|
||||
return context
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class HTTPFileSystem(FileSystem):
|
||||
scheme = Schemes.HTTP
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
REQUIRES = {"aiohttp": "aiohttp", "aiohttp-retry": "aiohttp_retry"}
|
||||
CAN_TRAVERSE = False
|
||||
|
||||
SESSION_RETRIES = 5
|
||||
SESSION_BACKOFF_FACTOR = 0.1
|
||||
REQUEST_TIMEOUT = 60
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
import aiohttp
|
||||
from fsspec.asyn import fsspec_loop
|
||||
|
||||
from dvc.config import ConfigError
|
||||
|
||||
credentials = {}
|
||||
client_kwargs = credentials.setdefault("client_kwargs", {})
|
||||
|
||||
if config.get("auth"):
|
||||
user = config.get("user")
|
||||
password = config.get("password")
|
||||
custom_auth_header = config.get("custom_auth_header")
|
||||
|
||||
if password is None and config.get("ask_password"):
|
||||
password = ask_password(config.get("url"), user or "custom")
|
||||
|
||||
auth_method = config["auth"]
|
||||
if auth_method == "basic":
|
||||
if user is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'basic' authentication require both "
|
||||
"'user' and 'password'"
|
||||
)
|
||||
|
||||
client_kwargs["auth"] = aiohttp.BasicAuth(user, password)
|
||||
elif auth_method == "custom":
|
||||
if custom_auth_header is None or password is None:
|
||||
raise ConfigError(
|
||||
"HTTP 'custom' authentication require both "
|
||||
"'custom_auth_header' and 'password'"
|
||||
)
|
||||
credentials["headers"] = {custom_auth_header: password}
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Auth method {auth_method!r} is not supported."
|
||||
)
|
||||
|
||||
# Force cleanup of closed SSL transports.
|
||||
# https://github.com/iterative/dvc/issues/7414
|
||||
connector_kwargs = {"enable_cleanup_closed": True}
|
||||
|
||||
if "ssl_verify" in config:
|
||||
connector_kwargs.update(ssl=make_context(config["ssl_verify"]))
|
||||
|
||||
with fsspec_loop():
|
||||
client_kwargs["connector"] = aiohttp.TCPConnector(
|
||||
**connector_kwargs
|
||||
)
|
||||
# The connector should not be owned by aiohttp.ClientSession since
|
||||
# it is closed by fsspec (HTTPFileSystem.close_session)
|
||||
client_kwargs["connector_owner"] = False
|
||||
|
||||
# Allow reading proxy configurations from the environment.
|
||||
client_kwargs["trust_env"] = True
|
||||
|
||||
credentials["get_client"] = self.get_client
|
||||
self.upload_method = config.get("method", "POST")
|
||||
return credentials
|
||||
|
||||
async def get_client(self, **kwargs):
|
||||
import aiohttp
|
||||
from aiohttp_retry import ExponentialRetry, RetryClient
|
||||
|
||||
kwargs["retry_options"] = ExponentialRetry(
|
||||
attempts=self.SESSION_RETRIES,
|
||||
factor=self.SESSION_BACKOFF_FACTOR,
|
||||
max_timeout=self.REQUEST_TIMEOUT,
|
||||
exceptions=[aiohttp.ClientError],
|
||||
)
|
||||
|
||||
# The default timeout for the aiohttp is 300 seconds
|
||||
# which is too low for DVC's interactions (especially
|
||||
# on the read) when dealing with large data blobs. We
|
||||
# unlimit the total time to read, and only limit the
|
||||
# time that is spent when connecting to the remote server.
|
||||
kwargs["timeout"] = aiohttp.ClientTimeout(
|
||||
total=None,
|
||||
connect=self.REQUEST_TIMEOUT,
|
||||
sock_connect=self.REQUEST_TIMEOUT,
|
||||
sock_read=self.REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
return RetryClient(**kwargs)
|
||||
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.http import (
|
||||
HTTPFileSystem as _HTTPFileSystem,
|
||||
)
|
||||
|
||||
return _HTTPFileSystem(**self.fs_args)
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return path
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file: Union[AnyFSPath, IO],
|
||||
to_info: AnyFSPath,
|
||||
callback: FsspecCallback = DEFAULT_CALLBACK,
|
||||
size: int = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
kwargs.setdefault("method", self.upload_method)
|
||||
super().put_file(
|
||||
from_file, to_info, callback=callback, size=size, **kwargs
|
||||
)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
|
||||
def find(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def isdir(self, *args, **kwargs):
|
||||
return False
|
||||
|
||||
def isfile(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
def ls(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def walk(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
# pylint: enable=arguments-differ
|
||||
@@ -1,7 +0,0 @@
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .http import HTTPFileSystem
|
||||
|
||||
|
||||
class HTTPSFileSystem(HTTPFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.HTTPS
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from fsspec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.system import System
|
||||
from dvc.utils import tmp_fname
|
||||
from dvc.utils.fs import copy_fobj_to_file, copyfile, makedirs, move, remove
|
||||
|
||||
from .base import FileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method, arguments-differ
|
||||
class FsspecLocalFileSystem(AbstractFileSystem):
|
||||
sep = os.sep
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from fsspec.implementations.local import LocalFileSystem as LocalFS
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fs = LocalFS()
|
||||
|
||||
def makedirs(self, path, exist_ok=False):
|
||||
makedirs(path, exist_ok=exist_ok)
|
||||
|
||||
def mkdir(self, path, create_parents=True, **kwargs):
|
||||
if self.exists(path):
|
||||
raise FileExistsError(path)
|
||||
if create_parents:
|
||||
self.makedirs(path, exist_ok=True)
|
||||
else:
|
||||
os.mkdir(path, **kwargs)
|
||||
|
||||
def lexists(self, path, **kwargs):
|
||||
return os.path.lexists(path)
|
||||
|
||||
def exists(self, path, **kwargs):
|
||||
# TODO: replace this with os.path.exists once the problem is fixed on
|
||||
# the fsspec https://github.com/intake/filesystem_spec/issues/742
|
||||
return os.path.lexists(path)
|
||||
|
||||
def checksum(self, path) -> str:
|
||||
from fsspec.utils import tokenize
|
||||
|
||||
st = os.stat(path)
|
||||
return str(int(tokenize([st.st_ino, st.st_mtime, st.st_size]), 16))
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
return self.fs.info(path)
|
||||
|
||||
def ls(self, path, **kwargs):
|
||||
return self.fs.ls(path, **kwargs)
|
||||
|
||||
def isfile(self, path) -> bool:
|
||||
return os.path.isfile(path)
|
||||
|
||||
def isdir(self, path) -> bool:
|
||||
return os.path.isdir(path)
|
||||
|
||||
def walk(self, path, maxdepth=None, topdown=True, **kwargs):
|
||||
"""Directory fs generator.
|
||||
|
||||
See `os.walk` for the docs. Differences:
|
||||
- no support for symlinks
|
||||
"""
|
||||
for root, dirs, files in os.walk(
|
||||
path,
|
||||
topdown=topdown,
|
||||
):
|
||||
yield os.path.normpath(root), dirs, files
|
||||
|
||||
def find(self, path, **kwargs):
|
||||
for root, _, files in self.walk(path, **kwargs):
|
||||
for file in files:
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
yield f"{root}{os.sep}{file}"
|
||||
|
||||
@classmethod
|
||||
def _parent(cls, path):
|
||||
return os.path.dirname(path)
|
||||
|
||||
def put_file(self, lpath, rpath, callback=None, **kwargs):
|
||||
parent = self._parent(rpath)
|
||||
makedirs(parent, exist_ok=True)
|
||||
tmp_file = os.path.join(parent, tmp_fname())
|
||||
copyfile(lpath, tmp_file, callback=callback)
|
||||
os.replace(tmp_file, rpath)
|
||||
|
||||
def get_file(self, rpath, lpath, callback=None, **kwargs):
|
||||
copyfile(rpath, lpath, callback=callback)
|
||||
|
||||
def mv(self, path1, path2, **kwargs):
|
||||
self.makedirs(self._parent(path2), exist_ok=True)
|
||||
move(path1, path2)
|
||||
|
||||
def rmdir(self, path):
|
||||
os.rmdir(path)
|
||||
|
||||
def rm_file(self, path):
|
||||
remove(path)
|
||||
|
||||
def rm(self, path, recursive=False, maxdepth=None):
|
||||
remove(path)
|
||||
|
||||
def copy(self, path1, path2, recursive=False, on_error=None, **kwargs):
|
||||
tmp_info = os.path.join(self._parent(path2), tmp_fname(""))
|
||||
try:
|
||||
copyfile(path1, tmp_info)
|
||||
os.rename(tmp_info, path2)
|
||||
except Exception:
|
||||
self.rm_file(tmp_info)
|
||||
raise
|
||||
|
||||
def open(self, path, mode="r", encoding=None, **kwargs):
|
||||
return open(path, mode=mode, encoding=encoding)
|
||||
|
||||
def symlink(self, path1, path2):
|
||||
return System.symlink(path1, path2)
|
||||
|
||||
@staticmethod
|
||||
def is_symlink(path):
|
||||
return System.is_symlink(path)
|
||||
|
||||
@staticmethod
|
||||
def is_hardlink(path):
|
||||
return System.is_hardlink(path)
|
||||
|
||||
def hardlink(self, path1, path2):
|
||||
# If there are a lot of empty files (which happens a lot in datasets),
|
||||
# and the cache type is `hardlink`, we might reach link limits and
|
||||
# will get something like: `too many links error`
|
||||
#
|
||||
# This is because all those empty files will have the same hash
|
||||
# (i.e. 68b329da9893e34099c7d8ad5cb9c940), therefore, they will be
|
||||
# linked to the same file in the cache.
|
||||
#
|
||||
# From https://en.wikipedia.org/wiki/Hard_link
|
||||
# * ext4 limits the number of hard links on a file to 65,000
|
||||
# * Windows with NTFS has a limit of 1024 hard links on a file
|
||||
#
|
||||
# That's why we simply create an empty file rather than a link.
|
||||
if self.size(path1) == 0:
|
||||
self.open(path2, "w").close()
|
||||
|
||||
logger.debug("Created empty file: %s -> %s", path1, path2)
|
||||
return
|
||||
|
||||
return System.hardlink(path1, path2)
|
||||
|
||||
def reflink(self, path1, path2):
|
||||
return System.reflink(path1, path2)
|
||||
|
||||
|
||||
class LocalFileSystem(FileSystem):
|
||||
sep = os.sep
|
||||
|
||||
scheme = Schemes.LOCAL
|
||||
PARAM_CHECKSUM = "md5"
|
||||
PARAM_PATH = "path"
|
||||
TRAVERSE_PREFIX_LEN = 2
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
return FsspecLocalFileSystem(**self.config)
|
||||
|
||||
@cached_property
|
||||
def path(self):
|
||||
from .path import Path
|
||||
|
||||
return Path(self.sep, os.getcwd)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
self.makedirs(self.path.parent(to_info))
|
||||
tmp_info = self.path.join(self.path.parent(to_info), tmp_fname(""))
|
||||
try:
|
||||
copy_fobj_to_file(fobj, tmp_info)
|
||||
os.rename(tmp_info, to_info)
|
||||
except Exception:
|
||||
self.remove(tmp_info)
|
||||
raise
|
||||
|
||||
|
||||
localfs = LocalFileSystem()
|
||||
@@ -1,26 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import FileSystem
|
||||
|
||||
|
||||
class MemoryFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.MEMORY
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def __eq__(self, other):
|
||||
# NOTE: all fsspec MemoryFileSystem instances are equivalent and use a
|
||||
# single global store
|
||||
return isinstance(other, type(self))
|
||||
|
||||
__hash__ = FileSystem.__hash__
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.memory import MemoryFileSystem as MemFS
|
||||
|
||||
return MemFS(**self.fs_args)
|
||||
@@ -1,37 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import ObjectFileSystem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class OSSFileSystem(ObjectFileSystem):
|
||||
scheme = Schemes.OSS
|
||||
REQUIRES = {"ossfs": "ossfs"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
LIST_OBJECT_PAGE_SIZE = 100
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
login_info = {}
|
||||
login_info["key"] = config.get("oss_key_id") or os.getenv(
|
||||
"OSS_ACCESS_KEY_ID"
|
||||
)
|
||||
login_info["secret"] = config.get("oss_key_secret") or os.getenv(
|
||||
"OSS_ACCESS_KEY_SECRET"
|
||||
)
|
||||
login_info["endpoint"] = config.get("oss_endpoint")
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from ossfs import OSSFileSystem as _OSSFileSystem
|
||||
|
||||
return _OSSFileSystem(**self.fs_args)
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import ntpath
|
||||
import posixpath
|
||||
|
||||
|
||||
class Path:
|
||||
def __init__(self, sep, getcwd=None):
|
||||
def _getcwd():
|
||||
return ""
|
||||
|
||||
self.getcwd = getcwd or _getcwd
|
||||
if sep == posixpath.sep:
|
||||
self.flavour = posixpath
|
||||
elif sep == ntpath.sep:
|
||||
self.flavour = ntpath
|
||||
else:
|
||||
raise ValueError(f"unsupported separator '{sep}'")
|
||||
|
||||
def join(self, *parts):
|
||||
return self.flavour.join(*parts)
|
||||
|
||||
def split(self, path):
|
||||
return self.flavour.split(path)
|
||||
|
||||
def normpath(self, path):
|
||||
return self.flavour.normpath(path)
|
||||
|
||||
def isabs(self, path):
|
||||
return self.flavour.isabs(path)
|
||||
|
||||
def abspath(self, path):
|
||||
if not self.isabs(path):
|
||||
path = self.join(self.getcwd(), path)
|
||||
return self.normpath(path)
|
||||
|
||||
def commonprefix(self, path):
|
||||
return self.flavour.commonprefix(path)
|
||||
|
||||
def parts(self, path):
|
||||
drive, path = self.flavour.splitdrive(path.rstrip(self.flavour.sep))
|
||||
|
||||
ret = []
|
||||
while True:
|
||||
path, part = self.flavour.split(path)
|
||||
|
||||
if part:
|
||||
ret.append(part)
|
||||
continue
|
||||
|
||||
if path:
|
||||
ret.append(path)
|
||||
|
||||
break
|
||||
|
||||
ret.reverse()
|
||||
|
||||
if drive:
|
||||
ret = [drive] + ret
|
||||
|
||||
return tuple(ret)
|
||||
|
||||
def parent(self, path):
|
||||
return self.flavour.dirname(path)
|
||||
|
||||
def dirname(self, path):
|
||||
return self.parent(path)
|
||||
|
||||
def parents(self, path):
|
||||
parts = self.parts(path)
|
||||
return tuple(
|
||||
self.join(*parts[:length])
|
||||
for length in range(len(parts) - 1, 0, -1)
|
||||
)
|
||||
|
||||
def name(self, path):
|
||||
return self.parts(path)[-1]
|
||||
|
||||
def suffix(self, path):
|
||||
name = self.name(path)
|
||||
_, dot, suffix = name.partition(".")
|
||||
return dot + suffix
|
||||
|
||||
def with_name(self, path, name):
|
||||
parts = list(self.parts(path))
|
||||
parts[-1] = name
|
||||
return self.join(*parts)
|
||||
|
||||
def with_suffix(self, path, suffix):
|
||||
parts = list(self.parts(path))
|
||||
real_path, _, _ = parts[-1].partition(".")
|
||||
parts[-1] = real_path + suffix
|
||||
return self.join(*parts)
|
||||
|
||||
def isin(self, left, right):
|
||||
left_parts = self.parts(left)
|
||||
right_parts = self.parts(right)
|
||||
left_len = len(left_parts)
|
||||
right_len = len(right_parts)
|
||||
return left_len > right_len and left_parts[:right_len] == right_parts
|
||||
|
||||
def isin_or_eq(self, left, right):
|
||||
return left == right or self.isin(left, right)
|
||||
|
||||
def overlaps(self, left, right):
|
||||
# pylint: disable=arguments-out-of-order
|
||||
return self.isin_or_eq(left, right) or self.isin(right, left)
|
||||
|
||||
def relpath(self, path, start):
|
||||
return self.flavour.relpath(path, start=start)
|
||||
|
||||
def relparts(self, path, base):
|
||||
return self.parts(self.relpath(path, base))
|
||||
|
||||
def as_posix(self, path):
|
||||
return path.replace(self.flavour.sep, posixpath.sep)
|
||||
-463
@@ -1,463 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from itertools import takewhile
|
||||
from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type, Union
|
||||
|
||||
from fsspec.spec import AbstractFileSystem
|
||||
from funcy import cached_property, wrap_prop, wrap_with
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
from .dvc import DvcFileSystem
|
||||
from .path import Path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
|
||||
|
||||
|
||||
def _wrap_walk(dvc_fs, *args, **kwargs):
|
||||
for root, dnames, fnames in dvc_fs.walk(*args, **kwargs):
|
||||
yield dvc_fs.path.join(dvc_fs.repo.root_dir, root), dnames, fnames
|
||||
|
||||
|
||||
def _ls(fs, path):
|
||||
dnames = []
|
||||
fnames = []
|
||||
|
||||
for entry in fs.ls(path, detail=True):
|
||||
name = fs.path.name(entry["name"])
|
||||
if entry["type"] == "directory":
|
||||
dnames.append(name)
|
||||
else:
|
||||
fnames.append(name)
|
||||
|
||||
return dnames, fnames
|
||||
|
||||
|
||||
def _merge_info(repo, fs_info, dvc_info):
|
||||
from dvc.utils import is_exec
|
||||
|
||||
ret = {"repo": repo}
|
||||
|
||||
if dvc_info:
|
||||
ret["dvc_info"] = dvc_info
|
||||
ret["type"] = dvc_info["type"]
|
||||
ret["size"] = dvc_info["size"]
|
||||
if not fs_info and "md5" in dvc_info:
|
||||
ret["md5"] = dvc_info["md5"]
|
||||
|
||||
if fs_info:
|
||||
ret["type"] = fs_info["type"]
|
||||
ret["size"] = fs_info["size"]
|
||||
isexec = False
|
||||
if fs_info["type"] == "file":
|
||||
isexec = is_exec(fs_info["mode"])
|
||||
ret["isexec"] = isexec
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class _RepoFileSystem(AbstractFileSystem): # pylint:disable=abstract-method
|
||||
"""DVC + git-tracked files fs.
|
||||
|
||||
Args:
|
||||
repo: DVC or git repo.
|
||||
subrepos: traverse to subrepos (by default, it ignores subrepos)
|
||||
repo_factory: A function to initialize subrepo with, default is Repo.
|
||||
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
|
||||
"""
|
||||
|
||||
PARAM_REPO_URL = "repo_url"
|
||||
PARAM_REPO_ROOT = "repo_root"
|
||||
PARAM_REV = "rev"
|
||||
PARAM_CACHE_DIR = "cache_dir"
|
||||
PARAM_CACHE_TYPES = "cache_types"
|
||||
PARAM_SUBREPOS = "subrepos"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repo: Optional["Repo"] = None,
|
||||
subrepos=False,
|
||||
repo_factory: RepoFactory = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
from pygtrie import Trie
|
||||
|
||||
if repo is None:
|
||||
repo, repo_factory = self._repo_from_fs_config(
|
||||
subrepos=subrepos, **kwargs
|
||||
)
|
||||
|
||||
if not repo_factory:
|
||||
from dvc.repo import Repo
|
||||
|
||||
self.repo_factory: RepoFactory = Repo
|
||||
else:
|
||||
self.repo_factory = repo_factory
|
||||
|
||||
self.path = Path(self.sep)
|
||||
self.repo = repo
|
||||
self.hash_jobs = repo.fs.hash_jobs
|
||||
self._traverse_subrepos = subrepos
|
||||
|
||||
self._subrepos_trie = Trie()
|
||||
"""Keeps track of each and every path with the corresponding repo."""
|
||||
|
||||
key = self._get_key(self.repo.root_dir)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
self._dvcfss = {}
|
||||
"""Keep a dvcfs instance of each repo."""
|
||||
|
||||
if hasattr(repo, "dvc_dir"):
|
||||
self._dvcfss[key] = DvcFileSystem(repo=repo)
|
||||
|
||||
def _get_key(self, path):
|
||||
parts = self.repo.fs.path.relparts(path, self.repo.root_dir)
|
||||
if parts == (".",):
|
||||
parts = ()
|
||||
return parts
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
if self.repo is None:
|
||||
return None
|
||||
return self.repo.url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return {
|
||||
self.PARAM_REPO_URL: self.repo_url,
|
||||
self.PARAM_REPO_ROOT: self.repo.root_dir,
|
||||
self.PARAM_REV: getattr(self.repo.fs, "rev", None),
|
||||
self.PARAM_CACHE_DIR: os.path.abspath(
|
||||
self.repo.odb.local.cache_dir
|
||||
),
|
||||
self.PARAM_CACHE_TYPES: self.repo.odb.local.cache_types,
|
||||
self.PARAM_SUBREPOS: self._traverse_subrepos,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _repo_from_fs_config(
|
||||
cls, **config
|
||||
) -> Tuple["Repo", Optional["RepoFactory"]]:
|
||||
from dvc.external_repo import erepo_factory, external_repo
|
||||
from dvc.repo import Repo
|
||||
|
||||
url = config.get(cls.PARAM_REPO_URL)
|
||||
root = config.get(cls.PARAM_REPO_ROOT)
|
||||
assert url or root
|
||||
|
||||
def _open(*args, **kwargs):
|
||||
# NOTE: if original repo was an erepo (and has a URL),
|
||||
# we cannot use Repo.open() since it will skip erepo
|
||||
# cache/remote setup for local URLs
|
||||
if url is None:
|
||||
return Repo.open(*args, **kwargs)
|
||||
return external_repo(*args, **kwargs)
|
||||
|
||||
cache_dir = config.get(cls.PARAM_CACHE_DIR)
|
||||
cache_config = (
|
||||
{}
|
||||
if not cache_dir
|
||||
else {
|
||||
"cache": {
|
||||
"dir": cache_dir,
|
||||
"type": config.get(cls.PARAM_CACHE_TYPES),
|
||||
}
|
||||
}
|
||||
)
|
||||
repo_kwargs: dict = {
|
||||
"rev": config.get(cls.PARAM_REV),
|
||||
"subrepos": config.get(cls.PARAM_SUBREPOS, False),
|
||||
"uninitialized": True,
|
||||
}
|
||||
factory: Optional["RepoFactory"] = None
|
||||
if url is None:
|
||||
repo_kwargs["config"] = cache_config
|
||||
else:
|
||||
repo_kwargs["cache_dir"] = cache_dir
|
||||
factory = erepo_factory(url, cache_config)
|
||||
|
||||
with _open(
|
||||
url if url else root,
|
||||
**repo_kwargs,
|
||||
) as repo:
|
||||
return repo, factory
|
||||
|
||||
def _get_repo(self, path: str) -> "Repo":
|
||||
"""Returns repo that the path falls in, using prefix.
|
||||
|
||||
If the path is already tracked/collected, it just returns the repo.
|
||||
|
||||
Otherwise, it collects the repos that might be in the path's parents
|
||||
and then returns the appropriate one.
|
||||
"""
|
||||
if not self.repo.fs.path.isin_or_eq(path, self.repo.root_dir):
|
||||
# outside of repo
|
||||
return self.repo
|
||||
|
||||
key = self._get_key(path)
|
||||
repo = self._subrepos_trie.get(key)
|
||||
if repo:
|
||||
return repo
|
||||
|
||||
prefix_key, repo = self._subrepos_trie.longest_prefix(key)
|
||||
prefix = self.repo.fs.path.join(
|
||||
self.repo.root_dir,
|
||||
*prefix_key, # pylint: disable=not-an-iterable
|
||||
)
|
||||
|
||||
parents = (parent for parent in self.repo.fs.path.parents(path))
|
||||
dirs = [path] + list(takewhile(lambda p: p != prefix, parents))
|
||||
dirs.reverse()
|
||||
self._update(dirs, starting_repo=repo)
|
||||
return self._subrepos_trie.get(key) or self.repo
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
def _update(self, dirs, starting_repo):
|
||||
"""Checks for subrepo in directories and updates them."""
|
||||
repo = starting_repo
|
||||
for d in dirs:
|
||||
key = self._get_key(d)
|
||||
if self._is_dvc_repo(d):
|
||||
repo = self.repo_factory(
|
||||
d,
|
||||
fs=self.repo.fs,
|
||||
repo_factory=self.repo_factory,
|
||||
)
|
||||
self._dvcfss[key] = DvcFileSystem(repo=repo)
|
||||
self._subrepos_trie[key] = repo
|
||||
|
||||
def _is_dvc_repo(self, dir_path):
|
||||
"""Check if the directory is a dvc repo."""
|
||||
if not self._traverse_subrepos:
|
||||
return False
|
||||
|
||||
from dvc.repo import Repo
|
||||
|
||||
repo_path = os.path.join(dir_path, Repo.DVC_DIR)
|
||||
return self.repo.fs.isdir(repo_path)
|
||||
|
||||
def _get_fs_pair(
|
||||
self, path
|
||||
) -> Tuple[
|
||||
Optional[FileSystem],
|
||||
Optional[str],
|
||||
Optional[DvcFileSystem],
|
||||
Optional[str],
|
||||
]:
|
||||
"""
|
||||
Returns a pair of fss based on repo the path falls in, using prefix.
|
||||
"""
|
||||
from dvc.utils import as_posix
|
||||
|
||||
if os.path.isabs(path):
|
||||
if self.repo.fs.path.isin_or_eq(path, self.repo.root_dir):
|
||||
path = self.repo.fs.path.relpath(path, self.repo.root_dir)
|
||||
else:
|
||||
return None, None, self.repo.dvcfs, path
|
||||
|
||||
path = as_posix(path)
|
||||
|
||||
parts = self.path.parts(path)
|
||||
if parts and parts[0] == os.curdir:
|
||||
parts = parts[1:]
|
||||
|
||||
fs_path = self.repo.fs.path.join(self.repo.root_dir, *parts)
|
||||
repo = self._get_repo(fs_path)
|
||||
fs = repo.fs
|
||||
|
||||
repo_parts = fs.path.relparts(repo.root_dir, self.repo.root_dir)
|
||||
if repo_parts[0] == os.curdir:
|
||||
repo_parts = repo_parts[1:]
|
||||
|
||||
dvc_parts = parts[len(repo_parts) :]
|
||||
if dvc_parts and dvc_parts[0] == os.curdir:
|
||||
dvc_parts = dvc_parts[1:]
|
||||
|
||||
key = self._get_key(repo.root_dir)
|
||||
dvc_fs = self._dvcfss.get(key)
|
||||
if dvc_fs:
|
||||
dvc_path = dvc_fs.path.join(*dvc_parts) if dvc_parts else ""
|
||||
else:
|
||||
dvc_path = None
|
||||
|
||||
return fs, fs_path, dvc_fs, dvc_path
|
||||
|
||||
def open(
|
||||
self, path, mode="r", encoding="utf-8", **kwargs
|
||||
): # pylint: disable=arguments-renamed, arguments-differ
|
||||
if "b" in mode:
|
||||
encoding = None
|
||||
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
try:
|
||||
return fs.open(fs_path, mode=mode, encoding=encoding)
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
return dvc_fs.open(dvc_path, mode=mode, encoding=encoding, **kwargs)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
_, _, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
return dvc_fs is not None and dvc_fs.isdvc(dvc_path, **kwargs)
|
||||
|
||||
def ls(self, path, detail=True, **kwargs):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
repo = dvc_fs.repo if dvc_fs else self.repo
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
names = set()
|
||||
if dvc_fs:
|
||||
with suppress(FileNotFoundError):
|
||||
for entry in dvc_fs.ls(dvc_path, detail=False):
|
||||
names.add(dvc_fs.path.name(entry))
|
||||
|
||||
if fs:
|
||||
try:
|
||||
for entry in dvcignore.ls(
|
||||
fs, fs_path, detail=False, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
names.add(fs.path.name(entry))
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
pass
|
||||
|
||||
dvcfiles = kwargs.get("dvcfiles", False)
|
||||
|
||||
def _func(fname):
|
||||
from dvc.dvcfile import is_valid_filename
|
||||
from dvc.ignore import DvcIgnore
|
||||
|
||||
if dvcfiles:
|
||||
return True
|
||||
|
||||
return not (
|
||||
is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
|
||||
)
|
||||
|
||||
names = filter(_func, names)
|
||||
|
||||
infos = []
|
||||
paths = []
|
||||
for name in names:
|
||||
entry_path = self.path.join(path, name)
|
||||
try:
|
||||
info = self.info(entry_path, ignore_subrepos=ignore_subrepos)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
infos.append(info)
|
||||
paths.append(entry_path)
|
||||
|
||||
if not detail:
|
||||
return paths
|
||||
|
||||
return infos
|
||||
|
||||
def get_file(self, rpath, lpath, callback=DEFAULT_CALLBACK, **kwargs):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(rpath)
|
||||
|
||||
if fs:
|
||||
try:
|
||||
fs.get_file( # pylint: disable=protected-access
|
||||
fs_path, lpath, callback=callback, **kwargs
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
if not dvc_fs:
|
||||
raise
|
||||
|
||||
dvc_fs.get_file( # pylint: disable=protected-access
|
||||
dvc_path, lpath, callback=callback, **kwargs
|
||||
)
|
||||
|
||||
def info(self, path, **kwargs):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
repo = dvc_fs.repo if dvc_fs else self.repo
|
||||
dvcignore = repo.dvcignore
|
||||
ignore_subrepos = kwargs.get("ignore_subrepos", True)
|
||||
|
||||
dvc_info = None
|
||||
if dvc_fs:
|
||||
try:
|
||||
dvc_info = dvc_fs.info(dvc_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
fs_info = None
|
||||
if fs:
|
||||
try:
|
||||
fs_info = fs.info(fs_path)
|
||||
if dvcignore.is_ignored(
|
||||
fs, fs_path, ignore_subrepos=ignore_subrepos
|
||||
):
|
||||
fs_info = None
|
||||
except (FileNotFoundError, NotADirectoryError):
|
||||
if not dvc_info:
|
||||
raise
|
||||
|
||||
# NOTE: if some parent in fs_path turns out to be a file, it means
|
||||
# that the whole repofs branch doesn't exist.
|
||||
if fs and not fs_info and dvc_info:
|
||||
for parent in fs.path.parents(fs_path):
|
||||
try:
|
||||
if fs.info(parent)["type"] != "directory":
|
||||
dvc_info = None
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
if not dvc_info and not fs_info:
|
||||
raise FileNotFoundError
|
||||
|
||||
info = _merge_info(dvc_fs.repo, fs_info, dvc_info)
|
||||
info["name"] = path
|
||||
return info
|
||||
|
||||
def checksum(self, path):
|
||||
fs, fs_path, dvc_fs, dvc_path = self._get_fs_pair(path)
|
||||
|
||||
try:
|
||||
return fs.checksum(fs_path)
|
||||
except FileNotFoundError:
|
||||
return dvc_fs.checksum(dvc_path)
|
||||
|
||||
|
||||
class RepoFileSystem(FileSystem):
|
||||
scheme = "local"
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
return _RepoFileSystem(**self.fs_args)
|
||||
|
||||
def isdvc(self, path, **kwargs):
|
||||
return self.fs.isdvc(path, **kwargs)
|
||||
|
||||
@property
|
||||
def repo(self):
|
||||
return self.fs.repo
|
||||
|
||||
@property
|
||||
def repo_url(self):
|
||||
return self.fs.repo_url
|
||||
|
||||
@property
|
||||
def config(self):
|
||||
return self.fs.config
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import ObjectFileSystem
|
||||
|
||||
_AWS_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".aws", "config")
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class S3FileSystem(ObjectFileSystem):
|
||||
scheme = Schemes.S3
|
||||
REQUIRES = {"s3fs": "s3fs", "boto3": "boto3"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
_GRANTS = {
|
||||
"grant_full_control": "GrantFullControl",
|
||||
"grant_read": "GrantRead",
|
||||
"grant_read_acp": "GrantReadACP",
|
||||
"grant_write_acp": "GrantWriteACP",
|
||||
}
|
||||
|
||||
_TRANSFER_CONFIG_ALIASES = {
|
||||
"max_queue_size": "max_io_queue",
|
||||
"max_concurrent_requests": "max_concurrency",
|
||||
"multipart_threshold": "multipart_threshold",
|
||||
"multipart_chunksize": "multipart_chunksize",
|
||||
}
|
||||
|
||||
def _split_s3_config(self, s3_config):
|
||||
"""Splits the general s3 config into 2 different config
|
||||
objects, one for transfer.TransferConfig and other is the
|
||||
general session config"""
|
||||
|
||||
from boto3.s3.transfer import TransferConfig
|
||||
|
||||
from dvc.utils import conversions
|
||||
|
||||
config, transfer_config = {}, {}
|
||||
for key, value in s3_config.items():
|
||||
if key in self._TRANSFER_CONFIG_ALIASES:
|
||||
if key in {"multipart_chunksize", "multipart_threshold"}:
|
||||
# cast human readable sizes (like 24MiB) to integers
|
||||
value = conversions.human_readable_to_bytes(value)
|
||||
else:
|
||||
value = int(value)
|
||||
transfer_config[self._TRANSFER_CONFIG_ALIASES[key]] = value
|
||||
else:
|
||||
config[key] = value
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._transfer_config = TransferConfig(**transfer_config)
|
||||
return config
|
||||
|
||||
def _load_aws_config_file(self, profile):
|
||||
from botocore.configloader import load_config
|
||||
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self._transfer_config = None
|
||||
config_path = os.environ.get("AWS_CONFIG_FILE", _AWS_CONFIG_PATH)
|
||||
if not os.path.exists(config_path):
|
||||
return {}
|
||||
|
||||
config = load_config(config_path)
|
||||
profile_config = config["profiles"].get(profile or "default")
|
||||
if not profile_config:
|
||||
return {}
|
||||
|
||||
s3_config = profile_config.get("s3", {})
|
||||
return self._split_s3_config(s3_config)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
from dvc.config import ConfigError
|
||||
from dvc.utils.flatten import flatten, unflatten
|
||||
|
||||
login_info = defaultdict(dict)
|
||||
|
||||
# credentials
|
||||
login_info["key"] = config.get("access_key_id")
|
||||
login_info["secret"] = config.get("secret_access_key")
|
||||
login_info["token"] = config.get("session_token")
|
||||
|
||||
# session configuration
|
||||
login_info["profile"] = config.get("profile")
|
||||
login_info["use_ssl"] = config.get("use_ssl", True)
|
||||
|
||||
# extra client configuration
|
||||
client = login_info["client_kwargs"]
|
||||
client["region_name"] = config.get("region")
|
||||
client["endpoint_url"] = config.get("endpointurl")
|
||||
client["verify"] = config.get("ssl_verify")
|
||||
|
||||
# timeout configuration
|
||||
config_kwargs = login_info["config_kwargs"]
|
||||
config_kwargs["read_timeout"] = config.get("read_timeout")
|
||||
config_kwargs["connect_timeout"] = config.get("connect_timeout")
|
||||
|
||||
# encryptions
|
||||
additional = login_info["s3_additional_kwargs"]
|
||||
additional["ServerSideEncryption"] = config.get("sse")
|
||||
additional["SSEKMSKeyId"] = config.get("sse_kms_key_id")
|
||||
additional["ACL"] = config.get("acl")
|
||||
for grant_option, grant_key in self._GRANTS.items():
|
||||
if config.get(grant_option):
|
||||
if additional["ACL"]:
|
||||
raise ConfigError(
|
||||
"`acl` and `grant_*` AWS S3 config options "
|
||||
"are mutually exclusive"
|
||||
)
|
||||
additional[grant_key] = config[grant_option]
|
||||
|
||||
# config kwargs
|
||||
session_config = login_info["config_kwargs"]
|
||||
session_config["s3"] = self._load_aws_config_file(
|
||||
login_info["profile"]
|
||||
)
|
||||
|
||||
shared_creds = config.get("credentialpath")
|
||||
if shared_creds:
|
||||
os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", shared_creds)
|
||||
|
||||
if (
|
||||
client["region_name"] is None
|
||||
and session_config["s3"].get("region_name") is None
|
||||
and os.getenv("AWS_REGION") is None
|
||||
):
|
||||
# Enable bucket region caching
|
||||
login_info["cache_regions"] = config.get("cache_regions", True)
|
||||
|
||||
config_path = config.get("configpath")
|
||||
if config_path:
|
||||
os.environ.setdefault("AWS_CONFIG_FILE", config_path)
|
||||
|
||||
return unflatten(
|
||||
{
|
||||
key: value
|
||||
for key, value in flatten(login_info).items()
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from s3fs import S3FileSystem as _S3FileSystem
|
||||
|
||||
return _S3FileSystem(**self.fs_args)
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path):
|
||||
return "s3://" + path.lstrip("/")
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
import getpass
|
||||
import os.path
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, memoize, silent, wrap_prop, wrap_with
|
||||
|
||||
from dvc import prompt
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.utils.fs import as_atomic
|
||||
|
||||
from ._callback import DEFAULT_CALLBACK
|
||||
from .base import FileSystem
|
||||
|
||||
DEFAULT_PORT = 22
|
||||
|
||||
|
||||
@wrap_with(threading.Lock())
|
||||
@memoize
|
||||
def ask_password(host, user, port):
|
||||
return prompt.password(
|
||||
"Enter a private key passphrase or a password for "
|
||||
"host '{host}' port '{port}' user '{user}'".format(
|
||||
host=host, port=port, user=user
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
class SSHFileSystem(FileSystem):
|
||||
scheme = Schemes.SSH
|
||||
REQUIRES = {"sshfs": "sshfs"}
|
||||
PARAM_CHECKSUM = "md5"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args["port"]
|
||||
path = path.lstrip("/")
|
||||
return f"ssh://{host}:{port}/{path}"
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self.CAN_TRAVERSE = True
|
||||
from sshfs.config import parse_config
|
||||
|
||||
login_info = {}
|
||||
|
||||
try:
|
||||
user_ssh_config = parse_config(host=config["host"])
|
||||
except FileNotFoundError:
|
||||
user_ssh_config = {}
|
||||
|
||||
login_info["host"] = user_ssh_config.get("Hostname", config["host"])
|
||||
|
||||
login_info["username"] = (
|
||||
config.get("user")
|
||||
or config.get("username")
|
||||
or user_ssh_config.get("User")
|
||||
or getpass.getuser()
|
||||
)
|
||||
login_info["port"] = (
|
||||
config.get("port")
|
||||
or silent(int)(user_ssh_config.get("Port"))
|
||||
or DEFAULT_PORT
|
||||
)
|
||||
|
||||
if config.get("ask_password") and config.get("password") is None:
|
||||
config["password"] = ask_password(
|
||||
login_info["host"], login_info["username"], login_info["port"]
|
||||
)
|
||||
|
||||
login_info["password"] = config.get("password")
|
||||
login_info["passphrase"] = config.get("password")
|
||||
|
||||
raw_keys = []
|
||||
if config.get("keyfile"):
|
||||
raw_keys.append(config.get("keyfile"))
|
||||
elif user_ssh_config.get("IdentityFile"):
|
||||
raw_keys.extend(user_ssh_config.get("IdentityFile"))
|
||||
|
||||
if raw_keys:
|
||||
login_info["client_keys"] = [
|
||||
os.path.expanduser(key) for key in raw_keys
|
||||
]
|
||||
|
||||
login_info["timeout"] = config.get("timeout", 1800)
|
||||
|
||||
# These two settings fine tune the asyncssh to use the
|
||||
# fastest encryption algorithm and disable compression
|
||||
# altogether (since it blocking, it is slowing down
|
||||
# the transfers in a considerable rate, and even for
|
||||
# compressible data it is making it extremely slow).
|
||||
# See: https://github.com/ronf/asyncssh/issues/374
|
||||
login_info["encryption_algs"] = [
|
||||
"[email protected]",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
]
|
||||
login_info["compression_algs"] = None
|
||||
|
||||
login_info["gss_auth"] = config.get("gss_auth", False)
|
||||
login_info["agent_forwarding"] = config.get("agent_forwarding", True)
|
||||
login_info["proxy_command"] = user_ssh_config.get("ProxyCommand")
|
||||
|
||||
# We are going to automatically add stuff to known_hosts
|
||||
# something like paramiko's AutoAddPolicy()
|
||||
login_info["known_hosts"] = None
|
||||
return login_info
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from sshfs import SSHFileSystem as _SSHFileSystem
|
||||
|
||||
return _SSHFileSystem(**self.fs_args)
|
||||
|
||||
# Ensure that if an interrupt happens during the transfer, we don't
|
||||
# pollute the cache.
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super().upload_fobj(fobj, tmp_file, **kwargs)
|
||||
|
||||
def put_file(
|
||||
self,
|
||||
from_file,
|
||||
to_info,
|
||||
callback=DEFAULT_CALLBACK,
|
||||
size=None,
|
||||
**kwargs,
|
||||
):
|
||||
with as_atomic(self, to_info) as tmp_file:
|
||||
super().put_file(
|
||||
from_file, tmp_file, callback=callback, size=size, **kwargs
|
||||
)
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from .base import RemoteActionNotImplemented
|
||||
from .local import LocalFileSystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import FileSystem
|
||||
from .types import AnyPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _link(
|
||||
link: "AnyPath",
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
if not isinstance(from_fs, type(to_fs)):
|
||||
raise OSError(errno.EXDEV, "can't link across filesystems")
|
||||
|
||||
try:
|
||||
func = getattr(to_fs, link)
|
||||
func(from_path, to_path)
|
||||
except (OSError, AttributeError, RemoteActionNotImplemented) as exc:
|
||||
# NOTE: there are so many potential error codes that a link call can
|
||||
# throw, that is safer to just catch them all and try another link.
|
||||
raise OSError(
|
||||
errno.ENOTSUP, f"'{link}' is not supported by {type(from_fs)}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _copy(
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
if isinstance(from_fs, LocalFileSystem):
|
||||
return to_fs.upload(from_path, to_path)
|
||||
|
||||
if isinstance(to_fs, LocalFileSystem):
|
||||
return from_fs.download_file(from_path, to_path)
|
||||
|
||||
with from_fs.open(from_path, mode="rb") as fobj:
|
||||
size = from_fs.size(from_path)
|
||||
return to_fs.upload(fobj, to_path, size=size)
|
||||
|
||||
|
||||
def _try_links(
|
||||
links: List["AnyPath"],
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> None:
|
||||
error = None
|
||||
while links:
|
||||
link = links[0]
|
||||
|
||||
if link == "copy":
|
||||
return _copy(from_fs, from_path, to_fs, to_path)
|
||||
|
||||
try:
|
||||
return _link(link, from_fs, from_path, to_fs, to_path)
|
||||
except OSError as exc:
|
||||
if exc.errno not in [errno.ENOTSUP, errno.EXDEV]:
|
||||
raise
|
||||
error = exc
|
||||
|
||||
del links[0]
|
||||
|
||||
raise OSError(
|
||||
errno.ENOTSUP, "no more link types left to try out"
|
||||
) from error
|
||||
|
||||
|
||||
def transfer(
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
hardlink: bool = False,
|
||||
links: Optional[List["AnyPath"]] = None,
|
||||
) -> None:
|
||||
try:
|
||||
assert not (hardlink and links)
|
||||
if hardlink:
|
||||
links = links or ["reflink", "hardlink", "copy"]
|
||||
else:
|
||||
links = links or ["reflink", "copy"]
|
||||
_try_links(links, from_fs, from_path, to_fs, to_path)
|
||||
except OSError as exc:
|
||||
# If the target file already exists, we are going to simply
|
||||
# ignore the exception (#4992).
|
||||
#
|
||||
# On Windows, it is not always guaranteed that you'll get
|
||||
# FileExistsError (it might be PermissionError or a bare OSError)
|
||||
# but all of those exceptions raised from the original
|
||||
# FileExistsError so we have a separate check for that.
|
||||
if isinstance(exc, FileExistsError) or (
|
||||
os.name == "nt"
|
||||
and exc.__context__
|
||||
and isinstance(exc.__context__, FileExistsError)
|
||||
):
|
||||
logger.debug("'%s' file already exists, skipping", to_path)
|
||||
return None
|
||||
|
||||
raise
|
||||
|
||||
|
||||
def _test_link(
|
||||
link: "AnyPath",
|
||||
from_fs: "FileSystem",
|
||||
from_file: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_file: "AnyPath",
|
||||
) -> bool:
|
||||
try:
|
||||
_try_links([link], from_fs, from_file, to_fs, to_file)
|
||||
except OSError:
|
||||
logger.debug("", exc_info=True)
|
||||
return False
|
||||
|
||||
try:
|
||||
_is_link_func = getattr(to_fs, f"is_{link}")
|
||||
return _is_link_func(to_file)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_links(
|
||||
links: List["AnyPath"],
|
||||
from_fs: "FileSystem",
|
||||
from_path: "AnyPath",
|
||||
to_fs: "FileSystem",
|
||||
to_path: "AnyPath",
|
||||
) -> List["AnyPath"]:
|
||||
from dvc.utils import tmp_fname
|
||||
|
||||
from_file = from_fs.path.join(from_path, tmp_fname())
|
||||
to_file = to_fs.path.join(
|
||||
to_fs.path.parent(to_path),
|
||||
tmp_fname(),
|
||||
)
|
||||
|
||||
from_fs.makedirs(from_fs.path.parent(from_file))
|
||||
with from_fs.open(from_file, "wb") as fobj:
|
||||
fobj.write(b"test")
|
||||
to_fs.makedirs(to_fs.path.parent(to_file))
|
||||
|
||||
ret = []
|
||||
try:
|
||||
for link in links:
|
||||
try:
|
||||
if _test_link(link, from_fs, from_file, to_fs, to_file):
|
||||
ret.append(link)
|
||||
finally:
|
||||
to_fs.remove(to_file)
|
||||
finally:
|
||||
from_fs.remove(from_file)
|
||||
|
||||
return ret
|
||||
@@ -1,92 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
from .base import FileSystem
|
||||
from .http import ask_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAVFileSystem(FileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAV
|
||||
root_marker = ""
|
||||
REQUIRES = {"webdav4": "webdav4"}
|
||||
PARAM_CHECKSUM = "etag"
|
||||
|
||||
def __init__(self, **config):
|
||||
super().__init__(**config)
|
||||
|
||||
cert_path = config.get("cert_path", None)
|
||||
key_path = config.get("key_path", None)
|
||||
self.prefix = config.get("prefix", "")
|
||||
self.fs_args.update(
|
||||
{
|
||||
"base_url": config["url"],
|
||||
"cert": cert_path if not key_path else (cert_path, key_path),
|
||||
"verify": config.get("ssl_verify", True),
|
||||
"timeout": config.get("timeout", 30),
|
||||
}
|
||||
)
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
return self.fs_args["base_url"] + "/" + path
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(urlpath)
|
||||
scheme = parsed.scheme.replace("webdav", "http")
|
||||
return {
|
||||
"prefix": parsed.path.rstrip("/"),
|
||||
"host": urlunparse((scheme, parsed.netloc, "", None, None, None)),
|
||||
"url": urlunparse(
|
||||
(
|
||||
scheme,
|
||||
parsed.netloc,
|
||||
parsed.path.rstrip("/"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
),
|
||||
"user": parsed.username,
|
||||
}
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
user = config.get("user", None)
|
||||
password = config.get("password", None)
|
||||
|
||||
headers = {}
|
||||
token = config.get("token")
|
||||
auth = None
|
||||
if token:
|
||||
headers.update({"Authorization": f"Bearer {token}"})
|
||||
elif user:
|
||||
if not password and config.get("ask_password"):
|
||||
password = ask_password(config["host"], user)
|
||||
auth = (user, password)
|
||||
|
||||
return {"headers": headers, "auth": auth}
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from webdav4.fsspec import WebdavFileSystem
|
||||
|
||||
return WebdavFileSystem(**self.fs_args)
|
||||
|
||||
def upload_fobj(self, fobj, to_info, **kwargs):
|
||||
size = kwargs.get("size")
|
||||
# using upload_fileobj to directly upload fileobj rather than buffering
|
||||
# and using overwrite=True to avoid check for an extra exists call,
|
||||
# as caller should ensure that the file does not exist beforehand.
|
||||
return self.fs.upload_fileobj(fobj, to_info, overwrite=True, size=size)
|
||||
|
||||
|
||||
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
|
||||
scheme = Schemes.WEBDAVS
|
||||
@@ -1,56 +0,0 @@
|
||||
import threading
|
||||
|
||||
from funcy import cached_property, wrap_prop
|
||||
|
||||
from dvc.scheme import Schemes
|
||||
|
||||
# pylint:disable=abstract-method
|
||||
from .base import FileSystem
|
||||
|
||||
|
||||
class WebHDFSFileSystem(FileSystem):
|
||||
scheme = Schemes.WEBHDFS
|
||||
REQUIRES = {"fsspec": "fsspec"}
|
||||
PARAM_CHECKSUM = "checksum"
|
||||
|
||||
@classmethod
|
||||
def _strip_protocol(cls, path: str) -> str:
|
||||
from fsspec.utils import infer_storage_options
|
||||
|
||||
return infer_storage_options(path)["path"]
|
||||
|
||||
def unstrip_protocol(self, path: str) -> str:
|
||||
host = self.fs_args["host"]
|
||||
port = self.fs_args["port"]
|
||||
path = path.lstrip("/")
|
||||
return f"webhdfs://{host}:{port}/{path}"
|
||||
|
||||
@staticmethod
|
||||
def _get_kwargs_from_urls(urlpath):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
return (
|
||||
WebHDFS._get_kwargs_from_urls( # pylint:disable=protected-access
|
||||
urlpath
|
||||
)
|
||||
)
|
||||
|
||||
def _prepare_credentials(self, **config):
|
||||
self._ssl_verify = config.pop("ssl_verify", True)
|
||||
principal = config.pop("kerberos_principal", None)
|
||||
if principal:
|
||||
config["kerb_kwargs"] = {"principal": principal}
|
||||
return config
|
||||
|
||||
@wrap_prop(threading.Lock())
|
||||
@cached_property
|
||||
def fs(self):
|
||||
from fsspec.implementations.webhdfs import WebHDFS
|
||||
|
||||
fs = WebHDFS(**self.fs_args)
|
||||
fs.session.verify = self._ssl_verify
|
||||
return fs
|
||||
|
||||
def checksum(self, path):
|
||||
ukey = self.fs.ukey(path)
|
||||
return ukey["bytes"]
|
||||
@@ -1,43 +0,0 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
HASH_DIR_SUFFIX = ".dir"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HashInfo:
|
||||
name: Optional[str]
|
||||
value: Optional[str]
|
||||
obj_name: Optional[str] = field(default=None, compare=False)
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}: {self.value}"
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.value))
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
if not d:
|
||||
return cls(None, None)
|
||||
|
||||
((name, value),) = d.items()
|
||||
return cls(name, value)
|
||||
|
||||
def to_dict(self):
|
||||
ret = OrderedDict()
|
||||
if not self:
|
||||
return ret
|
||||
|
||||
ret[self.name] = self.value
|
||||
return ret
|
||||
|
||||
@property
|
||||
def isdir(self):
|
||||
if not self:
|
||||
return False
|
||||
return self.value.endswith(HASH_DIR_SUFFIX)
|
||||
+11
-9
@@ -8,10 +8,8 @@ from pathspec.patterns import GitWildMatchPattern
|
||||
from pathspec.util import normalize_file
|
||||
from pygtrie import Trie
|
||||
|
||||
from dvc.fs.base import AnyFSPath, FileSystem
|
||||
from dvc.fs.local import localfs
|
||||
from dvc.fs import AnyFSPath, FileSystem, Schemes, localfs
|
||||
from dvc.pathspec_math import PatternInfo, merge_patterns
|
||||
from dvc.scheme import Schemes
|
||||
from dvc.types import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,7 +33,6 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
self.sep = sep
|
||||
self.pattern_list = pattern_list
|
||||
self.dirname = dirname
|
||||
self.prefix = self.dirname + self.sep
|
||||
|
||||
self.regex_pattern_list = [
|
||||
GitWildMatchPattern.pattern_to_regex(pattern_info.patterns)
|
||||
@@ -74,10 +71,13 @@ class DvcIgnorePatterns(DvcIgnore):
|
||||
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.
|
||||
|
||||
prefix = self.dirname.rstrip(self.sep) + self.sep
|
||||
|
||||
if dirname == self.dirname:
|
||||
path = basename
|
||||
elif dirname.startswith(self.prefix):
|
||||
rel = dirname[len(self.prefix) :]
|
||||
elif dirname.startswith(prefix):
|
||||
rel = dirname[len(prefix) :]
|
||||
# NOTE: `os.path.join` is ~x5.5 slower
|
||||
path = f"{rel}{self.sep}{basename}"
|
||||
else:
|
||||
@@ -210,6 +210,7 @@ class DvcIgnoreFilter:
|
||||
new_pattern = DvcIgnorePatterns.from_file(path, self.fs, name)
|
||||
if old_pattern:
|
||||
plist, prefix = merge_patterns(
|
||||
self.fs.path.flavour,
|
||||
old_pattern.pattern_list,
|
||||
old_pattern.dirname,
|
||||
new_pattern.pattern_list,
|
||||
@@ -259,6 +260,7 @@ class DvcIgnoreFilter:
|
||||
old_pattern = ignore_trie.longest_prefix(key).value
|
||||
if old_pattern:
|
||||
plist, prefix = merge_patterns(
|
||||
self.fs.path.flavour,
|
||||
old_pattern.pattern_list,
|
||||
old_pattern.dirname,
|
||||
new_pattern.pattern_list,
|
||||
@@ -299,7 +301,7 @@ class DvcIgnoreFilter:
|
||||
|
||||
def walk(self, fs: FileSystem, path: AnyFSPath, **kwargs):
|
||||
ignore_subrepos = kwargs.pop("ignore_subrepos", True)
|
||||
if fs.scheme == Schemes.LOCAL:
|
||||
if fs.protocol == Schemes.LOCAL:
|
||||
for root, dirs, files in fs.walk(path, **kwargs):
|
||||
dirs[:], files[:] = self(
|
||||
root, dirs, files, ignore_subrepos=ignore_subrepos
|
||||
@@ -309,7 +311,7 @@ class DvcIgnoreFilter:
|
||||
yield from fs.walk(path, **kwargs)
|
||||
|
||||
def find(self, fs: FileSystem, path: AnyFSPath, **kwargs):
|
||||
if fs.scheme == Schemes.LOCAL:
|
||||
if fs.protocol == Schemes.LOCAL:
|
||||
for root, _, files in self.walk(fs, path, **kwargs):
|
||||
for file in files:
|
||||
# NOTE: os.path.join is ~5.5 times slower
|
||||
@@ -402,7 +404,7 @@ class DvcIgnoreFilter:
|
||||
) -> bool:
|
||||
# NOTE: can't use self.check_ignore(path).match for now, see
|
||||
# https://github.com/iterative/dvc/issues/4555
|
||||
if fs.scheme != Schemes.LOCAL:
|
||||
if fs.protocol != Schemes.LOCAL:
|
||||
return False
|
||||
if fs.isfile(path):
|
||||
return self.is_ignored_file(path, ignore_subrepos)
|
||||
|
||||
+17
-15
@@ -1,3 +1,4 @@
|
||||
import importlib.metadata as importlib_metadata
|
||||
import itertools
|
||||
import os
|
||||
import pathlib
|
||||
@@ -7,19 +8,12 @@ import psutil
|
||||
|
||||
from dvc import __version__
|
||||
from dvc.exceptions import NotDvcRepoError
|
||||
from dvc.fs import FS_MAP, get_fs_cls, get_fs_config
|
||||
from dvc.fs.utils import test_links
|
||||
from dvc.fs import Schemes, generic, get_fs_cls, get_fs_config, registry
|
||||
from dvc.repo import Repo
|
||||
from dvc.scm import SCMError
|
||||
from dvc.utils import error_link
|
||||
from dvc.utils.pkg import PKG
|
||||
|
||||
try:
|
||||
import importlib.metadata as importlib_metadata
|
||||
except ImportError: # < 3.8
|
||||
import importlib_metadata # type: ignore[no-redef]
|
||||
|
||||
|
||||
package = "" if PKG is None else f"({PKG})"
|
||||
|
||||
|
||||
@@ -38,9 +32,9 @@ def get_dvc_info():
|
||||
# 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.odb.local.cache_dir):
|
||||
if os.path.exists(repo.odb.local.path):
|
||||
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
|
||||
fs_type = get_fs_type(repo.odb.local.cache_dir)
|
||||
fs_type = get_fs_type(repo.odb.local.path)
|
||||
info.append(f"Cache directory: {fs_type}")
|
||||
else:
|
||||
info.append("Cache types: " + error_link("no-dvc-cache"))
|
||||
@@ -64,7 +58,7 @@ def _get_caches(cache):
|
||||
caches = (
|
||||
cache_type
|
||||
for cache_type, cache_instance in cache.by_scheme()
|
||||
if cache_instance
|
||||
if cache_instance and cache_type != "repo"
|
||||
)
|
||||
|
||||
# Caches will be always non-empty including the local cache
|
||||
@@ -73,7 +67,7 @@ def _get_caches(cache):
|
||||
|
||||
def _get_remotes(config):
|
||||
schemes = (
|
||||
get_fs_cls(get_fs_config(None, config, name=remote)).scheme
|
||||
get_fs_cls(get_fs_config(None, config, name=remote)).protocol
|
||||
for remote in config["remote"]
|
||||
)
|
||||
|
||||
@@ -83,10 +77,10 @@ def _get_remotes(config):
|
||||
def _get_linktype_support_info(repo):
|
||||
odb = repo.odb.local
|
||||
|
||||
links = test_links(
|
||||
links = generic.test_links(
|
||||
["reflink", "hardlink", "symlink"],
|
||||
odb.fs,
|
||||
odb.fs_path,
|
||||
odb.path,
|
||||
repo.fs,
|
||||
repo.root_dir,
|
||||
)
|
||||
@@ -96,7 +90,15 @@ def _get_linktype_support_info(repo):
|
||||
|
||||
def _get_supported_remotes():
|
||||
supported_remotes = []
|
||||
for scheme, fs_cls in FS_MAP.items():
|
||||
for scheme in registry:
|
||||
if scheme in [Schemes.LOCAL, Schemes.MEMORY, "dvc", "git"]:
|
||||
continue
|
||||
|
||||
try:
|
||||
fs_cls = registry[scheme]
|
||||
except ImportError:
|
||||
continue
|
||||
|
||||
if not fs_cls.get_missing_deps():
|
||||
dependencies = []
|
||||
for requirement in fs_cls.REQUIRES:
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Use heuristics to guess if it is a text file or a binary file."""
|
||||
|
||||
# Based on https://eli.thegreenplace.net/2011/10/19/
|
||||
# perls-guess-if-file-is-text-or-binary-implemented-in-python
|
||||
|
||||
|
||||
TEXT_CHARS = bytes(range(32, 127)) + b"\n\r\t\f\b"
|
||||
DEFAULT_CHUNK_SIZE = 512
|
||||
|
||||
|
||||
def istextblock(block):
|
||||
if not block:
|
||||
# An empty file is considered a valid text file
|
||||
return True
|
||||
|
||||
if b"\x00" in block:
|
||||
# Files with null bytes are binary
|
||||
return False
|
||||
|
||||
# Use translate's 'deletechars' argument to efficiently remove all
|
||||
# occurrences of TEXT_CHARS from the block
|
||||
nontext = block.translate(None, TEXT_CHARS)
|
||||
return float(len(nontext)) / len(block) <= 0.30
|
||||
|
||||
|
||||
def istextfile(fname, fs, blocksize=DEFAULT_CHUNK_SIZE):
|
||||
"""Uses heuristics to guess whether the given file is text or binary,
|
||||
by reading a single block of bytes from the file.
|
||||
If more than 30% of the chars in the block are non-text, or there
|
||||
are NUL ('\x00') bytes in the block, assume this is a binary file.
|
||||
"""
|
||||
with fs.open(fname, "rb") as fobj:
|
||||
block = fobj.read(blocksize)
|
||||
return istextblock(block)
|
||||
+11
@@ -70,6 +70,8 @@ class LockNoop(LockBase):
|
||||
self._lock = True
|
||||
|
||||
def unlock(self):
|
||||
if not self.is_locked:
|
||||
raise DvcException("Unlock called on an unlocked lock")
|
||||
self._lock = False
|
||||
|
||||
@property
|
||||
@@ -93,6 +95,7 @@ class Lock(LockBase):
|
||||
super().__init__(lockfile)
|
||||
self._friendly = friendly
|
||||
self._lock = None
|
||||
self._lock_failed = False
|
||||
|
||||
@property
|
||||
def files(self):
|
||||
@@ -100,6 +103,7 @@ class Lock(LockBase):
|
||||
|
||||
def _do_lock(self):
|
||||
try:
|
||||
self._lock_failed = False
|
||||
with Tqdm(
|
||||
bar_format="{desc}",
|
||||
disable=not self._friendly,
|
||||
@@ -111,6 +115,7 @@ class Lock(LockBase):
|
||||
):
|
||||
self._lock = zc.lockfile.LockFile(self._lockfile)
|
||||
except zc.lockfile.LockError:
|
||||
self._lock_failed = True
|
||||
raise LockError(FAILED_TO_LOCK_MESSAGE)
|
||||
|
||||
def lock(self):
|
||||
@@ -120,6 +125,12 @@ class Lock(LockBase):
|
||||
lock_retry()
|
||||
|
||||
def unlock(self):
|
||||
if self._lock_failed:
|
||||
assert self._lock is None
|
||||
return
|
||||
|
||||
if not self.is_locked:
|
||||
raise DvcException("Unlock called on an unlocked lock")
|
||||
self._lock.close()
|
||||
self._lock = None
|
||||
|
||||
|
||||
+26
-3
@@ -163,7 +163,7 @@ class LoggerHandler(logging.StreamHandler):
|
||||
msg, file=self.stream, end=getattr(self, "terminator", "\n")
|
||||
)
|
||||
self.flush()
|
||||
except RecursionError:
|
||||
except (BrokenPipeError, RecursionError):
|
||||
raise
|
||||
except Exception: # noqa, pylint: disable=broad-except
|
||||
self.handleError(record)
|
||||
@@ -207,7 +207,12 @@ def disable_other_loggers():
|
||||
logger.disabled = True
|
||||
|
||||
|
||||
def setup(level=logging.INFO):
|
||||
def set_loggers_level(level: int = logging.INFO) -> None:
|
||||
for name in ["dvc", "dvc_objects", "dvc_data"]:
|
||||
logging.getLogger(name).setLevel(level)
|
||||
|
||||
|
||||
def setup(level: int = logging.INFO) -> None:
|
||||
colorama.init()
|
||||
|
||||
if level >= logging.DEBUG:
|
||||
@@ -269,7 +274,25 @@ def setup(level=logging.INFO):
|
||||
"console_trace",
|
||||
"console_errors",
|
||||
],
|
||||
}
|
||||
},
|
||||
"dvc_objects": {
|
||||
"level": level,
|
||||
"handlers": [
|
||||
"console_info",
|
||||
"console_debug",
|
||||
"console_trace",
|
||||
"console_errors",
|
||||
],
|
||||
},
|
||||
"dvc_data": {
|
||||
"level": level,
|
||||
"handlers": [
|
||||
"console_info",
|
||||
"console_debug",
|
||||
"console_trace",
|
||||
"console_errors",
|
||||
],
|
||||
},
|
||||
},
|
||||
"disable_existing_loggers": False,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.fs.ssh import SSHFileSystem
|
||||
from dvc_ssh import SSHFileSystem
|
||||
|
||||
from dvc.types import StrPath
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user