Compare commits

...
89 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 13d1276a12 dvc: bump to 2.6.4 2021-08-20 20:00:55 +03:00
Ruslan Kuprieiev 7da20a7c74 setup: upgrade s3fs
Fixes https://github.com/iterative/dvc/issues/6466
2021-08-20 19:43:14 +03:00
Paweł Redzyński a7175501aa ruamel.yaml: adjust to new DuplicatedKeyError (#6464) 2021-08-19 20:02:16 +03:00
GaoandPeter Rowlands 57899ee86d Remove all of the experiments in the workspace (#6394)
* Remove all of the experiments in the workspace

fix #5676
1. remove all the experiments in workspace
2. add tests for it

* Only remove baseline ones

* Add a new arguments --all

* Update dvc/repo/experiments/remove.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Rename and shortcut for these commands

* Solve the issue from removing a changing list.

* Update dvc/repo/experiments/remove.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Update dvc/repo/experiments/remove.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* delete workspace option

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-08-19 10:18:51 +09:00
Saugat Pachhai 4b9a4114fe exp run: pass recursive args to checkout (#6458) 2021-08-18 15:17:33 +05:45
Ruslan Kuprieiev 82e559a51a dvc: bump to 2.6.3 2021-08-17 15:17:48 +03:00
Ruslan Kuprieiev 33c6a9b19b analytics: introduce DVC_NO_ANALYTICS (#6453)
Replacing DVC_ANALYTICS. This is following our other env conventions and
will not have effect if set to false/no/etc, and will only work if set
to true/yes/y/etc. Will disable analytics even if it is explicitly
enabled in our configs.

Kudos @skshetry
2021-08-17 15:16:40 +03:00
Saugat Pachhai 14cc748139 Increase coverage target to 90%. 2021-08-17 12:23:35 +05:45
David de la Iglesia Castro 40abf34c8c Update dvc exp branch help (#6439) 2021-08-17 11:14:44 +09:00
Ruslan Kuprieiev 9fef38e778 dvc: bump to 2.6.2 2021-08-17 02:52:07 +03:00
Batuhan Taskaya b44e1c3646 fetched: correctly order mixed odbs (#6436) 2021-08-17 02:51:08 +03:00
Ruslan Kuprieiev c9b3d9dbae dvc: bump to 2.6.1 2021-08-17 01:49:06 +03:00
Ruslan Kuprieiev c2bf0dc713 analytics: add DVC_ANALYTICS env var (#6448) 2021-08-17 01:48:38 +03:00
Saugat Pachhai 451bd3ebc8 Change ui messaging for the updater (#6437) 2021-08-16 18:31:44 +03:00
Arjun Vikram 41bc81910f plots: upgrade to latest Vega library versions (#6371) 2021-08-16 16:22:09 +02:00
Ruslan Kuprieiev 0aec0e3249 updater: only notify in dvc doctor (#6247)
We've used updater for years now to notify our users ASAP about new
releases. These days dvc is more stable and we've actually been
receiving numerous complains about updater being too annoying and
even introduced a config flag to turn it off, which a lot of people do
and which defeats its purpose. Instead of introducing some complex logic
that would notify more rarely and once for the user instead of once for
every dvc project they have - this PR just turns it off for everything,
except `dvc doctor/version`, which is the command that people use when
they are running into issues and that will give them a hint to just try
out the newest version first, as it might be already fixed.

This PR is a more of a compromise, to get the most out of updater while
removing a pain point of it being too annoying, without investing much
time into it and not having to maintain complex unnecessary logic. We
might get back to introducing something more intricate in the future,
if the need arises.

Fixes #5912
2021-08-16 17:28:49 +05:45
Batuhan Taskaya 8406e605ab fs: find(..., detail=True) now casts values properly (#6432)
* fs: find(..., detail=True) now casts values properly

* bump pydrive2
2021-08-16 14:26:07 +03:00
Saugat Pachhai 1bb7f39e14 add support for Python 3.10 (#6422)
* add support for Python 3.10

pyarrow is not available, so we are skipping tests for it.
And, restricting `hdfs` extras to be not installed in Python3.10.

Pathlib removed `_init` method and `init` parameter, so just working
around that. It's not a big change.

* skip windows on 3.10; update pip/wheel/setuptools during install

pywin32 is not yet available for 3.10 Python, so skipping those for now

* install libgit2 with Python3.10 as pygit2 wheel is not available

* install libgit2

* only run on macOS, libgit2-1.1 not available on ubuntu
2021-08-16 16:24:28 +05:45
dependabot[bot] a285d2ba47 build(deps): Bump pygments from 2.9.0 to 2.10.0 (#6430)
Bumps [pygments](https://github.com/pygments/pygments) from 2.9.0 to 2.10.0.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.9.0...2.10.0)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-08-16 10:03:34 +03:00
Ruslan Kuprieiev 13c1314099 dvc: bump to 2.6.0 2021-08-15 17:39:36 +03:00
Ruslan Kuprieiev 27b54198a4 pyinstaller: build: check dvc doctor output (#6425) 2021-08-15 17:30:17 +03:00
Saugat Pachhai a7f01b65a5 Unpin networkx
Networkx v2.6.2 now does not require any additional requirements. All of the other 2.6 releases have been yanked. So, it's safe for us to unpin it.
2021-08-13 14:53:10 +05:45
Gao de97a49401 Remove a special queued experiments (#6393)
* Remove a special queued experiments

fix #6157
1. dvc exp remove not accept queued experiments name
2. add some tests for this feature

* Extract tests and add revision support

1. Extract remove experiments to a new file.
2. revision can be used to remove special queued experiment

* Accept shortened revisions

* Split removing committed and queued exp functions

* Api name change

* Return to the old API name

* shorten some functions

* Error message change

* Better test cases, more corner case

* Still raise exception in a mixed case
2021-08-13 15:15:21 +08:00
dependabot[bot]andBatuhan Taskaya 4c957bf4ab build(deps): bump google-cloud-storage from 1.41.1 to 1.42.0 (#6415)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.41.1 to 1.42.0.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/master/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.41.1...v1.42.0)

---
updated-dependencies:
- dependency-name: google-cloud-storage
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: Batuhan Taskaya <[email protected]>
2021-08-12 13:15:34 +03:00
6dd435eefe Push running checkpoint to remote (#6332)
* Push running checkpoint to remote

Fix #6182

* Update dvc/repo/experiments/executor/base.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Get the branch name

* Finish this PR

1. move env name to dvc/env.py.
2. add some tests for it.

* Update after finally commit

* Some problems found in the review

1. change the behavior of self remote
2. do not use string DVC_EXP_AUTO_PUSH
3. downgrade the logger level in auto push
4. use full branch ref

* Update dvc/repo/experiments/executor/base.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* add a remote check before auto push

* Split current one env into two

* Rename DVC_EXP_CHECKPOINT_PUSH to DVC_EXP_AUTO_PUSH

* value error

* Remove getenv_bool switch to env2bool

* Better on handling Dulwich exceptions

* Name changing removing prints

* Update dvc/env.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Some changes in review

* Add validation to repo URL

* Add new type of SCMError

* Update dvc/repo/experiments/executor/base.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Use spy tests call counts and args

* Update dvc/scm/git/backend/base.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

* Update dvc/scm/git/backend/dulwich.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

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

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

* Remove validate_git_repo to validate_git_remote

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-08-12 15:17:08 +08:00
Saugat Pachhai fb96b13f13 Fix issue while sending analytics report (#6407)
* demonstrate issue while sending analytics report

When sending analytics, we are not flushing the data to the temp file
which causes failure at times, especially on Windows

* close file before calling daemonized analytics send command

This way, the contents bufferred will get a chance to be flushed.
2021-08-10 16:07:46 +03:00
Peter Rowlands (변기호) 6c646a7070 pygit: account for locked dirs on windows during checkout (#6408) 2021-08-10 15:18:32 +03:00
dependabot[bot] 955aedee13 build(deps): bump jaraco-windows from 5.5.0 to 5.6.0 (#6396)
Bumps [jaraco-windows](https://github.com/jaraco/jaraco.windows) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/jaraco/jaraco.windows/releases)
- [Changelog](https://github.com/jaraco/jaraco.windows/blob/main/CHANGES.rst)
- [Commits](https://github.com/jaraco/jaraco.windows/compare/v5.5.0...v5.6.0)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-08-10 15:16:15 +03:00
Ruslan Kuprieiev e99e6cd367 repofs: use underlying fs.download to download files (#6401) 2021-08-10 13:12:56 +03:00
Batuhan Taskaya 1f8278252a gdrive: migrate to pydrive2.fs (#6399) 2021-08-10 12:59:44 +03:00
dependabot[bot] 38cd7d01c2 build(deps): bump wheel from 0.36.2 to 0.37.0 (#6404)
Bumps [wheel](https://github.com/pypa/wheel) from 0.36.2 to 0.37.0.
- [Release notes](https://github.com/pypa/wheel/releases)
- [Changelog](https://github.com/pypa/wheel/blob/master/docs/news.rst)
- [Commits](https://github.com/pypa/wheel/compare/0.36.2...0.37.0)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-08-10 08:35:38 +05:45
Maxime RischardandRuslan Kuprieiev 63b710b7c0 More granular read-write lock requests in the run() and reproduce() methods of the Stage class (#5815)
* Remove rwlock decorator for the run() method of the Stage class.

Instead of pre-emptively grabbing read locks on the dependencies
and write locks on the outputs when calling the run() method,
delegate the locking mechanism to other methods.
This results in more selective locking that is less likely
to cause a LockError when executing steps in parallel.

* Remove rwlocked decorator from reproduce() method of Stage class.

* Reinstate read-lock (not write) for run method of Stage class

* fix bugs and typos

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-08-10 01:49:21 +03:00
Ruslan Kuprieiev d97a19e97d refodb: cache ref objects (#6392)
* refodb: cache ref objects

* odb: ignore FileNotFoundError when removing
2021-08-09 13:53:38 +03:00
Saugat Pachhai fd76e0991f cleanup index, and add test for used_objs (#6390) 2021-08-06 15:35:01 +05:45
Gao 3d604a8455 Show name of experiments in the queue (#6382)
* Show name of stashed experiments

* Add tests for it

* Reorder the stash and commit rev name
2021-08-06 11:02:16 +09:00
Saugat Pachhai 6c33fbb617 introduce index (#6300)
* introduce index

* migrate tests to use index

* migrate checkout/diff to use index.outs

* test for index

* fix typings

* add few more tests
2021-08-05 14:23:40 +03:00
Nikita KodenkoandPeter Rowlands 3be395b3a7 Unittest to pytest: moved few unittest tests to pytest (#6379)
* tests: rm TestGit in func/test_init.py

* tests: func: rm TestSCMGit in test_scm.py

* test: func: rm _check_dirs in test_init.py

* tests: func: minor fixes in test_scm.py

* Update tests/func/test_scm.py

Co-authored-by: Peter Rowlands (변기호) <[email protected]>

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-08-04 15:11:09 +03:00
Peter Rowlands (변기호) 114a07e7a4 objects: use object IDs and references instead of naive objs in status/transfer (#6360)
* objects.status: use object IDs instead of naive objects

* objects.transfer: use object IDs instead of naive objects

* objects: add ReferenceHashFile

* odb: add ReferenceObjectDB

* repo: collect used object IDs instead of naive objs

* support basic/naive fs serialization

* objects.stage: stage file objects in addition to trees

* objects: remove save(), update transfer()

* output/dep: update for osave removal

* path_info: support both url and path attributes

* status/gc: use object IDs instead of naive objs

* diff: update staging usage

* commit: update staging usage

* tests: clean staging when needed

* tests: update for save/transfer/stage changes

* dvcfs: don't allow config serialization (repofs should be used instead)

* utils: return mtime as str, size as int

* tests: update unit tests for save/stage/transfer changes

* objects: use Repo.open when needed in ReferenceHashFile

* fix ls test staging

* objects.stage: don't skip hash check when loading from state

- individual hashes will still be loaded from state if they are still
  valid

* ReferenceHashFile: serialize repo fs paths relative to repo root

* preserve state when staging

* objects.tree: skip hash computation in digest() when we have already
loaded hash from state

* always check file existence in referencehashfile

* update repofs test since stage no longer allows nonexistent files

* odb: only protect on check() if hash was verified

* staging: use unique memfs namespace for staging per dest ODB

* tests: clean full staging namespace

* refodb: preserve repofs cache dir

* repodependency: stage imports to local ODB and strict check circular
imports

* fetch: fetch staging last

* staging: make dest odb mandatory and preserve state

* refodb: cache nonlocal fs's

* fix rebase errors

* repofs: support full instantiation from fs config

* repofs: preserve subrepo factory for erepos

* tests: clarify repofs dirty file behavior
2021-08-04 13:55:52 +03:00
Peter Rowlands (변기호) 34697ef298 exp gc: fix --queued warning message (#6381) 2021-08-03 10:37:38 +00:00
Saugat Pachhai 1eccfa9d33 benedict: cleanup code and add tests (#6380) 2021-08-03 12:28:42 +05:45
dependabot[bot]andPeter Rowlands 6c14bf39dc build(deps): bump pylint from 2.8.3 to 2.9.6 (#6369)
* build(deps): bump pylint from 2.8.3 to 2.9.6

Bumps [pylint](https://github.com/PyCQA/pylint) from 2.8.3 to 2.9.6.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Changelog](https://github.com/PyCQA/pylint/blob/main/ChangeLog)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.8.3...v2.9.6)

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

Signed-off-by: dependabot[bot] <[email protected]>

* pylint: fix 2.9.6 issues

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Rowlands <[email protected]>
2021-08-03 10:29:55 +09:00
Saugat Pachhai 7444696dcd Fix merging of params when empty (#6378)
* Fix merging of params when empty

Recent release of python-benedict==0.24.1 started ignoring empty dictionaries
during merge. The way we expected to work was questionable and also how
benedict itself tries to handle the references to empty dictionary is
also questionable.

I consider this to be a proper way to handle this even if this gets fixed
on the benedict side.

* Revert "setup: limit python-benedict version (#6377)"

This reverts commit 15de826266.

* add comment for why passing through benedict
2021-08-02 22:45:04 +03:00
pre-commit-ci[bot] 76be53bc3e [pre-commit.ci] pre-commit autoupdate (#6376)
updates:
- [github.com/timothycrosley/isort: 5.9.2 → 5.9.3](https://github.com/timothycrosley/isort/compare/5.9.2...5.9.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-08-02 21:46:35 +03:00
Ruslan Kuprieiev 8d71c3861f fs: raise RemoteNotFoundError (#6375) 2021-08-02 21:09:55 +03:00
Ruslan Kuprieiev 15de826266 setup: limit python-benedict version (#6377)
Temporary fix for #6374 Kudos @skshetry
2021-08-02 20:44:03 +03:00
Batuhan Taskaya 9634efeb84 ssh: handle concurrency issues for raw command execution (#6370)
* ssh: handle concurrency issues for raw command execution

* setup: bump sshfs
2021-07-29 14:40:49 +03:00
Ruslan Kuprieiev e3a541aa00 setup: relax aiobotocore requirements (#6367)
Fixes #6366
2021-07-28 16:19:16 +03:00
Batuhan Taskaya 9b4b6e5b31 ssh: migrate to sshfs (#6295) 2021-07-28 12:49:53 +03:00
dependabot[bot] b294bca383 build(deps): bump codecov/codecov-action from 2.0.1 to 2.0.2 (#6361)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.0.1 to 2.0.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2.0.1...v2.0.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-28 10:21:10 +03:00
Batuhan Taskaya 4a843dbd61 odb: use regular upload() when the source filesystem is local (#6365)
* odb: use regular upload() when the source filesystem is local

* support download_file too
2021-07-27 15:55:49 +03:00
Ruslan Kuprieiev 1e43c303a7 output: cache tree objects (#6301) 2021-07-24 23:07:43 +03:00
meieraleandAlexander Meier 985c0328c7 remote: add excludes for AzureDefaultCredentials (#6359)
add additional excludes to DefaultAzureCredential
which are needed in a Windows Environment to
use az cli provided credentials, etc.

Co-authored-by: Alexander Meier <[email protected]>
2021-07-23 16:02:16 +03:00
Batuhan Taskaya d5722e3edf fsspec: use rm_file() (#6358) 2021-07-23 14:10:16 +03:00
Paweł Redzyński 67b42ebe4f plots: cleanup data extraction (#6355)
* plots: cleanup data extraction

* fixup
2021-07-23 10:28:36 +09:00
Batuhan Taskaya 1d7e754044 setup: bump flatten-dict to 0.4.1 (#6357) 2021-07-22 21:46:18 +03:00
Batuhan Taskaya c8739008d1 fsspec: implement callback mixins (#6322)
* fsspec: implement callback mixins

* drop redundant refresh() calls
2021-07-22 18:42:28 +03:00
Nikita KodenkoandSaugat Pachhai e731e35e6c tests: moved few unittest tests to pytest (#6339)
* tests: moved few unittest tests to pytest

* tests: unittest.mock -> pytest-mock in few tests

* tests: test_prompt: unittest.mock -> pytest-mock

* Update tests/unit/test_daemon.py

Co-authored-by: Saugat Pachhai <[email protected]>

Co-authored-by: Saugat Pachhai <[email protected]>
2021-07-22 18:06:54 +03:00
Peter Rowlands (변기호) cd7c95a785 ci: temporarily mark gdrive xfail (#6348) 2021-07-22 11:14:54 +03:00
dependabot[bot] 771b89ddf2 build(deps): bump google-cloud-storage from 1.41.0 to 1.41.1 (#6341)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.41.0 to 1.41.1.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/master/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.41.0...v1.41.1)

---
updated-dependencies:
- dependency-name: google-cloud-storage
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-22 00:42:07 +03:00
Saugat Pachhai 64587e8b6d do not print in style by default for errors (#6344) 2021-07-21 14:42:29 +03:00
Saugat Pachhai 5faf9b3a7d update webdav4 to latest fsspec version (#6343) 2021-07-21 10:17:14 +00:00
Peter Rowlands (변기호) 12d1e3e981 objects: fix gdrive CI issue (#6338)
* fs: ensure gdrivefs.info returns integer size

* transfer: only verify on pull
2021-07-20 18:54:49 +03:00
pre-commit-ci[bot] d95bd62f8e [pre-commit.ci] pre-commit autoupdate (#6333)
updates:
- [github.com/ambv/black: 21.6b0 → 21.7b0](https://github.com/ambv/black/compare/21.6b0...21.7b0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-07-20 14:12:05 +03:00
dependabot[bot] 005bca1da6 build(deps): bump codecov/codecov-action from 1.5.2 to 2.0.1 (#6334)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 1.5.2 to 2.0.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v1.5.2...v2.0.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-20 14:06:50 +03:00
Peter Rowlands (변기호) 3d84df930b objects: migrate remote push/pull to objects.transfer (#6308)
* objects: migrate remote status to objects.status

* data_cloud: use objects.status

* objects: migrate remote._process to objects.transfer

* data_cloud: use objects.transfer

* objects: handle memfs staging objects in transfer

* fetch/import: use unified transfer()

* update get_remote and transfer exception usage

* update tests

* objects.transfer: use src ODB verify rules after xfer

* odb: move index from remote into odb

* objects.transfer: skip src status check when possible

* update tests

* remove dvc.remote

* odb.index: migrate to diskcache

* objects: fix status/transfer optimization

* update index usage

* state: remove dead sqlite related code

* update cloud tests

* drop unnecessary remove in tests

* use abstract base class in odb index

* status: load trees from local cache instead of remote odb when possible

* handle status when dir cache is explicitly removed
2021-07-20 13:30:01 +03:00
Batuhan Taskaya 66d0df2d12 setup: upgrade adlfs to 2021.7.1 (#6337)
The previous release got yanked on the PyPI, due to it containing backwards incompatible changes.
2021-07-20 08:59:45 +00:00
Christian Clauss d8b67f6f78 Upgrade to modern Python syntax (#6296) 2021-07-19 01:30:37 +03:00
dependabot[bot] 6711b647d9 build(deps): bump google-cloud-storage from 1.40.0 to 1.41.0 (#6311)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.40.0 to 1.41.0.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/master/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.40.0...v1.41.0)

---
updated-dependencies:
- dependency-name: google-cloud-storage
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-19 00:41:23 +03:00
Batuhan Taskaya 1e81983d6d import-url: use the same chunk size for istextblock() calls (#6303) 2021-07-19 00:28:42 +03:00
Ruslan Kuprieiev 97809661bd setup: pathspec <0.9.0 (#6331) 2021-07-18 18:24:28 +03:00
Batuhan Taskaya b229588df3 azure: move to default fsspec loop (#6323) 2021-07-18 17:47:20 +03:00
Sayanta Banerjee cae117cfd4 Update README.rst (#6330)
* Update README.rst

More clean and simple

* Update README.rst

Rectification from the previous pull
2021-07-16 23:44:30 +03:00
Paweł Redzyński 21af7438db plots/metrics/params/experiments show: stop throwing exceptions (#5984)
* params/metrics/plots/exp show: introduce onerror

* fixup

* plots/metrics/params: removing errors

* removing more errors

* removing errors

* some more removing

* new plots ux

* new ux for metrics

* new ux for params

* fixing tests

* fixups

* minor test fixes

* minor test fixes

* commands: plots/params: fix ui imports

* working on error handlng decorator, stopped at show checkpoints

* error_handler, further dvelopment

* plots just before moving loading to collect

* appending errors to results: initial passing

* fixup

* onerror: remove onerror fixture

* fixups

* plots/metrics/params/experiments: temporarily remove error warnings

* plots: extract parsing out of class method

* metrics/experiments: provide exception serializer for show-json

* params/plots/metrics: signal errors

* rebase fixups

* fix TODOs

* fixup

* skshetry's review refactor
2021-07-16 15:36:04 +02:00
Batuhan Taskaya 27c1722b90 setup: bump ossfs==2021.7.5 (#6326) 2021-07-16 20:48:57 +08:00
Batuhan Taskaya 6c8673b158 setup: upgrade fsspec/s3fs/gcsfs to 2021.7.0 (#6317) 2021-07-15 11:33:07 +02:00
Batuhan Taskaya 6275f5d0d7 fsspec: create directories on upload/upload_fobj (#6309) 2021-07-14 15:42:51 +03:00
GaoandRuslan Kuprieiev 93f36d9351 oss: migrate to ossfs (#6307)
* Migrate to ossfs

fix 6177

* Add verification to object stored on oss

* Change the oss remote's dependency.

pass all of the tests

* Performance optimaztion and ossfs version update

* Remove the argument to set the resumable get/put

1. Experiments shows resumable get/put has little influnence on performance.
2. Just use resumable ones as default

* Use a new api for performance optimization

* Without filesize progress bar version

* With progress bar version

* Bump and add comments

1. Bump ossfs from 2021.6.2 to 2021.7.0
2. add some comments to the verification trigger

* Change the name of kwrags following parent class.

* Bump to new ossfs and rewrite upload_fobj

Because some problem with oss-emulater
1. bump to ossfs 2021.7.1
2. rewrite upload_fobj

* Optimization on rm_files

* Some optimization follow the old version.

* Update dvc/fs/oss.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>

* Update dvc/objects/db/oss.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>

* bump into ossfs 2021.7.2

* Bump into ossfs 2021.7.3OC

* Switch to real_oss_server

* Switch to a real oss server

* Use the oss bucket located in US.

* Remove some emulator related code

* Add envs to ci

* remove some oss pytest XFAIL

Because we didn't use emulator, we can remove some pytest XFAILs

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-07-13 15:27:34 +03:00
Batuhan Taskaya 58f0911234 azure: support directory imports with azure (#6245)
* azure: support directory imports with azure

* upgrade azurite to 3.13

* tmp: use azurite 3.12?

* upgrade to azurite 3.13.1

* bump adlfs to 2021.7.0
2021-07-13 13:39:30 +03:00
pre-commit-ci[bot] f45311e658 [pre-commit.ci] pre-commit autoupdate (#6306)
updates:
- [github.com/timothycrosley/isort: 5.9.1 → 5.9.2](https://github.com/timothycrosley/isort/compare/5.9.1...5.9.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-07-13 08:49:27 +05:45
Peter Rowlands (변기호) 1271db6b59 exp show: display running/queued state for experiments (#6174)
* exp: allow fetching from arbitrary executor by git URL

* exp: write info for running executors to .dvc/tmp/exp/run

currently only includes PID and git fetch URL

* exp: refactor stash/queue cleanup

* exp: add ability to collect dict of currently running experiments

* exp show: display run/queue state in the table

* exp run: unlock main repo while running tmpdir executors

* add tests for new exp show behavior

* fix workspace checkpoint run handling

* fix issue where running stash revs were shown as queued

* use "running"/"queued"

* add executor (location) column

* compare: add TabularData.is_empty

* use td.is_empty to drop state/executor

* use "workspace" and "temp" as executor names
2021-07-13 00:20:38 +09:00
Saugat Pachhai beb610c3ab add: ignore duplicated entries (#6286) 2021-07-12 20:43:41 +05:45
Batuhan Taskaya 4557de420c import-url: compute the stage hash in --to-remote (#6175)
* import-url: compute the stage hash in --to-remote
* save_deps() before calculating the md5
2021-07-12 09:59:34 +03:00
dependabot[bot] 481a411a35 build(deps): bump wsgidav from 3.1.0 to 3.1.1 (#6302)
Bumps [wsgidav](https://github.com/mar10/wsgidav) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/mar10/wsgidav/releases)
- [Changelog](https://github.com/mar10/wsgidav/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mar10/wsgidav/compare/v3.1.0...v3.1.1)

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

Signed-off-by: dependabot[bot] <[email protected]>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-12 11:26:14 +05:45
Ruslan Kuprieiev 52047214b7 fs: repo: handle directories with multiple artifacts (#6278)
Related to #5677
2021-07-12 02:17:07 +03:00
Peter Rowlands (변기호) 71bae7e4af objects: use separate staging ODB for staging trees (#6195)
* fs: use fsspecwrapper in memfs

* odb: add memfs staging ODB in ODBManager

* odb: add read_only attribute for odb instances

* objects: handle memfs paths when staging trees

* odb: remove git ODB

* output: use staging ODB

* RepoDependency: use staging odb for imports

* dvcfs: handle case where dir cache is unavailable

* fix circular import check

* odb: use temp local ODB for staging

* objects: use staging ODB in stage() and tree load

* objects: add typing for add/check/load/stage

* output/dep: use new staging

* add unit tests for local odb staging

* localfs: account for string paths in move()

* include local in odb.by_scheme

* tests: count dir cache like any other object in test_pull_git_imports

* odb: clear staging on gc()

* objects: add tree.get/filter/insert

* output: use obj.filter/get for granular dir commit

* squash filter logic output

* dependency: don't filter imports at the dep level

* don't init staging ODB for external cache

* use staging ODB when computing tree hash for external outs

* remove unneeded force in test granular commit

* use unique-per-manager memfs URLs for staging

* odb: make staging per-ODBManager

* objects: move state dependent staging into ODBManager

* objects: support checking multiple odbs

- staging dependent checks can be done via ODBManager.check

* checkout: do state lookup before explicit stage() in _changed()

* output: use odbmanager based staging

* RepoDependency: use odbmanager based staging

* update obj staging unit tests

* checkout: remove check(), handle individual file exceptions on link()

* diff: use odbmanager based staging

* objects: add ObjectPermissionError for read/write perm errors

* odb: move staging out of odbmanager

* objects: handle staging ODB in objects.stage()

* objects.tree: don't stage anything in tree methods

* odb: clear staged objects on gc()

* remote: don't filter objs by scheme, objs are already grouped by ODB

* diff: update objects.stage usage

* output/dep: update stage() usage

* update ODB tests

* update func tests

* remove memfs from FS_MAP

* revert unneeded objects.check() changes

* remove unused tree.insert()

* revert unused odbmanager changes

* add separate odb error classes

* stage external outs directly in ODB

* check for obj existence in main ODB before staging

* use .dvc/tmp/staging instead of per-odb staging

* gc: only gc loal staging once
2021-07-09 18:48:28 +03:00
Ruslan Kuprieiev 67c46b98dd dvc: bump to 2.5.4 2021-07-08 23:02:16 +03:00
Ruslan Kuprieiev bd5809572e setup: relax requirements for importlib-metadata 2021-07-08 23:01:48 +03:00
Ruslan Kuprieiev 05832c81fc dvc: bump to 2.5.3 2021-07-08 19:34:26 +03:00
Ruslan KuprieievandSaugat Pachhai 86df89e3a8 pkg: don't build universal wheels (#6299)
Kudos @skshetry for discovering that we've been publishing py2.py3
wheels on pypi and researching the cause.

Co-authored-by: Saugat Pachhai <[email protected]>

Co-authored-by: Saugat Pachhai <[email protected]>
2021-07-08 19:33:52 +03:00
225 changed files with 5867 additions and 4976 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ coverage:
status:
project:
default:
target: 85
target: 90
threshold: 10%
patch:
default:
+14 -6
View File
@@ -42,13 +42,14 @@ jobs:
matrix:
os: [ubuntu-18.04, windows-2019, macos-10.15]
pyv: ["3.6", "3.7", "3.8", "3.9"]
# macos 11.0 workers take too long to start up
# include:
# - os: "macos-11.0"
# pyv: "3.9"
include:
- os: "macos-10.15"
pyv: "3.10.0-rc.1"
exclude:
- os: macos-10.15
pyv: "3.9"
- os: macos-10.15
pyv: "3.7"
steps:
- uses: actions/[email protected]
@@ -68,10 +69,14 @@ jobs:
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: install libgit2 on Python3.10 in macOS
if: matrix.pyv == '3.10.0-rc.1' && matrix.os == 'macos-10.15'
run: |
brew install libgit2
- name: install
if: steps.cache.pip-cache-dir.cache-hit != 'true'
run: |
pip install wheel
pip install --upgrade pip setuptools wheel
pip install -e ".[all,tests]"
- name: install temporary dependencies
run: |
@@ -100,12 +105,15 @@ jobs:
GDRIVE_CREDENTIALS_DATA: ${{ secrets.GDRIVE_CREDENTIALS_DATA }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID}}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET}}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT}}
run: >-
python -m tests -n=4
--cov-report=xml --cov-report=term
--all ${{ env.extra_test_args }}
- name: upload coverage report
uses: codecov/codecov-action@v1.5.2
uses: codecov/codecov-action@v2.0.2
with:
file: ./coverage.xml
fail_ci_if_error: false
+2 -2
View File
@@ -6,7 +6,7 @@ repos:
- id: black
language_version: python3
repo: https://github.com/ambv/black
rev: 21.6b0
rev: 21.7b0
- hooks:
- id: codespell
args:
@@ -18,7 +18,7 @@ repos:
- id: isort
language_version: python3
repo: https://github.com/timothycrosley/isort
rev: 5.9.1
rev: 5.9.3
- hooks:
- id: flake8
language_version: python3
+3 -3
View File
@@ -29,9 +29,9 @@ learning projects. Key features:
#. Helps manage experiments with Git tags/branches and **metrics** tracking.
**DVC** aims to replace spreadsheet and document sharing tools (such as Excel or Google Docs)
which are being used frequently as both knowledge repositories and team ledgers.
DVC also replaces both ad-hoc scripts to track, move, and deploy different model versions;
as well as ad-hoc data file suffixes and prefixes.
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
+9 -4
View File
@@ -2,6 +2,8 @@ import json
import logging
import os
from .env import DVC_NO_ANALYTICS
logger = logging.getLogger(__name__)
@@ -32,7 +34,7 @@ def collect_and_send_report(args=None, return_code=None):
with tempfile.NamedTemporaryFile(delete=False, mode="w") as fobj:
json.dump(report, fobj)
daemon(["analytics", fobj.name])
daemon(["analytics", fobj.name])
def is_enabled():
@@ -42,9 +44,12 @@ def is_enabled():
if env2bool("DVC_TEST"):
return False
enabled = to_bool(
Config(validate=False).get("core", {}).get("analytics", "true")
)
enabled = not os.getenv(DVC_NO_ANALYTICS)
if enabled:
enabled = to_bool(
Config(validate=False).get("core", {}).get("analytics", "true")
)
logger.debug("Analytics is {}abled.".format("en" if enabled else "dis"))
return enabled
+41 -38
View File
@@ -11,17 +11,17 @@ from dvc.exceptions import (
)
from dvc.ignore import DvcIgnoreFilter
from dvc.objects import check, load
from dvc.objects.errors import ObjectFormatError
from dvc.objects.stage import stage
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
from dvc.objects.db.slow_link_detection import ( # type: ignore[attr-defined]
slow_link_guard,
)
from dvc.objects.errors import ObjectFormatError
from dvc.objects.stage import stage
from dvc.types import Optional
logger = logging.getLogger(__name__)
def _changed(path_info, fs, obj, cache):
def _changed(path_info, fs, obj, cache, state=None):
logger.trace("checking if '%s'('%s') has changed.", path_info, obj)
try:
@@ -32,11 +32,15 @@ def _changed(path_info, fs, obj, cache):
)
return True
try:
actual = stage(cache, path_info, fs, obj.hash_info.name).hash_info
except FileNotFoundError:
logger.debug("'%s' doesn't exist.", path_info)
return True
actual = state.get(path_info, fs) if state else None
if actual is None:
try:
actual = stage(
cache, path_info, fs, obj.hash_info.name, dry_run=True
)[1].hash_info
except FileNotFoundError:
logger.debug("'%s' doesn't exist.", path_info)
return True
if obj.hash_info != actual:
logger.debug(
@@ -59,7 +63,9 @@ def _remove(path_info, fs, cache, force=False):
fs.remove(path_info)
return
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM).hash_info
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM, dry_run=True)[
1
].hash_info
try:
obj = load(cache, current)
check(cache, obj)
@@ -120,9 +126,11 @@ def _try_links(cache, from_info, to_info, link_types):
def _link(cache, from_info, to_info):
assert cache.fs.isfile(from_info)
cache.makedirs(to_info.parent)
_try_links(cache, from_info, to_info, cache.cache_types)
try:
_try_links(cache, from_info, to_info, cache.cache_types)
except FileNotFoundError as exc:
raise CheckoutError([to_info]) from exc
def _cache_is_copy(cache, path_info):
@@ -163,7 +171,7 @@ def _checkout_file(
modified = False
cache_info = cache.hash_to_path_info(obj.hash_info.value)
if fs.exists(path_info):
if not relink and _changed(path_info, fs, obj, cache):
if not relink and _changed(path_info, fs, obj, cache, state=state):
modified = True
_remove(path_info, fs, cache, force=force)
_link(cache, cache_info, path_info)
@@ -222,19 +230,23 @@ def _checkout_dir(
logger.debug("Linking directory '%s'.", path_info)
failed = []
for entry_key, entry_obj in obj:
entry_modified = _checkout_file(
path_info.joinpath(*entry_key),
fs,
entry_obj,
cache,
force,
progress_callback,
relink,
state=None,
)
if entry_modified:
modified = True
try:
entry_modified = _checkout_file(
path_info.joinpath(*entry_key),
fs,
entry_obj,
cache,
force,
progress_callback,
relink,
state=None,
)
if entry_modified:
modified = True
except CheckoutError as exc:
failed.extend(exc.target_infos)
modified = (
_remove_redundant_files(
@@ -243,6 +255,9 @@ def _checkout_dir(
or modified
)
if failed:
raise CheckoutError(failed)
if state:
state.save(path_info, fs, obj.hash_info)
@@ -310,21 +325,9 @@ def checkout(
_remove(path_info, fs, cache, force=force)
failed = path_info
elif not relink and not _changed(path_info, fs, obj, cache):
elif not relink and not _changed(path_info, fs, obj, cache, state=state):
logger.trace("Data '%s' didn't change.", path_info) # type: ignore
skip = True
else:
try:
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
if not quiet:
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
obj.hash_info,
path_info,
)
_remove(path_info, fs, cache, force=force)
failed = path_info
if failed or skip:
if progress_callback and obj:
-5
View File
@@ -33,17 +33,12 @@ class CmdBase(ABC):
def __init__(self, args):
from dvc.repo import Repo
from dvc.updater import Updater
os.chdir(args.cd)
self.repo = Repo(uninitialized=self.UNINITIALIZED)
self.config = self.repo.config
self.args = args
if self.repo.tmp_dir:
hardlink_lock = self.config["core"].get("hardlink_lock", False)
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
updater.check()
def do_run(self):
with self.repo:
+6 -4
View File
@@ -36,18 +36,20 @@ def _collect_targets(repo, target, outs):
return [stage.addressing for stage, _ in pairs]
targets = []
outs_trie = repo.index.outs_trie
for stage, info in pairs:
if not info:
targets.extend([str(out) for out in stage.outs])
continue
for out in repo.outs_trie.itervalues(prefix=info.parts): # noqa: B301
for out in outs_trie.itervalues(prefix=info.parts): # noqa: B301
targets.extend(str(out))
return targets
def _transform(repo, outs):
def _transform(index, outs):
import networkx as nx
from dvc.stage import Stage
@@ -55,7 +57,7 @@ def _transform(repo, outs):
def _relabel(node) -> str:
return node.addressing if isinstance(node, Stage) else str(node)
G = repo.outs_graph if outs else repo.graph
G = index.outs_graph if outs else index.graph
return nx.relabel_nodes(G, _relabel, copy=True)
@@ -85,7 +87,7 @@ def _filter(G, targets, full):
def _build(repo, target=None, full=False, outs=False):
targets = _collect_targets(repo, target, outs)
G = _transform(repo, outs)
G = _transform(repo.index, outs)
return _filter(G, targets, full)
+68 -19
View File
@@ -16,6 +16,7 @@ from dvc.command.repro import add_arguments as add_repro_arguments
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.ui import ui
from dvc.utils.flatten import flatten
from dvc.utils.serialize import encode_exception
if TYPE_CHECKING:
from rich.text import Text
@@ -28,6 +29,7 @@ logger = logging.getLogger(__name__)
SHOW_MAX_WIDTH = 1024
FILL_VALUE = "-"
FILL_VALUE_ERRORED = "!"
def _filter_name(names, label, filter_strs):
@@ -90,6 +92,7 @@ def _filter_names(
def _update_names(names, items):
for name, item in items:
item = item.get("data", {})
if isinstance(item, dict):
item = flatten(item)
names[name].update({key: None for key in item})
@@ -100,7 +103,8 @@ def _collect_names(all_experiments, **kwargs):
param_names = defaultdict(dict)
for _, experiments in all_experiments.items():
for exp in experiments.values():
for exp_data in experiments.values():
exp = exp_data.get("data", {})
_update_names(metric_names, exp.get("metrics", {}).items())
_update_names(param_names, exp.get("params", {}).items())
metric_names = _filter_names(
@@ -150,8 +154,15 @@ def _collect_rows(
)
new_checkpoint = True
for i, (rev, exp) in enumerate(experiments.items()):
queued = str(exp.get("queued") or "")
for i, (rev, results) in enumerate(experiments.items()):
exp = results.get("data", {})
if exp.get("running"):
state = "Running"
elif exp.get("queued"):
state = "Queued"
else:
state = FILL_VALUE
executor = exp.get("executor", FILL_VALUE)
is_baseline = rev == "baseline"
if is_baseline:
@@ -163,7 +174,7 @@ def _collect_rows(
tip = exp.get("checkpoint_tip")
parent_rev = exp.get("checkpoint_parent", "")
parent_exp = experiments.get(parent_rev, {})
parent_exp = experiments.get(parent_rev, {}).get("data", {})
parent_tip = parent_exp.get("checkpoint_tip")
parent = ""
@@ -190,15 +201,27 @@ def _collect_rows(
row = [
exp_name,
name_rev,
queued,
typ,
_format_time(exp.get("timestamp")),
parent,
state,
executor,
]
fill_value = FILL_VALUE_ERRORED if results.get("error") else FILL_VALUE
_extend_row(
row, metric_names, exp.get("metrics", {}).items(), precision
row,
metric_names,
exp.get("metrics", {}).items(),
precision,
fill_value=fill_value,
)
_extend_row(
row,
param_names,
exp.get("params", {}).items(),
precision,
fill_value=fill_value,
)
_extend_row(row, param_names, exp.get("params", {}).items(), precision)
yield row
@@ -261,19 +284,23 @@ def _format_time(timestamp):
return timestamp.strftime(fmt)
def _extend_row(row, names, items, precision):
def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
from rich.text import Text
from dvc.compare import _format_field, with_value
if not items:
row.extend(FILL_VALUE for keys in names.values() for _ in keys)
row.extend(fill_value for keys in names.values() for _ in keys)
return
for fname, item in items:
for fname, data in items:
item = data.get("data", {})
item = flatten(item) if isinstance(item, dict) else {fname: item}
for name in names[fname]:
value = with_value(item.get(name), FILL_VALUE)
value = with_value(
item.get(name),
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
)
# wrap field data in rich.Text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
@@ -305,7 +332,15 @@ def experiments_table(
from dvc.compare import TabularData
headers = ["Experiment", "rev", "queued", "typ", "Created", "parent"]
headers = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
td = TabularData(
lconcat(headers, metric_headers, param_headers), fill_value=FILL_VALUE
)
@@ -341,8 +376,7 @@ def prepare_exp_id(kwargs) -> "Text":
text.append(suff)
tree = experiment_types[typ]
queued = "*" if kwargs.get("queued") else ""
pref = (f"{tree} " if tree else "") + queued
pref = f"{tree} " if tree else ""
return Text(pref) + text
@@ -382,9 +416,13 @@ def show_experiments(
if no_timestamp:
td.drop("Created")
for col in ("State", "Executor"):
if td.is_empty(col):
td.drop(col)
row_styles = lmap(baseline_styler, td.column("typ"))
merge_headers = ["Experiment", "rev", "queued", "typ", "parent"]
merge_headers = ["Experiment", "rev", "typ", "parent"]
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
td.drop(*merge_headers[1:])
@@ -392,6 +430,8 @@ def show_experiments(
styles = {
"Experiment": {"no_wrap": True, "header_style": "black on grey93"},
"Created": {"header_style": "black on grey93"},
"State": {"header_style": "black on grey93"},
"Executor": {"header_style": "black on grey93"},
}
header_bg_colors = {"metrics": "cornsilk1", "params": "light_cyan1"}
styles.update(
@@ -430,7 +470,7 @@ def _normalize_headers(names):
def _format_json(item):
if isinstance(item, (date, datetime)):
return item.isoformat()
raise TypeError
return encode_exception(item)
class CmdExperimentsShow(CmdBase):
@@ -589,7 +629,7 @@ class CmdExperimentsGC(CmdRepro):
msg += " of the current repo."
if self.args.queued:
msg += " Run queued experiments will be preserved."
if self.args.queued:
else:
msg += " Run queued experiments will be removed."
logger.warning(msg)
@@ -707,7 +747,9 @@ class CmdExperimentsRemove(CmdBase):
def run(self):
self.repo.experiments.remove(
exp_names=self.args.experiment, queue=self.args.queue
exp_names=self.args.experiment,
queue=self.args.queue,
clear_all=self.args.all,
)
return 0
@@ -1197,9 +1239,16 @@ def add_parser(subparsers, parent_parser):
help=EXPERIMENTS_REMOVE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_remove_parser.add_argument(
remove_group = experiments_remove_parser.add_mutually_exclusive_group()
remove_group.add_argument(
"--queue", action="store_true", help="Remove all queued experiments."
)
remove_group.add_argument(
"-A",
"--all",
action="store_true",
help="Remove all committed experiments.",
)
experiments_remove_parser.add_argument(
"experiment",
nargs="*",
+6 -5
View File
@@ -12,12 +12,13 @@ class CmdLive(CmdBase):
def _run(self, target, revs=None):
metrics, plots = self.repo.live.show(target=target, revs=revs)
html_path = self.args.target + ".html"
self.repo.plots.write_html(html_path, plots, metrics)
if plots:
html_path = self.args.target + ".html"
self.repo.plots.write_html(html_path, plots, metrics)
ui.write("\nfile://", os.path.abspath(html_path), sep="")
return 0
ui.write("\nfile://", os.path.abspath(html_path), sep="")
return 0
return 1
class CmdLiveShow(CmdLive):
+3 -9
View File
@@ -3,8 +3,9 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.exceptions import BadMetricError, DvcException
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils.serialize import encode_exception
logger = logging.getLogger(__name__)
@@ -33,17 +34,10 @@ class CmdMetricsShow(CmdMetricsBase):
if self.args.show_json:
import json
ui.write(json.dumps(metrics))
ui.write(json.dumps(metrics, default=encode_exception))
else:
from dvc.compare import show_metrics
# When `metrics` contains a `None` key, it means that some files
# specified as `targets` in `repo.metrics.show` didn't contain any
# metrics.
missing = metrics.pop(None, None)
if missing:
raise BadMetricError(missing)
show_metrics(
metrics,
markdown=self.args.show_md,
+23 -17
View File
@@ -42,29 +42,35 @@ class CmdPlots(CmdBase):
ui.write(plots[target])
return 0
rel: str = self.args.out or "plots.html"
path = (Path.cwd() / rel).resolve()
self.repo.plots.write_html(
path, plots=plots, html_template_path=self.args.html_template
)
except DvcException:
logger.exception("")
return 1
assert path.is_absolute() # as_uri throws ValueError if not absolute
url = path.as_uri()
ui.write(url)
if self.args.open:
import webbrowser
if plots:
rel: str = self.args.out or "plots.html"
path: Path = (Path.cwd() / rel).resolve()
self.repo.plots.write_html(
path, plots=plots, html_template_path=self.args.html_template
)
opened = webbrowser.open(rel)
if not opened:
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
assert (
path.is_absolute()
) # as_uri throws ValueError if not absolute
url = path.as_uri()
ui.write(url)
if self.args.open:
import webbrowser
opened = webbrowser.open(rel)
if not opened:
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
else:
ui.error_write(
"No plots were loaded, visualization file will not be created."
)
return 0
+1 -1
View File
@@ -66,7 +66,7 @@ def prepare_stages_data(
class CmdStageList(CmdBase):
def _get_stages(self) -> Iterable["Stage"]:
if self.args.all:
stages: List["Stage"] = self.repo.stages # type: ignore
stages: List["Stage"] = self.repo.index.stages # type: ignore
logger.trace( # type: ignore[attr-defined]
"%d no. of stages found", len(stages)
)
+2 -2
View File
@@ -39,7 +39,7 @@ class CmdDataStatus(CmdDataBase):
for key, value in status.items():
if isinstance(value, str):
ui.write("{}{}{}".format(ind, self._normalize(value), key))
ui.write(f"{ind}{self._normalize(value)}{key}")
elif value:
ui.write(f"{ind}{key}:")
self._show(value, indent + 1)
@@ -80,7 +80,7 @@ class CmdDataStatus(CmdDataBase):
return 0
# additional hints for the user
if not self.repo.stages:
if not self.repo.index.stages:
ui.write(self.EMPTY_PROJECT_MSG)
elif self.args.cloud or self.args.remote:
remote = self.args.remote or self.repo.config["core"].get(
+3
View File
@@ -10,9 +10,12 @@ logger = logging.getLogger(__name__)
class CmdVersion(CmdBaseNoRepo):
def run(self):
from dvc.info import get_dvc_info
from dvc.updater import notify_updates
dvc_info = get_dvc_info()
ui.write(dvc_info, force=True)
notify_updates()
return 0
+6 -1
View File
@@ -134,6 +134,10 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
self.drop(*(set(self._keys) - set(col_names)))
self._keys = list(col_names)
def is_empty(self, col_name: str) -> bool:
col = self.column(col_name)
return not any(item != self._fill_value for item in col)
def to_csv(self) -> str:
import csv
from io import StringIO
@@ -277,8 +281,9 @@ def metrics_table(
td = TabularData(["Revision", "Path"], fill_value="-")
for branch, val in metrics.items():
for fname, metric in val.items():
for fname, metric in val.get("data", {}).items():
row_data: Dict[str, str] = {"Revision": branch, "Path": fname}
metric = metric.get("data", {})
flattened = (
flatten(format_dict(metric))
if isinstance(metric, dict)
+9 -1
View File
@@ -20,7 +20,15 @@ class ConfigError(DvcException):
super().__init__(f"config file error: {msg}")
class NoRemoteError(ConfigError):
class RemoteConfigError(ConfigError):
pass
class NoRemoteError(RemoteConfigError):
pass
class RemoteNotFoundError(RemoteConfigError):
pass
+5 -1
View File
@@ -189,7 +189,11 @@ SCHEMA = {
"tenant_id": str,
"client_id": str,
"client_secret": str,
"allow_anonymous_login": bool,
"allow_anonymous_login": Bool,
"exclude_environment_credential": Bool,
"exclude_visual_studio_code_credential": Bool,
"exclude_shared_token_cache_credential": Bool,
"exclude_managed_identity_credential": Bool,
**REMOTE_COMMON,
},
"oss": {
+49 -33
View File
@@ -3,9 +3,11 @@
import logging
from typing import TYPE_CHECKING, Iterable, Optional
from dvc.objects.db import get_index
if TYPE_CHECKING:
from dvc.objects.file import HashFile
from dvc.remote.base import Remote
from dvc.hash_info import HashInfo
from dvc.objects.db.base import ObjectDB
logger = logging.getLogger(__name__)
@@ -24,18 +26,18 @@ class DataCloud:
def __init__(self, repo):
self.repo = repo
def get_remote(
def get_remote_odb(
self,
name: Optional[str] = None,
command: str = "<command>",
) -> "Remote":
) -> "ObjectDB":
from dvc.config import NoRemoteError
if not name:
name = self.repo.config["core"].get("remote")
if name:
return self._init_remote(name)
return self._init_odb(name)
if bool(self.repo.config["remote"]):
error_msg = (
@@ -52,69 +54,79 @@ class DataCloud:
raise NoRemoteError(error_msg)
def _init_remote(self, name):
from dvc.remote import get_remote
def _init_odb(self, name):
from dvc.fs import get_cloud_fs
from dvc.objects.db import get_odb
return get_remote(self.repo, name=name)
cls, config, path_info = get_cloud_fs(self.repo, name=name)
config["tmp_dir"] = self.repo.tmp_dir
return get_odb(cls(**config), path_info, **config)
def push(
self,
objs: Iterable["HashFile"],
objs: Iterable["HashInfo"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
odb: Optional["ObjectDB"] = None,
):
"""Push data items in a cloud-agnostic way.
Args:
objs: objects to push to the cloud.
jobs: number of jobs that can be running simultaneously.
remote: optional remote to push to.
remote: optional name of remote to push to.
By default remote from core.remote config option is used.
show_checksums: show checksums instead of file names in
information messages.
odb: optional ODB to push to. Overrides remote.
"""
remote_obj = self.get_remote(remote, "push")
from dvc.objects.transfer import transfer
return remote_obj.push(
if not odb:
odb = self.get_remote_odb(remote, "push")
return transfer(
self.repo.odb.local,
odb,
objs,
jobs=jobs,
show_checksums=show_checksums,
dest_index=get_index(odb),
cache_odb=self.repo.odb.local,
)
def pull(
self,
objs: Iterable["HashFile"],
objs: Iterable["HashInfo"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
odb: Optional["ObjectDB"] = None,
):
"""Pull data items in a cloud-agnostic way.
Args:
objs: objects to pull from the cloud.
jobs: number of jobs that can be running simultaneously.
remote: optional remote to pull from.
remote: optional name of remote to pull from.
By default remote from core.remote config option is used.
show_checksums: show checksums instead of file names in
information messages.
odb: optional ODB to pull from. Overrides remote.
"""
remote_obj = self.get_remote(remote, "pull")
from dvc.objects.transfer import transfer
return remote_obj.pull(
if not odb:
odb = self.get_remote_odb(remote, "pull")
return transfer(
odb,
self.repo.odb.local,
objs,
jobs=jobs,
show_checksums=show_checksums,
src_index=get_index(odb),
cache_odb=self.repo.odb.local,
verify=odb.verify,
)
def status(
self,
objs: Iterable["HashFile"],
objs: Iterable["HashInfo"],
jobs: Optional[int] = None,
remote: Optional[str] = None,
show_checksums: bool = False,
odb: Optional["ObjectDB"] = None,
log_missing: bool = True,
):
"""Check status of data items in a cloud-agnostic way.
@@ -125,20 +137,24 @@ class DataCloud:
remote: optional remote to compare
cache to. By default remote from core.remote config option
is used.
show_checksums: show checksums instead of file names in
information messages.
odb: optional ODB to check status from. Overrides remote.
log_missing: log warning messages if file doesn't exist
neither in cache, neither in cloud.
"""
remote_obj = self.get_remote(remote, "status")
return remote_obj.status(
from dvc.objects.status import compare_status
if not odb:
odb = self.get_remote_odb(remote, "status")
return compare_status(
self.repo.odb.local,
odb,
objs,
jobs=jobs,
show_checksums=show_checksums,
log_missing=log_missing,
dest_index=get_index(odb),
cache_odb=self.repo.odb.local,
)
def get_url_for(self, remote, checksum):
remote_obj = self.get_remote(remote)
return str(remote_obj.odb.hash_to_path_info(checksum))
remote_odb = self.get_remote_odb(remote)
return str(remote_odb.hash_to_path_info(checksum))
+1 -1
View File
@@ -98,7 +98,7 @@ class ParamsDependency(Dependency):
f"Unable to read parameters from '{self}'"
) from exc
def read_params_d(self):
def read_params_d(self, **kwargs):
config = self._read()
ret = {}
+75 -54
View File
@@ -1,6 +1,7 @@
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, Optional, Set
from copy import copy
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
from voluptuous import Required
@@ -9,6 +10,7 @@ from dvc.path_info import PathInfo
from .base import Dependency
if TYPE_CHECKING:
from dvc.hash_info import HashInfo
from dvc.objects.db.base import ObjectDB
from dvc.objects.file import HashFile
@@ -40,7 +42,7 @@ class RepoDependency(Dependency):
return False
def __str__(self):
return "{} ({})".format(self.def_path, self.def_repo[self.PARAM_URL])
return f"{self.def_path} ({self.def_repo[self.PARAM_URL]})"
def workspace_status(self):
current = self.get_obj(locked=True).hash_info
@@ -62,16 +64,11 @@ class RepoDependency(Dependency):
def download(self, to, jobs=None):
from dvc.checkout import checkout
from dvc.objects import save
from dvc.objects.db.git import GitObjectDB
from dvc.repo.fetch import fetch_from_odb
for odb, objs in self.get_used_objs().items():
if not isinstance(odb, GitObjectDB):
fetch_from_odb(self.repo, odb, objs, jobs=jobs)
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
obj = self.get_obj()
save(self.repo.odb.local, obj, jobs=jobs)
checkout(
to.path_info,
to.fs,
@@ -95,39 +92,47 @@ class RepoDependency(Dependency):
def get_used_objs(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
used, _ = self._get_used_and_obj(**kwargs)
return used
def _get_used_and_obj(
self, obj_only=False, **kwargs
) -> Tuple[Dict[Optional["ObjectDB"], Set["HashInfo"]], "HashFile"]:
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputOrStageError, PathMissingError
from dvc.objects.db.git import GitObjectDB
from dvc.objects.stage import stage
from dvc.objects.tree import Tree
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:
used_objs = defaultdict(set)
used_obj_ids = defaultdict(set)
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
path_info = PathInfo(repo.root_dir) / str(self.def_path)
try:
for odb, objs in repo.used_objs(
[os.fspath(path_info)],
force=True,
jobs=kwargs.get("jobs"),
recursive=True,
).items():
if odb is None:
odb = repo.cloud.get_remote().odb
self._check_circular_import(odb)
used_objs[odb].update(objs)
except (NoRemoteError, NoOutputOrStageError):
pass
if not obj_only:
try:
for odb, obj_ids in repo.used_objs(
[os.fspath(path_info)],
force=True,
jobs=kwargs.get("jobs"),
recursive=True,
).items():
if odb is None:
odb = repo.cloud.get_remote_odb()
odb.read_only = True
self._check_circular_import(odb, obj_ids)
used_obj_ids[odb].update(obj_ids)
except (NoRemoteError, NoOutputOrStageError):
pass
try:
staged_obj = stage(
staging, staged_obj = stage(
local_odb,
path_info,
repo.repo_fs,
@@ -137,45 +142,61 @@ class RepoDependency(Dependency):
raise PathMissingError(
self.def_path, self.def_repo[self.PARAM_URL]
) from exc
staging = copy(staging)
staging.read_only = True
self._staged_objs[rev] = staged_obj
git_odb = GitObjectDB(repo.repo_fs, repo.root_dir)
used_objs[git_odb].add(staged_obj)
return used_objs
used_obj_ids[staging].add(staged_obj.hash_info)
if isinstance(staged_obj, Tree):
used_obj_ids[staging].update(
entry.hash_info for _, entry in staged_obj
)
return used_obj_ids, staged_obj
def _check_circular_import(self, odb):
def _check_circular_import(self, odb, obj_ids):
from dvc.exceptions import CircularImportError
from dvc.fs.repo import RepoFileSystem
from dvc.objects.db.reference import ReferenceObjectDB
from dvc.objects.tree import Tree
if not odb or not isinstance(odb.fs, RepoFileSystem):
if not isinstance(odb, ReferenceObjectDB):
return
self_url = self.repo.url or self.repo.root_dir
if odb.fs.repo_url is not None and odb.fs.repo_url == self_url:
raise CircularImportError(self, odb.fs.repo_url, self_url)
def iter_objs():
for hash_info in obj_ids:
if hash_info.isdir:
tree = Tree.load(odb, hash_info)
yield from (odb.get(entry.hash_info) for _, entry in tree)
else:
yield odb.get(hash_info)
checked_urls = set()
for obj in iter_objs():
if not isinstance(obj.fs, RepoFileSystem):
continue
if (
obj.fs.repo_url in checked_urls
or obj.fs.root_dir in checked_urls
):
continue
self_url = self.repo.url or self.repo.root_dir
if (
obj.fs.repo_url is not None
and obj.fs.repo_url == self_url
or obj.fs.root_dir == self.repo.root_dir
):
raise CircularImportError(self, obj.fs.repo_url, self_url)
checked_urls.update([obj.fs.repo_url, obj.fs.root_dir])
def get_obj(self, filter_info=None, **kwargs):
from dvc.objects.stage import stage
odb = self.repo.odb.local
locked = kwargs.pop("locked", True)
with self._make_repo(locked=locked, cache_dir=odb.cache_dir) as repo:
rev = repo.get_rev()
if locked and self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = rev
obj = self._staged_objs.get(rev)
if obj is not None:
return obj
path_info = PathInfo(repo.root_dir) / str(self.def_path)
obj = stage(
odb,
path_info,
repo.repo_fs,
odb.fs.PARAM_CHECKSUM,
)
self._staged_objs[rev] = obj
return obj
locked = kwargs.get("locked", True)
rev = self._get_rev(locked=locked)
if rev in self._staged_objs:
return self._staged_objs[rev]
_, obj = self._get_used_and_obj(
obj_only=True, filter_info=filter_info, **kwargs
)
return obj
def _make_repo(self, locked=True, **kwargs):
from dvc.external_repo import external_repo
+2 -4
View File
@@ -200,9 +200,7 @@ class SingleStageFile(FileMixin):
assert not isinstance(stage, PipelineStage)
if self.verify:
check_dvcfile_path(self.repo, self.path)
logger.debug(
"Saving information to '{file}'.".format(file=relpath(self.path))
)
logger.debug(f"Saving information to '{relpath(self.path)}'.")
dump_yaml(self.path, serialize.to_single_stage_file(stage))
self.repo.scm.track_file(self.relpath)
@@ -300,7 +298,7 @@ class PipelineFile(FileMixin):
if not self.exists():
return
with open(self.path, "r", encoding="utf-8") as f:
with open(self.path, encoding="utf-8") as f:
d = parse_yaml_for_update(f.read(), self.path)
self.validate(d, self.path)
+3
View File
@@ -7,3 +7,6 @@ DVCLIVE_SUMMARY = "DVCLIVE_SUMMARY"
DVCLIVE_HTML = "DVCLIVE_HTML"
DVCLIVE_RESUME = "DVCLIVE_RESUME"
DVC_IGNORE_ISATTY = "DVC_IGNORE_ISATTY"
DVC_EXP_GIT_REMOTE = "DVC_EXP_GIT_REMOTE"
DVC_EXP_AUTO_PUSH = "DVC_EXP_AUTO_PUSH"
DVC_NO_ANALYTICS = "DVC_NO_ANALYTICS"
+9 -37
View File
@@ -1,5 +1,4 @@
"""Exceptions raised by the dvc."""
from typing import List
class DvcException(Exception):
@@ -173,36 +172,6 @@ class BadMetricError(DvcException):
)
class MetricsError(DvcException):
pass
class NoMetricsParsedError(MetricsError):
def __init__(self, command):
super().__init__(
f"Could not parse {command} files. Use `-v` option to see more "
"details."
)
class NoMetricsFoundError(MetricsError):
def __init__(self, command, run_options):
super().__init__(
f"No {command} files in this repository. "
f"Use `{run_options}` options for "
f"`dvc run` to mark stage outputs as {command}."
)
class MetricDoesNotExistError(MetricsError):
def __init__(self, targets: List[str]):
if len(targets) == 1:
msg = "'{}' does not exist."
else:
msg = "'{}' do not exist."
super().__init__(msg.format(", ".join(targets)))
class RecursiveAddingWhileUsingFilename(DvcException):
def __init__(self):
super().__init__(
@@ -259,18 +228,21 @@ class GitHookAlreadyExistsError(DvcException):
)
class DownloadError(DvcException):
class FileTransferError(DvcException):
_METHOD = "transfer"
def __init__(self, amount):
self.amount = amount
super().__init__(f"{amount} files failed to download")
super().__init__(f"{amount} files failed to {self._METHOD}")
class UploadError(DvcException):
def __init__(self, amount):
self.amount = amount
class DownloadError(FileTransferError):
_METHOD = "download"
super().__init__(f"{amount} files failed to upload")
class UploadError(FileTransferError):
_METHOD = "upload"
class CheckoutError(DvcException):
+13 -9
View File
@@ -42,14 +42,6 @@ def external_repo(
config = _get_remote_config(url) if os.path.isdir(url) else {}
config.update(cache_config)
def make_repo(path, **_kwargs):
_config = cache_config.copy()
if os.path.isdir(url):
rel = os.path.relpath(path, _kwargs["scm"].root_dir)
repo_path = os.path.join(url, rel)
_config.update(_get_remote_config(repo_path))
return Repo(path, config=_config, **_kwargs)
root_dir = path if for_write else os.path.realpath(path)
repo_kwargs = dict(
root_dir=root_dir,
@@ -57,7 +49,7 @@ def external_repo(
scm=None if for_write else Git(root_dir),
rev=None if for_write else rev,
config=config,
repo_factory=make_repo,
repo_factory=erepo_factory(url, cache_config),
**kwargs,
)
@@ -87,6 +79,18 @@ def external_repo(
_remove(path)
def erepo_factory(url, cache_config, *args, **kwargs):
def make_repo(path, **_kwargs):
_config = cache_config.copy()
if os.path.isdir(url):
rel = os.path.relpath(path, _kwargs["scm"].root_dir)
repo_path = os.path.join(url, rel)
_config.update(_get_remote_config(repo_path))
return Repo(path, config=_config, **_kwargs)
return make_repo
CLONES: Dict[str, str] = {}
CACHE_DIRS: Dict[str, str] = {}
+9 -3
View File
@@ -32,15 +32,21 @@ FS_MAP = {
}
def get_fs_cls(remote_conf):
scheme = urlparse(remote_conf["url"]).scheme
def get_fs_cls(remote_conf, scheme=None):
if not scheme:
scheme = urlparse(remote_conf["url"]).scheme
return FS_MAP.get(scheme, LocalFileSystem)
def get_fs_config(config, **kwargs):
name = kwargs.get("name")
if name:
remote_conf = config["remote"][name.lower()]
try:
remote_conf = config["remote"][name.lower()]
except KeyError:
from dvc.config import RemoteNotFoundError
raise RemoteNotFoundError(f"remote '{name}' doesn't exist")
else:
remote_conf = kwargs
return _resolve_remote_refs(config, remote_conf)
+18 -57
View File
@@ -1,10 +1,8 @@
import asyncio
import logging
import os
import sys
import threading
from contextlib import contextmanager
from fsspec.asyn import fsspec_loop
from fsspec.utils import infer_storage_options
from funcy import cached_property, memoize, wrap_prop
@@ -23,47 +21,6 @@ _DEFAULT_CREDS_STEPS = (
)
@contextmanager
def _temp_event_loop():
"""When trying to initialize azure filesystem instances
with DefaultCredentials, the authentication process requires
to have an access to a separate event loop. The normal calls
are run in a separate loop managed by the fsspec, but the
DefaultCredentials assumes that the callee is managing their
own event loop. This function checks whether is there any
event loop set, and if not it creates a temporary event loop
and set it. After the context is finalized, it restores the
original event loop back (if there is any)."""
try:
original_loop = asyncio.get_event_loop()
original_policy = asyncio.get_event_loop_policy()
except RuntimeError:
original_loop = None
original_policy = None
# From 3.8>= and onwards, asyncio changed the default
# loop policy for windows to use proactor loops instead
# of selector based ones. Due to that, proxied connections
# doesn't work with the aiohttp and this is most likely an
# upstream bug that needs to be solved outside of DVC. Until
# such issue is resolved, we need to manage this;
# https://github.com/aio-libs/aiohttp/issues/4536
if sys.version_info >= (3, 8) and os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
loop = original_loop or asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
yield
finally:
if original_loop is None:
loop.close()
asyncio.set_event_loop(original_loop)
asyncio.set_event_loop_policy(original_policy)
class AzureAuthError(DvcException):
pass
@@ -149,9 +106,22 @@ class AzureFileSystem(ObjectFSWrapper):
and not any_secondary
and not config.get("allow_anonymous_login", False)
):
login_info["credential"] = DefaultAzureCredential(
exclude_interactive_browser_credential=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"]),
@@ -182,19 +152,10 @@ class AzureFileSystem(ObjectFSWrapper):
from azure.core.exceptions import AzureError
try:
with _temp_event_loop():
file_system = AzureBlobFileSystem(**self.fs_args)
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
return file_system
def open(
self, path_info, mode="r", **kwargs
): # pylint: disable=arguments-differ
with _temp_event_loop():
return self.fs.open(self._with_bucket(path_info), mode=mode)
+6 -1
View File
@@ -59,6 +59,11 @@ class BaseFileSystem:
self.jobs = kwargs.get("jobs") or self._JOBS
self.hash_jobs = kwargs.get("checksum_jobs") or self.HASH_JOBS
self._config = kwargs
@property
def config(self):
return self._config
@classmethod
def _strip_protocol(cls, path: str):
@@ -187,7 +192,7 @@ class BaseFileSystem:
def remove(self, path_info):
raise RemoteActionNotImplemented("remove", self.scheme)
def makedirs(self, path_info):
def makedirs(self, path_info, **kwargs):
"""Optional: Implement only if the remote needs to create
directories before copying/linking/moving data
"""
+26 -12
View File
@@ -32,6 +32,10 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
super().__init__(**kwargs)
self.repo = kwargs["repo"]
@property
def config(self):
raise NotImplementedError
def _find_outs(self, path, *args, **kwargs):
outs = self.repo.find_outs_by_path(path, *args, **kwargs)
@@ -51,14 +55,14 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
# NOTE: use string paths here for performance reasons
key = tuple(relpath(path_info, out.path_info).split(os.sep))
out.get_dir_cache(remote=remote)
if out.obj is None:
raise FileNotFoundError
obj = out.obj.trie.get(key)
if obj:
return obj.hash_info
raise FileNotFoundError
def open( # type: ignore
self, path: PathInfo, mode="r", encoding=None, remote=None, **kwargs
): # pylint: disable=arguments-differ
def _get_fs_path(self, path: PathInfo, remote=None):
try:
outs = self._find_outs(path, strict=False)
except OutputNotFoundError as exc:
@@ -78,33 +82,37 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
from dvc.config import NoRemoteError
try:
remote_obj = self.repo.cloud.get_remote(remote)
remote_odb = self.repo.cloud.get_remote_odb(remote)
except NoRemoteError as exc:
raise FileNotFoundError from exc
if out.is_dir_checksum:
checksum = self._get_granular_hash(path, out).value
else:
checksum = out.hash_info.value
remote_info = remote_obj.odb.hash_to_path_info(checksum)
return remote_obj.fs.open(
remote_info, mode=mode, encoding=encoding
)
remote_info = remote_odb.hash_to_path_info(checksum)
return remote_odb.fs, remote_info
if out.is_dir_checksum:
checksum = self._get_granular_hash(path, out).value
cache_path = out.odb.hash_to_path_info(checksum).url
else:
cache_path = out.cache_path
return open(cache_path, mode=mode, encoding=encoding)
return out.odb.fs, cache_path
def exists(self, path): # pylint: disable=arguments-differ
def open( # type: ignore
self, path: PathInfo, mode="r", encoding=None, **kwargs
): # pylint: disable=arguments-renamed
fs, fspath = self._get_fs_path(path, **kwargs)
return fs.open(fspath, mode=mode, encoding=encoding)
def exists(self, path): # pylint: disable=arguments-renamed
try:
self.metadata(path)
return True
except FileNotFoundError:
return False
def isdir(self, path): # pylint: disable=arguments-differ
def isdir(self, path): # pylint: disable=arguments-renamed
try:
meta = self.metadata(path)
return meta.isdir
@@ -127,7 +135,7 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
except FileNotFoundError:
return True
def isfile(self, path): # pylint: disable=arguments-differ
def isfile(self, path): # pylint: disable=arguments-renamed
try:
meta = self.metadata(path)
return meta.isfile
@@ -247,3 +255,9 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
ret[obj.hash_info.name] = obj.hash_info.value
return ret
def _download(self, from_info, to_file, **kwargs):
fs, path = self._get_fs_path(from_info)
fs._download( # pylint: disable=protected-access
path, to_file, **kwargs
)
+89 -14
View File
@@ -7,6 +7,7 @@ from funcy import cached_property
from dvc.progress import Tqdm
from .base import BaseFileSystem
from .local import LocalFileSystem
# pylint: disable=no-member
@@ -22,21 +23,20 @@ class FSSpecWrapper(BaseFileSystem):
def fs(self):
raise NotImplementedError
@lru_cache(512)
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return f"{path.bucket}/{path.path}"
return path
return str(path)
def _strip_bucket(self, entry):
try:
bucket, path = entry.split("/", 1)
except ValueError:
# If there is no path attached, only returns
# the bucket (top-level).
bucket, path = entry, None
return entry
return path or bucket
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.utils import infer_storage_options
options = infer_storage_options(urlpath)
options.pop("path", None)
options.pop("protocol", None)
return options
def _strip_buckets(self, entries, detail=False):
for entry in entries:
@@ -86,6 +86,7 @@ class FSSpecWrapper(BaseFileSystem):
return self.fs.open(self._with_bucket(path_info), mode=mode)
def copy(self, from_info, to_info):
self.makedirs(to_info.parent)
self.fs.copy(self._with_bucket(from_info), self._with_bucket(to_info))
def exists(self, path_info) -> bool:
@@ -109,8 +110,11 @@ class FSSpecWrapper(BaseFileSystem):
for file in self.find(path_info, **kwargs):
yield path_info.replace(path=file)
def move(self, from_info, to_info):
self.fs.move(self._with_bucket(from_info), self._with_bucket(to_info))
def remove(self, path_info):
self.fs.rm(self._with_bucket(path_info))
self.fs.rm_file(self._with_bucket(path_info))
def info(self, path_info):
info = self.fs.info(self._with_bucket(path_info)).copy()
@@ -118,18 +122,25 @@ class FSSpecWrapper(BaseFileSystem):
info["name"] = self._strip_bucket(info["name"])
return info
def makedirs(self, path_info, **kwargs):
self.fs.makedirs(
self._with_bucket(path_info), exist_ok=kwargs.pop("exist_ok", True)
)
def _upload_fobj(self, fobj, to_info, size=None):
self.makedirs(to_info.parent)
with self.open(to_info, "wb") as fdest:
shutil.copyfileobj(fobj, fdest, length=fdest.blocksize)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **kwargs
):
self.makedirs(to_info.parent)
size = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
self._with_bucket(to_info),
to_info,
size=size,
desc=name,
no_progress_bar=no_progress_bar,
@@ -158,6 +169,29 @@ class FSSpecWrapper(BaseFileSystem):
class ObjectFSWrapper(FSSpecWrapper):
TRAVERSE_PREFIX_LEN = 3
@lru_cache(512)
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return f"{path.bucket}/{path.path}"
return path
def _strip_bucket(self, entry):
try:
bucket, path = entry.split("/", 1)
except ValueError:
# If there is no path attached, only returns
# the bucket (top-level).
bucket, path = entry, None
return path or bucket
def makedirs(self, path_info, **kwargs):
# 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_info):
# Directory in object storages are interpreted differently
# among different fsspec providers, so this logic is a temporary
@@ -183,7 +217,7 @@ class ObjectFSWrapper(FSSpecWrapper):
files = self.fs.find(path, detail=detail)
if detail:
files = files.values()
files = list(files.values())
# When calling find() on a file, it returns the same file in a list.
# For object-based storages, the same behavior applies to empty
@@ -194,3 +228,44 @@ class ObjectFSWrapper(FSSpecWrapper):
return None
yield from self._strip_buckets(files, detail=detail)
_LOCAL_FS = LocalFileSystem()
class CallbackMixin:
"""Use the native ``get_file()``/``put_file()`` APIs
if the target filesystem supports callbacks."""
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **pbar_args
):
with Tqdm(
desc=name,
disable=no_progress_bar,
bytes=True,
total=-1,
**pbar_args,
) as pbar:
self.fs.put_file(
os.fspath(from_file),
self._with_bucket(to_info),
callback=pbar.as_callback(_LOCAL_FS, from_file),
)
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
):
with Tqdm(
desc=name,
disable=no_progress_bar,
bytes=True,
total=-1,
**pbar_args,
) as pbar:
self.fs.get_file(
self._with_bucket(from_info),
os.fspath(to_file),
callback=pbar.as_callback(self, from_info),
)
+19 -364
View File
@@ -1,25 +1,18 @@
import errno
import io
import logging
import os
import posixpath
import re
import threading
from collections import defaultdict
from contextlib import contextmanager
from urllib.parse import urlparse
from funcy import cached_property, retry, wrap_prop, wrap_with
from funcy.py3 import cat
from funcy import cached_property, retry, wrap_prop
from dvc.exceptions import DvcException
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from dvc.utils import format_link, tmp_fname
from dvc.utils.stream import IterStream
from .base import BaseFileSystem
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
logger = logging.getLogger(__name__)
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
@@ -86,7 +79,9 @@ class GDriveURLInfo(CloudURLInfo):
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
class GDriveFileSystem(
CallbackMixin, FSSpecWrapper
): # pylint:disable=abstract-method
scheme = Schemes.GDRIVE
PATH_CLS = GDriveURLInfo
PARAM_CHECKSUM = "checksum"
@@ -214,9 +209,9 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
@wrap_prop(threading.RLock())
@cached_property
def _drive(self):
def fs(self):
from pydrive2.auth import GoogleAuth
from pydrive2.drive import GoogleDrive
from pydrive2.fs import GDriveFileSystem as _GDriveFileSystem
temporary_save_path = self._gdrive_user_credentials_path
is_credentials_temp = os.getenv(
@@ -291,359 +286,19 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
if is_credentials_temp:
os.remove(temporary_save_path)
return GoogleDrive(gauth)
@wrap_prop(threading.RLock())
@cached_property
def _ids_cache(self):
cache = {
"dirs": defaultdict(list),
"ids": {},
"root_id": self._get_item_id(
self.path_info,
use_cache=False,
hint="Confirm the directory exists and you can access it.",
),
}
self._cache_path_id(self._path, cache["root_id"], cache=cache)
for item in self._gdrive_list(
"'{}' in parents and trashed=false".format(cache["root_id"])
):
item_path = (self.path_info / item["title"]).path
self._cache_path_id(item_path, item["id"], cache=cache)
return cache
def _cache_path_id(self, path, *item_ids, cache=None):
cache = cache or self._ids_cache
for item_id in item_ids:
cache["dirs"][path].append(item_id)
cache["ids"][item_id] = path
@cached_property
def _list_params(self):
params = {"corpora": "default"}
if self._bucket != "root" and self._bucket != "appDataFolder":
drive_id = self._gdrive_shared_drive_id(self._bucket)
if drive_id:
logger.debug(
"GDrive remote '{}' is using shared drive id '{}'.".format(
self.path_info, drive_id
)
)
params["driveId"] = drive_id
params["corpora"] = "drive"
return params
@_gdrive_retry
def _gdrive_shared_drive_id(self, item_id):
from pydrive2.files import ApiRequestError
param = {"id": item_id}
# it does not create a file on the remote
item = self._drive.CreateFile(param)
# ID of the shared drive the item resides in.
# Only populated for items in shared drives.
try:
item.FetchMetadata("driveId")
except ApiRequestError as exc:
error_code = exc.error.get("code", 0)
if error_code == 404:
raise DvcException(
"'{}' for '{}':\n\n"
"1. Confirm the directory exists and you can access it.\n"
"2. Make sure that credentials in '{}'\n"
" are correct for this remote e.g. "
"use the `gdrive_user_credentials_file` config\n"
" option if you use multiple GDrive remotes with "
"different email accounts.\n\nDetails".format(
item_id, self.path_info, self.credentials_location
)
) from exc
raise
return item.get("driveId", None)
@_gdrive_retry
def _gdrive_upload_fobj(self, fobj, parent_id, title):
item = self._drive.CreateFile(
{"title": title, "parents": [{"id": parent_id}]}
)
item.content = fobj
item.Upload()
return item
@_gdrive_retry
def _gdrive_download_file(
self, item_id, to_file, progress_desc, no_progress_bar
):
param = {"id": item_id}
# it does not create a file on the remote
gdrive_file = self._drive.CreateFile(param)
with Tqdm(
desc=progress_desc,
disable=no_progress_bar,
bytes=True,
# explicit `bar_format` as `total` will be set by `update_to`
bar_format=Tqdm.BAR_FMT_DEFAULT,
) as pbar:
gdrive_file.GetContentFile(to_file, callback=pbar.update_to)
@contextmanager
@_gdrive_retry
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb"}
item_id = self._get_item_id(path_info)
param = {"id": item_id}
# it does not create a file on the remote
gdrive_file = self._drive.CreateFile(param)
fd = gdrive_file.GetContentIOBuffer()
stream = IterStream(iter(fd))
if mode != "rb":
stream = io.TextIOWrapper(stream, encoding=encoding)
yield stream
@_gdrive_retry
def gdrive_delete_file(self, item_id):
from pydrive2.files import ApiRequestError
param = {"id": item_id}
# it does not create a file on the remote
item = self._drive.CreateFile(param)
try:
item.Trash() if self._trash_only else item.Delete()
except ApiRequestError as exc:
http_error_code = exc.error.get("code", 0)
if (
http_error_code == 403
and self._list_params["corpora"] == "drive"
and exc.GetField("location") == "file.permissions"
):
raise DvcException(
"Insufficient permissions to {}. You should have {} "
"access level for the used shared drive. More details "
"at {}.".format(
"move the file into Trash"
if self._trash_only
else "permanently delete the file",
"Manager or Content Manager"
if self._trash_only
else "Manager",
"https://support.google.com/a/answer/7337554",
)
) from exc
raise
def _gdrive_list(self, query):
param = {"q": query, "maxResults": 1000}
param.update(self._list_params)
file_list = self._drive.ListFile(param)
# Isolate and decorate fetching of remote drive items in pages.
get_list = _gdrive_retry(lambda: next(file_list, None))
# Fetch pages until None is received, lazily flatten the thing.
return cat(iter(get_list, None))
@_gdrive_retry
def _gdrive_create_dir(self, parent_id, title):
parent = {"id": parent_id}
item = self._drive.CreateFile(
{"title": title, "parents": [parent], "mimeType": FOLDER_MIME_TYPE}
)
item.Upload()
return item
@wrap_with(threading.RLock())
def _create_dir(self, parent_id, title, remote_path):
cached = self._ids_cache["dirs"].get(remote_path)
if cached:
return cached[0]
item = self._gdrive_create_dir(parent_id, title)
if parent_id == self._ids_cache["root_id"]:
self._cache_path_id(remote_path, item["id"])
return item["id"]
def _get_remote_item_ids(self, parent_ids, title):
if not parent_ids:
return None
query = "trashed=false and ({})".format(
" or ".join(
f"'{parent_id}' in parents" for parent_id in parent_ids
)
)
query += " and title='{}'".format(title.replace("'", "\\'"))
# GDrive list API is case insensitive, we need to compare
# all results and pick the ones with the right title
return [
item["id"]
for item in self._gdrive_list(query)
if item["title"] == title
]
def _get_cached_item_ids(self, path, use_cache):
if not path:
return [self._bucket]
if use_cache:
return self._ids_cache["dirs"].get(path, [])
return []
def _path_to_item_ids(self, path, create=False, use_cache=True):
item_ids = self._get_cached_item_ids(path, use_cache)
if item_ids:
return item_ids
parent_path, title = posixpath.split(path)
parent_ids = self._path_to_item_ids(parent_path, create, use_cache)
item_ids = self._get_remote_item_ids(parent_ids, title)
if item_ids:
return item_ids
return (
[self._create_dir(min(parent_ids), title, path)] if create else []
return _GDriveFileSystem(
self._with_bucket(self.path_info),
gauth,
trash_only=self._trash_only,
)
def _get_item_id(self, path_info, create=False, use_cache=True, hint=None):
assert path_info.bucket == self._bucket
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return posixpath.join(path.bucket, path.path)
item_ids = self._path_to_item_ids(path_info.path, create, use_cache)
if item_ids:
return min(item_ids)
return super()._with_bucket(path)
assert not create
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), hint or path_info
)
def exists(self, path_info) -> bool:
try:
self._get_item_id(path_info)
except FileNotFoundError:
return False
else:
return True
def _gdrive_list_ids(self, query_ids):
query = " or ".join(
f"'{query_id}' in parents" for query_id in query_ids
)
query = f"({query}) and trashed=false"
return self._gdrive_list(query)
def find(self, path_info, detail=False, prefix=None):
root_path = path_info.path
seen_paths = set()
dir_ids = [self._ids_cache["ids"].copy()]
while dir_ids:
query_ids = {
dir_id: dir_name
for dir_id, dir_name in dir_ids.pop().items()
if posixpath.commonpath([root_path, dir_name]) == root_path
if dir_id not in seen_paths
}
if not query_ids:
continue
seen_paths |= query_ids.keys()
new_query_ids = {}
dir_ids.append(new_query_ids)
for item in self._gdrive_list_ids(query_ids):
parent_id = item["parents"][0]["id"]
item_path = posixpath.join(query_ids[parent_id], item["title"])
if item["mimeType"] == FOLDER_MIME_TYPE:
new_query_ids[item["id"]] = item_path
self._cache_path_id(item_path, item["id"])
continue
if detail:
yield {
"type": "file",
"name": item_path,
"size": item["fileSize"],
"checksum": item["md5Checksum"],
}
else:
yield item_path
def ls(self, path_info, detail=False):
cached = path_info.path in self._ids_cache["dirs"]
if cached:
dir_ids = self._ids_cache["dirs"][path_info.path]
else:
dir_ids = self._path_to_item_ids(path_info.path)
if not dir_ids:
return None
root_path = path_info.path
for item in self._gdrive_list_ids(dir_ids):
item_path = posixpath.join(root_path, item["title"])
if detail:
if item["mimeType"] == FOLDER_MIME_TYPE:
yield {"type": "directory", "name": item_path}
else:
yield {
"type": "file",
"name": item_path,
"size": item["fileSize"],
"checksum": item["md5Checksum"],
}
else:
yield item_path
if not cached:
self._cache_path_id(root_path, *dir_ids)
def walk_files(self, path_info, **kwargs):
for file in self.find(path_info):
yield path_info.replace(path=file)
def remove(self, path_info):
item_id = self._get_item_id(path_info)
self.gdrive_delete_file(item_id)
def info(self, path_info):
item_id = self._get_item_id(path_info)
gdrive_file = self._drive.CreateFile({"id": item_id})
gdrive_file.FetchMetadata(fields="fileSize")
return {"size": gdrive_file.get("fileSize"), "type": "file"}
def _upload_fobj(self, fobj, to_info, **kwargs):
dirname = to_info.parent
assert dirname
parent_id = self._get_item_id(dirname, create=True)
self._gdrive_upload_fobj(fobj, parent_id, to_info.name)
def _upload(
self,
from_file,
to_info,
name=None,
no_progress_bar=False,
**_kwargs,
):
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
to_info,
size=os.path.getsize(from_file),
no_progress_bar=no_progress_bar,
desc=name or to_info.name,
)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
item_id = self._get_item_id(from_info)
self._gdrive_download_file(item_id, to_file, name, no_progress_bar)
def _upload_fobj(self, fobj, to_info, size: int = None):
rpath = self._with_bucket(to_info)
self.makedirs(os.path.dirname(rpath))
return self.fs.upload_fobj(fobj, rpath, size=size)
+16 -2
View File
@@ -36,8 +36,8 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
return tuple(relparts)
def open(
self, path, mode="r", encoding=None
): # pylint: disable=arguments-differ
self, path, mode="r", encoding=None, **kwargs
): # pylint: disable=arguments-renamed
# NOTE: this is incorrect, we need to use locale to determine default
# encoding.
encoding = encoding or "utf-8"
@@ -126,3 +126,17 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
for file in files:
# NOTE: os.path.join is ~5.5 times slower
yield f"{root}{os.sep}{file}"
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
):
import shutil
from dvc.progress import Tqdm
with open(to_file, "wb+") as to_fobj:
with Tqdm.wrapattr(
to_fobj, "write", desc=name, disable=no_progress_bar
) as wrapped:
with self.open(from_info, "rb", **kwargs) as from_fobj:
shutil.copyfileobj(from_fobj, wrapped)
+1 -1
View File
@@ -183,7 +183,7 @@ class HDFSFileSystem(BaseFileSystem):
else:
hdfs.delete_file(path_info.path)
def makedirs(self, path_info):
def makedirs(self, path_info, **kwargs):
with self.hdfs(path_info) as hdfs:
# NOTE: fs.create_dir creates parents by default
hdfs.create_dir(path_info.path)
+5 -3
View File
@@ -79,8 +79,8 @@ class LocalFileSystem(BaseFileSystem):
raise NotImplementedError
remove(path_info)
def makedirs(self, path_info):
makedirs(path_info, exist_ok=True)
def makedirs(self, path_info, **kwargs):
makedirs(path_info, exist_ok=kwargs.pop("exist_ok", True))
def isexec(self, path_info):
mode = self.stat(path_info).st_mode
@@ -90,7 +90,9 @@ class LocalFileSystem(BaseFileSystem):
return os.stat(path)
def move(self, from_info, to_info):
if from_info.scheme != "local" or to_info.scheme != "local":
if (
isinstance(from_info, PathInfo) and from_info.scheme != "local"
) or (isinstance(to_info, PathInfo) and to_info.scheme != "local"):
raise NotImplementedError
self.makedirs(to_info.parent)
+28 -27
View File
@@ -1,34 +1,35 @@
from .base import BaseFileSystem
import threading
from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from .fsspec_wrapper import FSSpecWrapper
class MemoryFileSystem(BaseFileSystem):
scheme = "local"
class MemoryFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
scheme = Schemes.MEMORY
PARAM_CHECKSUM = "md5"
PATH_CLS = CloudURLInfo
TRAVERSE_PREFIX_LEN = 2
DEFAULT_BLOCKSIZE = 4096
def __init__(self, **kwargs):
def __eq__(self, other):
# NOTE: all fsspec MemoryFileSystem instances are equivalent and use a
# single global store
return isinstance(other, type(self))
__hash__ = FSSpecWrapper.__hash__
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from fsspec.implementations.memory import MemoryFileSystem as MemFS
super().__init__(**kwargs)
return MemFS(**self.fs_args)
self.fs = MemFS()
def exists(self, path_info) -> bool:
return self.fs.exists(path_info.fspath)
def open(self, path_info, mode="r", encoding=None, **kwargs):
return self.fs.open(
path_info.fspath, mode=mode, encoding=encoding, **kwargs
)
def info(self, path_info):
return self.fs.info(path_info.fspath)
def stat(self, path_info):
import os
info = self.fs.info(path_info.fspath)
return os.stat_result((0, 0, 0, 0, 0, 0, info["size"], 0, 0, 0))
def walk_files(self, path_info, **kwargs):
raise NotImplementedError
def open(self, *args, **kwargs):
with super().open(*args, **kwargs) as fobj:
fobj.blocksize = self.DEFAULT_BLOCKSIZE
return fobj
+16 -106
View File
@@ -5,127 +5,37 @@ import threading
from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseFileSystem
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
logger = logging.getLogger(__name__)
class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""
oss2 document:
https://www.alibabacloud.com/help/doc-detail/32026.htm
Examples
----------
$ dvc remote add myremote oss://my-bucket/path
Set key id, key secret and endpoint using modify command
$ dvc remote modify myremote oss_key_id my-key-id
$ dvc remote modify myremote oss_key_secret my-key-secret
$ dvc remote modify myremote oss_endpoint endpoint
or environment variables
$ export OSS_ACCESS_KEY_ID="my-key-id"
$ export OSS_ACCESS_KEY_SECRET="my-key-secret"
$ export OSS_ENDPOINT="endpoint"
"""
# pylint:disable=abstract-method
class OSSFileSystem(CallbackMixin, ObjectFSWrapper):
scheme = Schemes.OSS
PATH_CLS = CloudURLInfo
REQUIRES = {"oss2": "oss2"}
REQUIRES = {"ossfs": "ossfs"}
PARAM_CHECKSUM = "etag"
COPY_POLL_SECONDS = 5
LIST_OBJECT_PAGE_SIZE = 100
DETAIL_FIELDS = frozenset(("etag", "size"))
def __init__(self, **config):
super().__init__(**config)
self.endpoint = config.get("oss_endpoint") or os.getenv("OSS_ENDPOINT")
self.key_id = (
config.get("oss_key_id")
or os.getenv("OSS_ACCESS_KEY_ID")
or "defaultId"
def _prepare_credentials(self, **config):
login_info = {}
login_info["key"] = config.get("oss_key_id") or os.getenv(
"OSS_ACCESS_KEY_ID"
)
self.key_secret = (
config.get("oss_key_secret")
or os.getenv("OSS_ACCESS_KEY_SECRET")
or "defaultSecret"
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 oss_service(self):
import oss2
def fs(self):
from ossfs import OSSFileSystem as _OSSFileSystem
logger.debug(f"key id: {self.key_id}")
logger.debug(f"key secret: {self.key_secret}")
return oss2.Auth(self.key_id, self.key_secret)
def _get_bucket(self, bucket):
import oss2
return oss2.Bucket(self.oss_service, self.endpoint, bucket)
def _generate_download_url(self, path_info, expires=3600):
return self._get_bucket(path_info.bucket).sign_url(
"GET", path_info.path, expires
)
def exists(self, path_info) -> bool:
paths = self._list_paths(path_info)
return any(path_info.path == path for path in paths)
def _list_paths(self, path_info):
import oss2
for blob in oss2.ObjectIterator(
self._get_bucket(path_info.bucket), prefix=path_info.path
):
yield blob.key
def walk_files(self, path_info, **kwargs):
if not kwargs.pop("prefix", False):
path_info = path_info / ""
for fname in self._list_paths(path_info):
if fname.endswith("/"):
continue
yield path_info.replace(path=fname)
def remove(self, path_info):
if path_info.scheme != self.scheme:
raise NotImplementedError
logger.debug(f"Removing oss://{path_info}")
self._get_bucket(path_info.bucket).delete_object(path_info.path)
def _upload_fobj(self, fobj, to_info, **kwargs):
self._get_bucket(to_info.bucket).put_object(to_info.path, fobj)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
bucket = self._get_bucket(to_info.bucket)
bucket.put_object_from_file(
to_info.path, from_file, progress_callback=pbar.update_to
)
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
import oss2
bucket = self._get_bucket(from_info.bucket)
oss2.resumable_download(
bucket,
from_info.path,
to_file,
progress_callback=pbar.update_to,
)
return _OSSFileSystem(**self.fs_args)
+101 -24
View File
@@ -35,14 +35,29 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
scheme = "local"
PARAM_CHECKSUM = "md5"
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=None, subrepos=False, repo_factory: RepoFactory = None
self,
repo: Optional["Repo"] = None,
subrepos=False,
repo_factory: RepoFactory = None,
**kwargs,
):
super().__init__()
from dvc.utils.collections import PathStringTrie
if repo is None:
repo, repo_factory = self._repo_from_fs_config(
subrepos=subrepos, **kwargs
)
if not repo_factory:
from dvc.repo import Repo
@@ -72,6 +87,67 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
return None
return self._main_repo.url
@property
def config(self):
return {
self.PARAM_REPO_URL: self.repo_url,
self.PARAM_REPO_ROOT: self.root_dir,
self.PARAM_REV: getattr(self._main_repo.fs, "rev", None),
self.PARAM_CACHE_DIR: os.path.abspath(
self._main_repo.odb.local.cache_dir
),
self.PARAM_CACHE_TYPES: self._main_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) -> Optional["Repo"]:
"""Returns repo that the path falls in, using prefix.
@@ -136,7 +212,7 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def open(
self, path, mode="r", encoding="utf-8", **kwargs
): # pylint: disable=arguments-differ
): # pylint: disable=arguments-renamed
if "b" in mode:
encoding = None
@@ -167,13 +243,13 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
except FileNotFoundError:
return False
(out,) = meta.outs
assert len(meta.outs) == 1
if fs.exists(out.path_info):
return False
for out in meta.outs:
if fs.exists(out.path_info):
return False
return True
def isdir(self, path): # pylint: disable=arguments-differ
def isdir(self, path): # pylint: disable=arguments-renamed
fs, dvc_fs = self._get_fs_pair(path)
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_dir(path):
@@ -194,17 +270,17 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
except FileNotFoundError:
return False
(out,) = meta.outs
assert len(meta.outs) == 1
if fs.exists(out.path_info):
return False
for out in meta.outs:
if fs.exists(out.path_info):
return False
return meta.isdir
def isdvc(self, path, **kwargs):
_, dvc_fs = self._get_fs_pair(path)
return dvc_fs is not None and dvc_fs.isdvc(path, **kwargs)
def isfile(self, path): # pylint: disable=arguments-differ
def isfile(self, path): # pylint: disable=arguments-renamed
fs, dvc_fs = self._get_fs_pair(path)
if dvc_fs and dvc_fs.repo.dvcignore.is_ignored_file(path):
@@ -377,19 +453,20 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
for fname in files:
yield PathInfo(root) / fname
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
):
import shutil
def _download(self, from_info, to_file, **kwargs):
fs, dvc_fs = self._get_fs_pair(from_info)
try:
fs._download( # pylint: disable=protected-access
from_info, to_file, **kwargs
)
return
except FileNotFoundError:
if not dvc_fs:
raise
from dvc.progress import Tqdm
with open(to_file, "wb+") as to_fobj:
with Tqdm.wrapattr(
to_fobj, "write", desc=name, disable=no_progress_bar
) as wrapped:
with self.open(from_info, "rb", **kwargs) as from_fobj:
shutil.copyfileobj(from_fobj, wrapped)
dvc_fs._download( # pylint: disable=protected-access
from_info, to_file, **kwargs
)
def metadata(self, path):
abspath = os.path.abspath(path)
+125
View File
@@ -0,0 +1,125 @@
import getpass
import os.path
import threading
from funcy import cached_property, first, memoize, silent, wrap_prop, wrap_with
from dvc import prompt
from dvc.scheme import Schemes
from dvc.utils.fs import as_atomic
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
_SSH_TIMEOUT = 60 * 30
_SSH_CONFIG_FILE = os.path.expanduser(os.path.join("~", ".ssh", "config"))
@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(CallbackMixin, FSSpecWrapper):
scheme = Schemes.SSH
REQUIRES = {"sshfs": "sshfs"}
DEFAULT_PORT = 22
PARAM_CHECKSUM = "md5"
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
path_str = path.path
else:
path_str = super()._with_bucket(path)
return os.path.normpath(path_str)
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 self.DEFAULT_PORT
)
login_info["password"] = config.get("password")
if user_ssh_config.get("IdentityFile"):
config.setdefault(
"keyfile", first(user_ssh_config.get("IdentityFile"))
)
keyfile = config.get("keyfile")
if keyfile:
login_info["client_keys"] = [os.path.expanduser(keyfile)]
login_info["timeout"] = config.get("timeout", _SSH_TIMEOUT)
# 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")
if config.get("ask_password") and login_info["password"] is None:
login_info["password"] = ask_password(
login_info["host"], login_info["username"], login_info["port"]
)
# 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, *args, **kwargs):
with as_atomic(self, to_info) as tmp_file:
super()._upload_fobj(fobj, tmp_file, *args, **kwargs)
def _upload(self, from_file, to_info, *args, **kwargs):
with as_atomic(self, to_info) as tmp_file:
super()._upload(from_file, tmp_file, *args, **kwargs)
-263
View File
@@ -1,263 +0,0 @@
import getpass
import io
import logging
import os
import shutil
import threading
from contextlib import closing, contextmanager
from funcy import first, memoize, silent, wrap_with
from dvc import prompt
from dvc.hash_info import HashInfo
from dvc.scheme import Schemes
from ..base import BaseFileSystem
from ..pool import get_connection
logger = logging.getLogger(__name__)
@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
)
)
class SSHFileSystem(BaseFileSystem): # pylint:disable=abstract-method
scheme = Schemes.SSH
REQUIRES = {"paramiko": "paramiko"}
_JOBS = 4
PARAM_CHECKSUM = "md5"
# At any given time some of the connections will go over network and
# paramiko stuff, so we would ideally have it double of server processors.
# We use conservative setting of 4 instead to not exhaust max sessions.
CHECKSUM_JOBS = 4
DEFAULT_CACHE_TYPES = ["copy"]
TRAVERSE_PREFIX_LEN = 2
DEFAULT_PORT = 22
TIMEOUT = 1800
def __init__(self, **config):
super().__init__(**config)
user_ssh_config = self._load_user_ssh_config(config["host"])
self.host = user_ssh_config.get("hostname", config["host"])
self.user = (
config.get("user")
or user_ssh_config.get("user")
or getpass.getuser()
)
self.port = (
config.get("port")
or self._try_get_ssh_config_port(user_ssh_config)
or self.DEFAULT_PORT
)
self.keyfile = config.get(
"keyfile"
) or self._try_get_ssh_config_keyfile(user_ssh_config)
self.timeout = config.get("timeout", self.TIMEOUT)
self.password = config.get("password", None)
self.ask_password = config.get("ask_password", False)
self.gss_auth = config.get("gss_auth", False)
proxy_command = user_ssh_config.get("proxycommand", False)
if proxy_command:
import paramiko
self.sock = paramiko.ProxyCommand(proxy_command)
else:
self.sock = None
self.allow_agent = config.get("allow_agent", True)
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.implementations.sftp import SFTPFileSystem
# pylint:disable=protected-access
kwargs = SFTPFileSystem._get_kwargs_from_urls(urlpath)
if "username" in kwargs:
kwargs["user"] = kwargs.pop("username")
return kwargs
@staticmethod
def ssh_config_filename():
return os.path.expanduser(os.path.join("~", ".ssh", "config"))
@staticmethod
def _load_user_ssh_config(hostname):
import paramiko
user_config_file = SSHFileSystem.ssh_config_filename()
user_ssh_config = {}
if hostname and os.path.exists(user_config_file):
ssh_config = paramiko.SSHConfig()
with open(user_config_file) as f:
# For whatever reason parsing directly from f is unreliable
f_copy = io.StringIO(f.read())
ssh_config.parse(f_copy)
user_ssh_config = ssh_config.lookup(hostname)
return user_ssh_config
@staticmethod
def _try_get_ssh_config_port(user_ssh_config):
return silent(int)(user_ssh_config.get("port"))
@staticmethod
def _try_get_ssh_config_keyfile(user_ssh_config):
return first(user_ssh_config.get("identityfile") or ())
def ensure_credentials(self):
# NOTE: we use the same password regardless of the server :(
if self.ask_password and self.password is None:
self.password = ask_password(self.host, self.user, self.port)
def ssh(self):
self.ensure_credentials()
from .connection import SSHConnection
return get_connection(
SSHConnection,
self.host,
username=self.user,
port=self.port,
key_filename=self.keyfile,
timeout=self.timeout,
password=self.password,
gss_auth=self.gss_auth,
sock=self.sock,
allow_agent=self.allow_agent,
)
@contextmanager
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb", "wb"}
with self.ssh() as ssh, closing(
ssh.sftp.open(path_info.path, mode)
) as fd:
if "b" in mode:
yield fd
else:
yield io.TextIOWrapper(fd, encoding=encoding)
def exists(self, path_info) -> bool:
with self.ssh() as ssh:
return ssh.exists(path_info.path)
def isdir(self, path_info):
with self.ssh() as ssh:
return ssh.isdir(path_info.path)
def isfile(self, path_info):
with self.ssh() as ssh:
return ssh.isfile(path_info.path)
def walk_files(self, path_info, **kwargs):
with self.ssh() as ssh:
for fname in ssh.walk_files(path_info.path):
yield path_info.replace(path=fname)
def remove(self, path_info):
if path_info.scheme != self.scheme:
raise NotImplementedError
with self.ssh() as ssh:
ssh.remove(path_info.path)
def makedirs(self, path_info):
with self.ssh() as ssh:
ssh.makedirs(path_info.path)
def move(self, from_info, to_info):
if from_info.scheme != self.scheme or to_info.scheme != self.scheme:
raise NotImplementedError
with self.ssh() as ssh:
ssh.move(from_info.path, to_info.path)
def copy(self, from_info, to_info):
if not from_info.scheme == to_info.scheme == self.scheme:
raise NotImplementedError
with self.ssh() as ssh:
ssh.atomic_copy(from_info.path, to_info.path)
def symlink(self, from_info, to_info):
if not from_info.scheme == to_info.scheme == self.scheme:
raise NotImplementedError
with self.ssh() as ssh:
ssh.symlink(from_info.path, to_info.path)
def hardlink(self, from_info, to_info):
if not from_info.scheme == to_info.scheme == self.scheme:
raise NotImplementedError
# See dvc/remote/local/__init__.py - hardlink()
if self.getsize(from_info) == 0:
with self.ssh() as ssh:
ssh.sftp.open(to_info.path, "w").close()
logger.debug(
"Created empty file: {src} -> {dest}".format(
src=str(from_info), dest=str(to_info)
)
)
return
with self.ssh() as ssh:
ssh.hardlink(from_info.path, to_info.path)
def reflink(self, from_info, to_info):
if from_info.scheme != self.scheme or to_info.scheme != self.scheme:
raise NotImplementedError
with self.ssh() as ssh:
ssh.reflink(from_info.path, to_info.path)
def md5(self, path_info):
with self.ssh() as ssh:
return HashInfo(
"md5",
ssh.md5(path_info.path),
size=ssh.getsize(path_info.path),
)
def info(self, path_info):
with self.ssh() as ssh:
return ssh.info(path_info.path)
def _upload_fobj(self, fobj, to_info, **kwargs):
self.makedirs(to_info.parent)
with self.open(to_info, mode="wb") as fdest:
shutil.copyfileobj(fobj, fdest)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
with self.ssh() as ssh:
ssh.download(
from_info.path,
to_file,
progress_title=name,
no_progress_bar=no_progress_bar,
)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
with self.ssh() as ssh:
ssh.upload(
from_file,
to_info.path,
progress_title=name,
no_progress_bar=no_progress_bar,
)
-356
View File
@@ -1,356 +0,0 @@
import errno
import logging
import os
import posixpath
import shlex
import stat
from contextlib import suppress
from funcy import cached_property
from dvc.exceptions import DvcException
from dvc.progress import Tqdm
from dvc.utils import tmp_fname
from ..base import RemoteCmdError
try:
import paramiko
except ImportError:
paramiko = None # type: ignore
logger = logging.getLogger(__name__)
def sizeof_fmt(num, suffix="B"):
"""Convert number of bytes to human-readable string"""
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return "{:.1f}{}{}".format(num, "Y", suffix)
class SSHConnection:
def __init__(self, host, *args, **kwargs):
logger.debug(
"Establishing ssh connection with '{host}' "
"through port '{port}' as user '{username}'".format(
host=host, **kwargs
)
)
kwargs.setdefault("timeout", 1800)
self.timeout = kwargs["timeout"]
self._ssh = paramiko.SSHClient()
# Explicitly disable paramiko logger. Due to how paramiko dynamically
# loads loggers, it is not disabled by DVC disable_other_loggers().
# See https://github.com/iterative/dvc/issues/3482
self._ssh.set_log_channel("dvc.paramiko")
logging.getLogger("dvc.paramiko").disabled = True
logging.getLogger("dvc.paramiko.sftp").disabled = True
self._ssh.load_system_host_keys()
self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self._ssh.connect(host, *args, **kwargs)
self._ssh.get_transport().set_keepalive(10)
self._sftp_channels = []
@property
def sftp(self):
if not self._sftp_channels:
self._sftp_channels = [self._ssh.open_sftp()]
return self._sftp_channels[0]
def close(self):
for sftp in self._sftp_channels:
sftp.close()
self._ssh.close()
def st_mode(self, path):
lstat = None
with suppress(FileNotFoundError):
lstat = self.sftp.lstat(path)
return lstat.st_mode if lstat else 0
def info(self, path):
st = self.sftp.stat(path)
return {
"size": st.st_size,
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
}
def getsize(self, path):
return self.info(path)["size"]
def exists(self, path):
return bool(self.st_mode(path))
def isdir(self, path):
return stat.S_ISDIR(self.st_mode(path))
def isfile(self, path):
return stat.S_ISREG(self.st_mode(path))
def islink(self, path):
return stat.S_ISLNK(self.st_mode(path))
def makedirs(self, path):
# Single stat call will say whether this is a dir, a file or a link
st_mode = self.st_mode(path)
if stat.S_ISDIR(st_mode):
return
if stat.S_ISREG(st_mode) or stat.S_ISLNK(st_mode):
raise DvcException(
f"a file with the same name '{path}' already exists"
)
head, tail = posixpath.split(path)
if head:
self.makedirs(head)
if tail:
try:
self.sftp.mkdir(path)
except OSError as exc:
# Since paramiko errors are very vague we need to recheck
# whether it's because path already exists or something else
if exc.errno == errno.EACCES or not self.exists(path):
raise DvcException(
f"unable to create remote directory '{path}'"
) from exc
def walk(self, directory, topdown=True):
# NOTE: original os.walk() implementation [1] with default options was
# used as a template.
#
# [1] https://github.com/python/cpython/blob/master/Lib/os.py
try:
dir_entries = self.sftp.listdir_attr(directory)
except OSError as exc:
raise DvcException(
"couldn't get the '{}' remote directory files list".format(
directory
)
) from exc
dirs = []
nondirs = []
for entry in dir_entries:
name = entry.filename
if stat.S_ISDIR(entry.st_mode):
dirs.append(name)
else:
nondirs.append(name)
if topdown:
yield directory, dirs, nondirs
for dname in dirs:
newpath = posixpath.join(directory, dname)
yield from self.walk(newpath, topdown=topdown)
if not topdown:
yield directory, dirs, nondirs
def walk_files(self, directory):
for root, _, files in self.walk(directory):
for fname in files:
yield posixpath.join(root, fname)
def _remove_file(self, path):
with suppress(FileNotFoundError):
self.sftp.remove(path)
def _remove_dir(self, path):
for root, dirs, files in self.walk(path, topdown=False):
for fname in files:
with suppress(FileNotFoundError):
self._remove_file(posixpath.join(root, fname))
for dname in dirs:
with suppress(FileNotFoundError):
self.sftp.rmdir(posixpath.join(root, dname))
with suppress(FileNotFoundError):
self.sftp.rmdir(path)
def remove(self, path):
if self.isdir(path):
self._remove_dir(path)
else:
self._remove_file(path)
def download(self, src, dest, no_progress_bar=False, progress_title=None):
with Tqdm(
desc=progress_title or os.path.basename(src),
disable=no_progress_bar,
bytes=True,
total=self.getsize(src),
) as pbar:
self.sftp.get(src, dest, callback=pbar.update_to)
def move(self, src, dst):
"""Rename src to dst, if it is not possible (in case src and dst are
on different filesystems) and actual physical copying of data is
happening.
"""
self.makedirs(posixpath.dirname(dst))
try:
self.sftp.rename(src, dst)
except OSError:
self.atomic_copy(src, dst)
self.remove(src)
def atomic_copy(self, src, dst):
tmp = tmp_fname(dst)
try:
self.copy(src, tmp)
self.sftp.rename(tmp, dst)
finally:
self.remove(tmp)
def upload(self, src, dest, no_progress_bar=False, progress_title=None):
self.makedirs(posixpath.dirname(dest))
tmp_file = tmp_fname(dest)
if not progress_title:
progress_title = posixpath.basename(dest)
with Tqdm(
desc=progress_title,
disable=no_progress_bar,
bytes=True,
total=os.path.getsize(src),
) as pbar:
self.sftp.put(src, tmp_file, callback=pbar.update_to)
self.sftp.rename(tmp_file, dest)
def execute(self, cmd):
stdin, stdout, stderr = self._ssh.exec_command(cmd)
channel = stdout.channel
stdin.close()
channel.shutdown_write()
stdout_chunks = []
stderr_chunks = []
while (
not channel.closed
or channel.recv_ready()
or channel.recv_stderr_ready()
):
import selectors
selector = selectors.DefaultSelector()
selector.register(stdout.channel, selectors.EVENT_READ)
got_chunk = False
events = selector.select(self.timeout)
for key, _ in events:
c = key.fileobj
if c.recv_ready():
stdout_chunks.append(stdout.channel.recv(len(c.in_buffer)))
got_chunk = True
if c.recv_stderr_ready():
stderr_len = len(c.in_stderr_buffer)
s = stderr.channel.recv_stderr(stderr_len)
stderr_chunks.append(s)
got_chunk = True
if (
not got_chunk
and stdout.channel.exit_status_ready()
and not stderr.channel.recv_stderr_ready()
and not stdout.channel.recv_ready()
):
stdout.channel.shutdown_read()
stdout.channel.close()
break
stdout.close()
stderr.close()
ret = stdout.channel.recv_exit_status()
if ret != 0:
err = b"".join(stderr_chunks).decode("utf-8")
raise RemoteCmdError("ssh", cmd, ret, err)
return b"".join(stdout_chunks).decode("utf-8")
@cached_property
def uname(self):
return self.execute("uname").strip()
def md5(self, path):
"""
Use different md5 commands depending on the OS:
- Darwin's `md5` returns BSD-style checksums by default
- Linux's `md5sum` needs the `--tag` flag for a similar output
Example:
MD5 (foo.txt) = f3d220a856b52aabbf294351e8a24300
"""
path = shlex.quote(path)
if self.uname == "Linux":
md5 = self.execute("md5sum " + path).split()[0]
elif self.uname == "Darwin":
md5 = self.execute("md5 " + path).split()[-1]
else:
raise DvcException(
f"'{self.uname}' is not supported as a SSH remote"
)
assert len(md5) == 32
return md5
def copy(self, src, dest):
dest = shlex.quote(dest)
src = shlex.quote(src)
self.execute(f"cp {src} {dest}")
def open_max_sftp_channels(self):
# If there are more than 1 it means we've already opened max amount
if len(self._sftp_channels) <= 1:
while True:
try:
self._sftp_channels.append(self._ssh.open_sftp())
except paramiko.ssh_exception.ChannelException:
if not self._sftp_channels:
raise
break
return self._sftp_channels
def open(self, *args, **kwargs):
return self.sftp.open(*args, **kwargs)
def symlink(self, src, dest):
self.sftp.symlink(src, dest)
def reflink(self, src, dest):
dest = shlex.quote(dest)
src = shlex.quote(src)
if self.uname == "Linux":
return self.execute(f"cp --reflink {src} {dest}")
if self.uname == "Darwin":
return self.execute(f"cp -c {src} {dest}")
raise DvcException(f"'{self.uname}' is not supported as a SSH remote")
def hardlink(self, src, dest):
dest = shlex.quote(dest)
src = shlex.quote(src)
self.execute(f"ln {src} {dest}")
-7
View File
@@ -81,10 +81,6 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
fobj, rpath, overwrite=True, size=size
)
def makedirs(self, path_info):
path = self.translate_path_info(path_info)
return self.fs.makedirs(path, exist_ok=True)
@lru_cache(512)
def translate_path_info(self, path):
if isinstance(path, self.PATH_CLS):
@@ -93,9 +89,6 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
_with_bucket = translate_path_info
def _strip_bucket(self, entry):
return entry
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
scheme = Schemes.WEBDAVS
+1
View File
@@ -14,6 +14,7 @@ class HashInfo:
value: Optional[str]
size: Optional[int] = field(default=None, compare=False)
nfiles: Optional[int] = field(default=None, compare=False)
obj_name: Optional[str] = field(default=None, compare=False)
def __bool__(self):
return bool(self.value)
+2 -2
View File
@@ -57,7 +57,7 @@ class DvcIgnorePatterns(DvcIgnore):
dirname = os.path.normpath(os.path.dirname(path))
with fs.open(path, encoding="utf-8") as fobj:
path_spec_lines = [
PatternInfo(line, "{}:{}:{}".format(name, line_no + 1, line))
PatternInfo(line, f"{name}:{line_no + 1}:{line}")
for line_no, line in enumerate(
map(str.strip, fobj.readlines())
)
@@ -166,7 +166,7 @@ class DvcIgnoreFilter:
".hg/",
".git/",
".git",
"{}/".format(Repo.DVC_DIR),
f"{Repo.DVC_DIR}/",
]
self.fs = fs
+2 -4
View File
@@ -42,9 +42,7 @@ def get_dvc_info():
# later decides to enable shared cache mode with
# `dvc config cache.shared group`.
if os.path.exists(repo.odb.local.cache_dir):
info.append(
"Cache types: {}".format(_get_linktype_support_info(repo))
)
info.append(f"Cache types: {_get_linktype_support_info(repo)}")
fs_type = get_fs_type(repo.odb.local.cache_dir)
info.append(f"Cache directory: {fs_type}")
else:
@@ -56,7 +54,7 @@ def get_dvc_info():
root_directory = repo.root_dir
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
info.append(f"Repo: {_get_dvc_repo_info(repo)}")
except NotDvcRepoError:
pass
except SCMError:
+2 -1
View File
@@ -5,6 +5,7 @@
TEXT_CHARS = bytes(range(32, 127)) + b"\n\r\t\f\b"
DEFAULT_CHUNK_SIZE = 512
def istextblock(block):
@@ -22,7 +23,7 @@ def istextblock(block):
return float(len(nontext)) / len(block) <= 0.30
def istextfile(fname, fs, blocksize=512):
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
+1 -1
View File
@@ -62,7 +62,7 @@ def addLoggingLevel(levelName, levelNum, methodName=None):
class LoggingException(Exception):
def __init__(self, record):
msg = "failed to log {}".format(str(record))
msg = f"failed to log {str(record)}"
super().__init__(msg)
+16 -37
View File
@@ -1,55 +1,34 @@
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TYPE_CHECKING, Optional
from dvc.progress import Tqdm
from typing import TYPE_CHECKING, Iterator, Union
from .tree import Tree
if TYPE_CHECKING:
from dvc.hash_info import HashInfo
from .db.base import ObjectDB
from .file import HashFile
logger = logging.getLogger(__name__)
def save(
odb: "ObjectDB",
obj: "HashFile",
jobs: Optional[int] = None,
**kwargs,
):
if isinstance(obj, Tree):
with ThreadPoolExecutor(max_workers=jobs) as executor:
for future in Tqdm(
as_completed(
executor.submit(
odb.add,
entry.path_info,
entry.fs,
entry.hash_info,
**kwargs,
)
for _, entry in obj
),
desc="Saving files",
total=len(obj),
unit="file",
):
future.result()
odb.add(obj.path_info, obj.fs, obj.hash_info, **kwargs)
def check(odb, obj):
odb.check(obj.hash_info)
def check(odb: "ObjectDB", obj: "HashFile", **kwargs):
if isinstance(obj, Tree):
for _, entry in obj:
odb.check(entry.hash_info)
odb.check(entry.hash_info, **kwargs)
odb.check(obj.hash_info, **kwargs)
def load(odb, hash_info):
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
+23 -4
View File
@@ -1,21 +1,26 @@
from typing import TYPE_CHECKING
from dvc.scheme import Schemes
if TYPE_CHECKING:
from .index import ObjectDBIndexBase
def get_odb(fs, path_info, **config):
from .base import ObjectDB
from .gdrive import GDriveObjectDB
from .local import LocalObjectDB
from .ssh import SSHObjectDB
from .oss import OSSObjectDB
if fs.scheme == Schemes.LOCAL:
return LocalObjectDB(fs, path_info, **config)
if fs.scheme == Schemes.SSH:
return SSHObjectDB(fs, path_info, **config)
if fs.scheme == Schemes.GDRIVE:
return GDriveObjectDB(fs, path_info, **config)
if fs.scheme == Schemes.OSS:
return OSSObjectDB(fs, path_info, **config)
return ObjectDB(fs, path_info, **config)
@@ -26,9 +31,23 @@ def _get_odb(repo, settings):
return None
cls, config, path_info = get_cloud_fs(repo, **settings)
config["tmp_dir"] = repo.tmp_dir
return get_odb(cls(**config), path_info, state=repo.state, **config)
def get_index(odb) -> "ObjectDBIndexBase":
import hashlib
from .index import ObjectDBIndex, ObjectDBIndexNoop
cls = ObjectDBIndex if odb.tmp_dir else ObjectDBIndexNoop
return cls(
odb.tmp_dir,
hashlib.sha256(odb.path_info.url.encode("utf-8")).hexdigest(),
odb.fs.CHECKSUM_DIR_SUFFIX,
)
class ODBManager:
CACHE_DIR = "cache"
CLOUD_SCHEMES = [
+99 -40
View File
@@ -2,14 +2,20 @@ import itertools
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from copy import copy
from typing import Optional
from typing import TYPE_CHECKING, Optional
from dvc.objects.errors import ObjectFormatError
from dvc.fs.local import LocalFileSystem
from dvc.objects.errors import ObjectDBPermissionError, ObjectFormatError
from dvc.objects.file import HashFile
from dvc.objects.tree import Tree
from dvc.progress import Tqdm
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.hash_info import HashInfo
from dvc.types import AnyPath, DvcPath
logger = logging.getLogger(__name__)
@@ -19,7 +25,7 @@ class ObjectDB:
DEFAULT_CACHE_TYPES = ["copy"]
CACHE_MODE: Optional[int] = None
def __init__(self, fs, path_info, **config):
def __init__(self, fs: "BaseFileSystem", path_info: "AnyPath", **config):
from dvc.state import StateNoop
self.fs = fs
@@ -30,6 +36,7 @@ class ObjectDB:
self.cache_type_confirmed = False
self.slow_link_warning = config.get("slow_link_warning", True)
self.tmp_dir = config.get("tmp_dir")
self.read_only = config.get("read_only", False)
@property
def config(self):
@@ -39,21 +46,29 @@ class ObjectDB:
"type": self.cache_types,
"slow_link_warning": self.slow_link_warning,
"tmp_dir": self.tmp_dir,
"read_only": self.read_only,
}
def __eq__(self, other):
return self.fs == other.fs and self.path_info == other.path_info
return (
self.fs == other.fs
and self.path_info == other.path_info
and self.read_only == other.read_only
)
def __hash__(self):
return hash((self.fs.scheme, self.path_info))
def exists(self, hash_info: "HashInfo"):
return self.fs.exists(self.hash_to_path_info(hash_info.value))
def move(self, from_info, to_info):
self.fs.move(from_info, to_info)
def makedirs(self, path_info):
self.fs.makedirs(path_info)
def get(self, hash_info):
def get(self, hash_info: "HashInfo"):
"""get raw object"""
return HashFile(
# Prefer string path over PathInfo when possible due to performance
@@ -62,23 +77,28 @@ class ObjectDB:
hash_info,
)
def add(self, path_info, fs, hash_info, move=True, **kwargs):
try:
self.check(hash_info, check_hash=self.verify)
return
except (ObjectFormatError, FileNotFoundError):
pass
cache_info = self.hash_to_path_info(hash_info.value)
# using our makedirs to create dirs with proper permissions
self.makedirs(cache_info.parent)
use_move = isinstance(fs, type(self.fs)) and move
def _add_file(
self,
from_fs: "BaseFileSystem",
from_info: "AnyPath",
to_info: "DvcPath",
_hash_info: "HashInfo",
move: bool = False,
):
self.makedirs(to_info.parent)
use_move = isinstance(from_fs, type(self.fs)) and move
try:
if use_move:
self.fs.move(path_info, cache_info)
self.fs.move(from_info, to_info)
elif isinstance(from_fs, LocalFileSystem):
if not isinstance(from_info, from_fs.PATH_CLS):
from_info = from_fs.PATH_CLS(from_info)
self.fs.upload(from_info, to_info)
elif isinstance(self.fs, LocalFileSystem):
from_fs.download_file(from_info, to_info)
else:
with fs.open(path_info, mode="rb") as fobj:
self.fs.upload_fobj(fobj, cache_info)
with from_fs.open(from_info, mode="rb") as fobj:
self.fs.upload_fobj(fobj, to_info)
except OSError as exc:
# If the target file already exists, we are going to simply
# ignore the exception (#4992).
@@ -92,20 +112,48 @@ class ObjectDB:
and exc.__context__
and isinstance(exc.__context__, FileExistsError)
):
logger.debug("'%s' file already exists, skipping", path_info)
logger.debug("'%s' file already exists, skipping", to_info)
if use_move:
fs.remove(path_info)
from_fs.remove(from_info)
else:
raise
self.protect(cache_info)
self.state.save(cache_info, self.fs, hash_info)
def add(
self,
path_info: "AnyPath",
fs: "BaseFileSystem",
hash_info: "HashInfo",
move: bool = True,
verify: Optional[bool] = None,
**kwargs,
):
if self.read_only:
raise ObjectDBPermissionError("Cannot add to read-only ODB")
if verify is None:
verify = self.verify
try:
self.check(hash_info, check_hash=verify)
return
except (ObjectFormatError, FileNotFoundError):
pass
cache_info = self.hash_to_path_info(hash_info.value)
self._add_file(fs, path_info, cache_info, hash_info, move)
try:
if verify:
self.check(hash_info, check_hash=True)
self.protect(cache_info)
self.state.save(cache_info, self.fs, hash_info)
except (ObjectFormatError, FileNotFoundError):
pass
callback = kwargs.get("download_callback")
if callback:
callback(1)
def hash_to_path_info(self, hash_):
def hash_to_path_info(self, hash_) -> "DvcPath":
return self.path_info / hash_[0:2] / hash_[2:]
# Override to return path as a string instead of PathInfo for clouds
@@ -125,7 +173,11 @@ class ObjectDB:
def set_exec(self, path_info): # pylint: disable=unused-argument
pass
def check(self, hash_info, check_hash=True):
def check(
self,
hash_info: "HashInfo",
check_hash: bool = True,
):
"""Compare the given hash with the (corresponding) actual one if
check_hash is specified, or just verify the existence of the cache
files on the filesystem.
@@ -142,7 +194,7 @@ class ObjectDB:
obj = self.get(hash_info)
if self.is_protected(obj.path_info):
logger.trace(
logger.trace( # type: ignore[attr-defined]
"Assuming '%s' is unchanged since it is read-only",
obj.path_info,
)
@@ -152,12 +204,14 @@ class ObjectDB:
obj.check(self, check_hash=check_hash)
except ObjectFormatError:
logger.warning("corrupted cache file '%s'.", obj.path_info)
self.fs.remove(obj.path_info)
with suppress(FileNotFoundError):
self.fs.remove(obj.path_info)
raise
# making cache file read-only so we don't need to check it
# next time
self.protect(obj.path_info)
if check_hash:
# making cache file read-only so we don't need to check it
# next time
self.protect(obj.path_info)
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
@@ -334,13 +388,20 @@ class ObjectDB:
def _remove_unpacked_dir(self, hash_):
pass
def gc(self, used, jobs=None):
def gc(self, used, jobs=None, cache_odb=None, shallow=True):
from ..tree import Tree
if self.read_only:
raise ObjectDBPermissionError("Cannot gc read-only ODB")
if not cache_odb:
cache_odb = self
used_hashes = set()
for obj in used:
used_hashes.add(obj.hash_info.value)
if isinstance(obj, Tree):
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 obj
entry_obj.hash_info.value for _, entry_obj in tree
)
removed = False
@@ -366,9 +427,7 @@ class ObjectDB:
"""Return list of the specified hashes which exist in this fs.
Hashes will be queried individually.
"""
logger.debug(
"Querying {} hashes via object_exists".format(len(hashes))
)
logger.debug(f"Querying {len(hashes)} hashes via object_exists")
with Tqdm(
desc="Querying "
+ ("cache in " + name if name else "remote cache"),
@@ -463,7 +522,7 @@ class ObjectDB:
hashes - remote_hashes, jobs, name
)
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
logger.debug(f"Querying '{len(hashes)}' hashes via traverse")
remote_hashes = set(
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
)
-24
View File
@@ -1,24 +0,0 @@
import logging
from .base import ObjectDB
logger = logging.getLogger(__name__)
class GitObjectDB(ObjectDB):
"""Dummy read-only ODB for uncached objects in external Git repos."""
def __init__(self, fs, path_info, **config):
from dvc.fs.repo import RepoFileSystem
assert isinstance(fs, RepoFileSystem)
super().__init__(fs, path_info)
def get(self, hash_info):
raise NotImplementedError
def add(self, path_info, fs, hash_info, move=True, **kwargs):
raise NotImplementedError
def gc(self, used, jobs=None):
raise NotImplementedError
+137
View File
@@ -0,0 +1,137 @@
import logging
import os
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Iterable, Optional, Set
from ..errors import ObjectDBError
if TYPE_CHECKING:
from dvc.types import StrPath
logger = logging.getLogger(__name__)
class ObjectDBIndexBase(ABC):
@abstractmethod
def __init__(
self, tmp_dir: "StrPath", name: str, dir_suffix: Optional[str] = None
):
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, dir_suffix: Optional[str] = None
): # 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, dir_suffix: Optional[str] = None
): # pylint: disable=super-init-not-called
from diskcache import 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(self.index_dir)
if not dir_suffix:
dir_suffix = self.fs.CHECKSUM_DIR_SUFFIX
self.dir_suffix = dir_suffix
def __iter__(self):
return iter(self.index)
def __contains__(self, hash_):
return hash_ in self.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)
def is_dir_hash(self, hash_: str):
return hash_.endswith(self.dir_suffix)
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
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
def intersection(self, hashes: Set[str]):
"""Iterate over values from `hashes` which exist in the index."""
yield from hashes.intersection(self.index.keys())
+1 -1
View File
@@ -112,7 +112,7 @@ class LocalObjectDB(ObjectDB):
# 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="Unprotecting '{}'".format(relpath(path)))
copyfile(path, tmp, name=f"Unprotecting '{relpath(path)}'")
remove(path)
os.rename(tmp, path)
+9
View File
@@ -0,0 +1,9 @@
from .base import ObjectDB
class OSSObjectDB(ObjectDB):
"""
Temporary extra verification
"""
DEFAULT_VERIFY = True
+84
View File
@@ -0,0 +1,84 @@
import io
import logging
import os
from typing import TYPE_CHECKING, Dict
from dvc.scheme import Schemes
from ..errors import ObjectFormatError
from ..reference import ReferenceHashFile
from .base import ObjectDB
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.hash_info import HashInfo
from dvc.types import AnyPath, DvcPath
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, "BaseFileSystem"] = {}
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
path_info = self.hash_to_path(hash_info.value)
try:
with self.fs.open(path_info, "rb") as fobj:
ref_file = ReferenceHashFile.from_bytes(
fobj.read(), fs_cache=self._fs_cache
)
except OSError:
raise FileNotFoundError
try:
ref_file.check(self, check_hash=False)
except ObjectFormatError:
self.fs.remove(path_info)
raise
self._obj_cache[hash_info] = ref_file
return ref_file
def _add_file(
self,
from_fs: "BaseFileSystem",
from_info: "AnyPath",
to_info: "DvcPath",
hash_info: "HashInfo",
move: bool = False,
):
self.makedirs(to_info.parent)
if hash_info.isdir:
return super()._add_file(
from_fs, from_info, to_info, hash_info, move
)
ref_file = ReferenceHashFile(from_info, from_fs, hash_info)
self._obj_cache[hash_info] = ref_file
ref_fobj = io.BytesIO(ref_file.to_bytes())
ref_fobj.seek(0)
try:
self.fs.upload_fobj(ref_fobj, to_info)
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
@@ -25,15 +25,15 @@ this.message = (
def slow_link_guard(f):
@wraps(f)
def wrapper(remote, *args, **kwargs):
def wrapper(odb, *args, **kwargs):
if this.already_displayed:
return f(remote, *args, **kwargs)
return f(odb, *args, **kwargs)
if not remote.slow_link_warning or remote.cache_types:
return f(remote, *args, **kwargs)
if not odb.slow_link_warning or odb.cache_types:
return f(odb, *args, **kwargs)
start = time.time()
result = f(remote, *args, **kwargs)
result = f(odb, *args, **kwargs)
delta = time.time() - start
if delta >= this.timeout_seconds:
-90
View File
@@ -1,90 +0,0 @@
import errno
import itertools
import logging
import posixpath
from concurrent.futures import ThreadPoolExecutor
from dvc.progress import Tqdm
from dvc.utils import to_chunks
from .base import ObjectDB
logger = logging.getLogger(__name__)
class SSHObjectDB(ObjectDB):
def batch_exists(self, path_infos, callback):
def _exists(chunk_and_channel):
chunk, channel = chunk_and_channel
ret = []
for path in chunk:
try:
channel.stat(path)
ret.append(True)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise
ret.append(False)
callback(path)
return ret
with self.fs.ssh() as ssh:
channels = ssh.open_max_sftp_channels()
max_workers = len(channels)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
paths = [path_info.path for path_info in path_infos]
chunks = to_chunks(paths, num_chunks=max_workers)
chunks_and_channels = zip(chunks, channels)
outcome = executor.map(_exists, chunks_and_channels)
results = list(itertools.chain.from_iterable(outcome))
return results
def hashes_exist(self, hashes, jobs=None, name=None):
"""This is older implementation used in remote/base.py
We are reusing it in RemoteSSH, because SSH's batch_exists proved to be
faster than current approach (relying on exists(path_info)) applied in
remote/base.
"""
if not self.fs.CAN_TRAVERSE:
return list(set(hashes) & set(self.all()))
# possibly prompt for credentials before "Querying" progress output
self.fs.ensure_credentials()
with Tqdm(
desc="Querying "
+ ("cache in " + name if name else "remote cache"),
total=len(hashes),
unit="file",
) as pbar:
def exists_with_progress(chunks):
return self.batch_exists(chunks, callback=pbar.update_msg)
with ThreadPoolExecutor(
max_workers=jobs or self.fs.jobs
) as executor:
path_infos = [self.hash_to_path_info(x) for x in hashes]
chunks = to_chunks(path_infos, num_chunks=self.fs.jobs)
results = executor.map(exists_with_progress, chunks)
in_remote = itertools.chain.from_iterable(results)
ret = list(itertools.compress(hashes, in_remote))
return ret
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
root = posixpath.join(self.path_info.path, prefix[:2])
else:
root = self.path_info.path
with self.fs.ssh() as ssh:
if prefix and not ssh.exists(root):
return
# If we simply return an iterator then with above closes instantly
if progress_callback:
for path in ssh.walk_files(root):
progress_callback()
yield path
else:
yield from ssh.walk_files(root)
+8
View File
@@ -7,3 +7,11 @@ class ObjectError(DvcException):
class ObjectFormatError(ObjectError):
pass
class ObjectDBError(DvcException):
pass
class ObjectDBPermissionError(ObjectDBError):
pass
+11 -5
View File
@@ -8,7 +8,9 @@ from .errors import ObjectFormatError
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.hash_info import HashInfo
from dvc.types import DvcPath
from dvc.types import AnyPath
from .db.base import ObjectDB
logger = logging.getLogger(__name__)
@@ -16,7 +18,7 @@ logger = logging.getLogger(__name__)
class HashFile:
def __init__(
self,
path_info: Optional["DvcPath"],
path_info: Optional["AnyPath"],
fs: Optional["BaseFileSystem"],
hash_info: "HashInfo",
name: Optional[str] = None,
@@ -57,10 +59,9 @@ class HashFile:
)
)
def check(self, odb, check_hash=True):
from .stage import get_file_hash
def check(self, odb: "ObjectDB", check_hash: bool = True):
if not check_hash:
assert self.fs
if not self.fs.exists(self.path_info):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
@@ -68,6 +69,11 @@ class HashFile:
else:
return None
self._check_hash(odb)
def _check_hash(self, odb):
from .stage import get_file_hash
actual = get_file_hash(
self.path_info, self.fs, self.hash_info.name, odb.state
)
+135
View File
@@ -0,0 +1,135 @@
import errno
import logging
import os
import pickle
from typing import TYPE_CHECKING, Optional, Tuple
from .errors import ObjectFormatError
from .file import HashFile
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.hash_info import HashInfo
from dvc.types import AnyPath
from .db.base import ObjectDB
logger = logging.getLogger(__name__)
class ReferenceHashFile(HashFile):
PARAM_PATH = "path"
PARAM_HASH = "hash"
PARAM_MTIME = "mtime"
PARAM_SIZE = "size"
PARAM_FS_CONFIG = "fs_config"
def __init__(
self,
path_info: "AnyPath",
fs: "BaseFileSystem",
hash_info: "HashInfo",
mtime: Optional[str] = None,
size: Optional[int] = None,
**kwargs,
):
super().__init__(path_info, fs, hash_info, **kwargs)
cur_mtime, cur_size = self._get_mtime_and_size()
self.mtime = cur_mtime if mtime is None else mtime
self.hash_info.size = cur_size if size is None else size
def __str__(self):
return f"ref object {self.hash_info} -> {self.path_info}"
def check(self, odb: "ObjectDB", check_hash: bool = True):
assert self.fs
if not self.fs.exists(self.path_info):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
)
if not (self.mtime, self.size) == self._get_mtime_and_size():
raise ObjectFormatError(f"{self} is changed")
if check_hash:
self._check_hash(odb)
def _get_mtime_and_size(self) -> Tuple[Optional[str], int]:
from dvc.utils.fs import get_mtime_and_size
assert self.fs
if hasattr(self.fs, "stat"):
try:
return get_mtime_and_size(self.path_info, self.fs)
except FileNotFoundError:
pass
return None, self.fs.getsize(self.path_info)
def to_bytes(self):
from dvc.fs.repo import RepoFileSystem
from dvc.path_info import PathInfo
# 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
path_info = self.path_info
if isinstance(self.fs, RepoFileSystem):
path_info = PathInfo(path_info).relative_to(self.fs.root_dir)
dict_ = {
self.PARAM_PATH: path_info,
self.PARAM_HASH: self.hash_info,
self.PARAM_MTIME: self.mtime,
self.PARAM_SIZE: self.size,
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
try:
dict_ = pickle.loads(data)
except pickle.PickleError as exc:
raise ObjectFormatError("ReferenceHashFile is corrupted") from exc
try:
path_info = 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 = RepoFileSystem(**config)
path_info = fs.root_dir / path_info
else:
fs_cls = get_fs_cls(config, scheme=path_info.scheme)
fs = fs_cls(**config)
return ReferenceHashFile(
path_info,
fs,
hash_info,
mtime=dict_.get(cls.PARAM_MTIME),
size=dict_.get(cls.PARAM_SIZE),
)
@staticmethod
def config_tuple(fs: "BaseFileSystem"):
return (
fs.scheme,
tuple(
(key, value)
for key, value in sorted(
fs.config.items(), key=lambda item: item[0]
)
),
)
+211 -96
View File
@@ -1,27 +1,45 @@
import hashlib
import logging
import os
import pathlib
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import TYPE_CHECKING, Dict, 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
from .db.reference import ReferenceObjectDB
from .file import HashFile
def _upload_file(path_info, fs, odb):
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.types import AnyPath, DvcPath
from .db.base import ObjectDB
logger = logging.getLogger(__name__)
_STAGING_MEMFS_PATH = "dvc-staging"
def _upload_file(path_info, fs, odb, upload_odb):
from dvc.utils import tmp_fname
from dvc.utils.stream import HashedStreamReader
tmp_info = odb.path_info / tmp_fname()
tmp_info = upload_odb.path_info / tmp_fname()
with fs.open(path_info, mode="rb", chunk_size=fs.CHUNK_SIZE) as stream:
stream = HashedStreamReader(stream)
odb.fs.upload_fobj(
upload_odb.fs.upload_fobj(
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
)
obj = HashFile(tmp_info, odb.fs, stream.hash_info)
return path_info, obj
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
return path_info, odb.get(stream.hash_info)
def _get_file_hash(path_info, fs, name):
@@ -59,19 +77,31 @@ def get_file_hash(path_info, fs, name, state=None):
return hash_info
def _get_file_obj(path_info, fs, name, odb=None, state=None, upload=False):
if upload:
def _get_file_obj(
path_info, fs, name, odb=None, upload_odb=None, dry_run=False
):
state = odb.state if odb else None
hash_info = get_file_hash(path_info, fs, name, state=state)
if upload_odb and not dry_run:
assert odb and name == "md5"
return _upload_file(path_info, fs, odb)
return _upload_file(path_info, fs, odb, upload_odb)
obj = HashFile(
path_info, fs, get_file_hash(path_info, fs, name, state=state)
)
if dry_run:
obj = HashFile(path_info, fs, hash_info)
else:
odb.add(path_info, fs, hash_info, move=False)
obj = odb.get(hash_info)
return path_info, obj
def _build_objects(
path_info, fs, name, odb, state, upload, dvcignore=None, **kwargs
path_info,
fs,
name,
dvcignore=None,
jobs=None,
no_progress_bar=False,
**kwargs,
):
if dvcignore:
walk_iterator = dvcignore.walk_files(fs, path_info)
@@ -80,45 +110,31 @@ def _build_objects(
with Tqdm(
unit="md5",
desc="Computing file/dir hashes (only done once)",
disable=kwargs.pop("no_progress_bar", False),
disable=no_progress_bar,
) as pbar:
worker = pbar.wrap_fn(
partial(
_get_file_obj,
fs=fs,
name=name,
odb=odb,
state=state,
upload=upload,
**kwargs,
)
)
with ThreadPoolExecutor(
max_workers=kwargs.pop("jobs", fs.hash_jobs)
max_workers=jobs if jobs is not None else fs.hash_jobs
) as executor:
yield from executor.map(worker, walk_iterator)
def _iter_objects(path_info, fs, name, odb, state, upload, **kwargs):
if not upload and name in fs.DETAIL_FIELDS:
for details in fs.find(path_info, detail=True):
file_info = path_info.replace(path=details["name"])
hash_info = HashInfo(name, details[name], size=details.get("size"))
yield file_info, HashFile(file_info, fs, hash_info)
return None
yield from _build_objects(
path_info, fs, name, odb, state, upload, **kwargs
)
def _iter_objects(path_info, fs, name, **kwargs):
yield from _build_objects(path_info, fs, name, **kwargs)
def _build_tree(path_info, fs, name, odb, state, upload, **kwargs):
def _build_tree(path_info, fs, name, **kwargs):
from .tree import Tree
tree = Tree(None, None, None)
for file_info, obj in _iter_objects(
path_info, fs, name, odb, state, upload, **kwargs
):
for file_info, obj in _iter_objects(path_info, fs, name, **kwargs):
if DvcIgnore.DVCIGNORE_FILE == file_info.name:
raise DvcIgnoreInCollectedDirError(file_info.parent)
@@ -131,85 +147,184 @@ def _build_tree(path_info, fs, name, odb, state, upload, **kwargs):
# Yes, this is a BUG, as long as we permit "/" in
# filenames on Windows and "\" on Unix
tree.add(file_info.relative_to(path_info).parts, obj)
tree.digest()
return tree
def _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs):
def _get_tree_obj(path_info, fs, fs_info, name, odb=None, **kwargs):
from .tree import Tree
value = fs.info(path_info).get(name)
if value:
value = fs_info.get(name)
if odb and value:
hash_info = HashInfo(name, value)
try:
return Tree.load(odb, hash_info)
tree = Tree.load(odb, hash_info)
# NOTE: loaded entries are naive objects with hash_infos but no
# path_info. For staging trees, obj.path_info should be relative
# to the staging src `path_info` and src fs
for key, entry in tree:
entry.fs = fs
entry.path_info = path_info.joinpath(*key)
return tree
except FileNotFoundError:
pass
tree = _build_tree(path_info, fs, name, odb, state, upload, **kwargs)
odb.add(tree.path_info, tree.fs, tree.hash_info)
if name != "md5":
# 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.
raw = odb.get(tree.hash_info)
hash_info = get_file_hash(raw.path_info, raw.fs, name, state)
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"
odb.add(tree.path_info, tree.fs, tree.hash_info)
tree = _build_tree(path_info, 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
path_info, fs
)
tree.digest(hash_info=hash_info)
odb.add(tree.path_info, tree.fs, tree.hash_info, move=True)
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.path_info)
tree.fs = raw.fs
tree.path_info = raw.path_info
return tree
def stage(odb, path_info, fs, name, upload=False, **kwargs):
assert path_info and (
isinstance(path_info, str) or path_info.scheme == fs.scheme
)
_url_cache: Dict[str, str] = {}
def _make_staging_url(path_info: "AnyPath"):
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
url = CloudURLInfo(f"{Schemes.MEMORY}://{_STAGING_MEMFS_PATH}")
if path_info:
if isinstance(path_info, (str, pathlib.PurePath)):
path = os.path.abspath(path_info)
else:
path = str(path_info)
if path not in _url_cache:
_url_cache[path] = hashlib.sha256(path.encode("utf-8")).hexdigest()
url /= _url_cache[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()
path_info = _make_staging_url(odb.path_info)
state = odb.state
return ReferenceObjectDB(fs, path_info, state=state)
def _load_from_state(odb, staging, path_info, fs, name):
from . import check, load
from .errors import ObjectFormatError
from .tree import Tree
state = odb.state
hash_info = state.get(path_info, 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):
obj.hash_info.nfiles = len(obj)
for key, entry in obj:
entry.fs = fs
entry.path_info = path_info.joinpath(*key)
else:
obj.fs = fs
obj.path_info = path_info
assert obj.hash_info.name == name
obj.hash_info.size = hash_info.size
return odb_, 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.path_info, tree.fs, tree.hash_info)
raw = odb.get(tree.hash_info)
hash_info = get_file_hash(raw.path_info, raw.fs, name, state=odb.state)
tree.path_info = raw.path_info
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",
path_info: "DvcPath",
fs: "BaseFileSystem",
name: str,
upload: bool = False,
dry_run: bool = False,
**kwargs,
) -> Tuple["ObjectDB", "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 path_info and path_info.scheme == fs.scheme
details = fs.info(path_info)
state = odb.state
# pylint: disable=assignment-from-none
hash_info = state.get(path_info, fs)
# If we have dir hash in state db, but dir cache file is lost,
# then we need to recollect the dir via .get_dir_hash() call below,
# see https://github.com/iterative/dvc/issues/2219 for context
if (
hash_info
and hash_info.isdir
and not odb.fs.exists(odb.hash_to_path_info(hash_info.value))
):
hash_info = None
if hash_info:
from . import load
from .tree import Tree
obj = load(odb, hash_info)
if isinstance(obj, Tree):
obj.hash_info.nfiles = len(obj)
for key, entry in obj:
entry.fs = fs
entry.path_info = path_info.joinpath(*key)
else:
obj.fs = fs
obj.path_info = path_info
assert obj.hash_info.name == name
obj.hash_info.size = hash_info.size
return obj
staging = _get_staging(odb)
if odb:
try:
return _load_from_state(odb, staging, path_info, fs, name)
except FileNotFoundError:
pass
if details["type"] == "directory":
obj = _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs)
obj = _get_tree_obj(
path_info,
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:
_, obj = _get_file_obj(path_info, fs, name, odb, state, upload)
_, obj = _get_file_obj(
path_info,
fs,
name,
odb=staging,
upload_odb=odb if upload else None,
dry_run=dry_run,
)
if obj.hash_info:
state.save(path_info, fs, obj.hash_info)
if odb and odb.state and obj.hash_info:
odb.state.save(path_info, fs, obj.hash_info)
return obj
return staging, obj
+187
View File
@@ -0,0 +1,187 @@
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 .db.base import ObjectDB
from .db.index import ObjectDBIndexBase
from .file import HashFile
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):
# Validate our index by verifying all indexed .dir hashes
# still exist on the remote
dir_hashes = set(dir_objs.keys())
indexed_dirs = set(index.dir_hashes())
indexed_dir_exists = set()
if indexed_dirs:
indexed_dir_exists.update(odb.list_hashes_exists(indexed_dirs))
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_exists.update(odb.list_hashes_exists(dir_hashes - dir_exists))
# 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 = [entry.hash_info.value for _, entry 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,
**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.path_info)
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 _, entry in tree:
assert entry.hash_info and entry.hash_info.value
hash_infos[entry.hash_info.value] = entry.hash_info
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.path_info)
if index and hashes:
if dir_objs:
exists = hashes.intersection(
_indexed_dir_hashes(odb, index, dir_objs, name, cache_odb)
)
hashes.difference_update(exists)
if hashes:
exists.update(index.intersection(hashes))
hashes.difference_update(exists)
if hashes:
exists.update(
odb.hashes_exist(hashes, name=str(odb.path_info), **kwargs)
)
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,
**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, **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, **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
+239
View File
@@ -0,0 +1,239 @@
import errno
import itertools
import logging
from concurrent import futures
from concurrent.futures import ThreadPoolExecutor
from functools import partial, wraps
from typing import TYPE_CHECKING, Callable, Iterable, Optional
from funcy import split
from dvc.progress import Tqdm
if TYPE_CHECKING:
from dvc.hash_info import HashInfo
from .db.base import ObjectDB
from .db.index import ObjectDBIndexBase
logger = logging.getLogger(__name__)
def _log_exceptions(func):
@wraps(func)
def wrapper(path_info, *args, **kwargs):
try:
func(path_info, *args, **kwargs)
return 0
except Exception as exc: # pylint: disable=broad-except
# NOTE: this means we ran out of file descriptors and there is no
# reason to try to proceed, as we will hit this error anyways.
# pylint: disable=no-member
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
raise
logger.exception("failed to transfer '%s'", path_info)
return 1
return wrapper
def _transfer(
src, dest, dir_ids, file_ids, missing_ids, jobs, verify, move, **kwargs
):
func = _log_exceptions(dest.add)
total = len(dir_ids) + len(file_ids)
if total == 0:
return 0
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
func = pbar.wrap_fn(func)
with ThreadPoolExecutor(max_workers=jobs) as executor:
processor = partial(
_create_tasks,
executor,
jobs,
func,
src,
verify,
move,
)
processor.add_func = func
_do_transfer(
src,
dest,
dir_ids,
file_ids,
missing_ids,
processor,
verify=verify,
move=move,
**kwargs,
)
return total
def _create_tasks(executor, jobs, func, src, verify, move, obj_ids):
fails = 0
hash_iter = iter(obj_ids)
def submit(hash_info):
obj = src.get(hash_info)
return executor.submit(
func,
obj.path_info,
obj.fs,
obj.hash_info,
verify=verify,
move=move,
)
def create_taskset(amount):
return {
submit(hash_info)
for hash_info in itertools.islice(hash_iter, amount)
}
tasks = create_taskset(jobs * 5)
while tasks:
done, tasks = futures.wait(tasks, return_when=futures.FIRST_COMPLETED)
fails += sum(task.result() for task in done)
tasks.update(create_taskset(len(done)))
return fails
def _do_transfer(
src: "ObjectDB",
dest: "ObjectDB",
dir_ids: Iterable["HashInfo"],
file_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,
):
from dvc.exceptions import FileTransferError
from dvc.objects.errors import ObjectFormatError
total_fails = 0
succeeded_dir_objs = []
all_file_ids = set(file_ids)
for dir_hash in dir_ids:
from .tree import Tree
bound_file_ids = set()
dir_obj: Optional["Tree"] = None
for odb in (cache_odb, src):
if odb is not None:
try:
dir_obj = Tree.load(odb, dir_hash)
break
except (FileNotFoundError, ObjectFormatError):
pass
assert dir_obj
entry_ids = {entry.hash_info for _, entry in dir_obj}
for file_hash in all_file_ids.copy():
if file_hash in entry_ids:
bound_file_ids.add(file_hash)
all_file_ids.remove(file_hash)
dir_fails = 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).path_info,
dest.get(dir_obj.hash_info).path_info,
)
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:
raw_obj = src.get(dir_obj.hash_info)
is_dir_failed = processor.add_func( # type: ignore[attr-defined]
raw_obj.path_info,
raw_obj.fs,
raw_obj.hash_info,
**kwargs,
)
total_fails += is_dir_failed
if not is_dir_failed:
succeeded_dir_objs.append(dir_obj)
# insert the rest
total_fails += 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 = {entry.hash_info.value for _, entry 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,
move: 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.path_info,
dest.path_info,
)
if src == dest:
return 0
status = compare_status(src, dest, obj_ids, check_deleted=False, **kwargs)
if not status.new:
return 0
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, status.new)
if jobs is None:
jobs = dest.fs.jobs
return _transfer(
src,
dest,
set(dir_ids),
set(file_ids),
status.missing,
jobs,
verify,
move,
**kwargs,
)
+34 -30
View File
@@ -1,7 +1,7 @@
import json
import logging
import posixpath
from typing import TYPE_CHECKING, Optional, Tuple
from typing import TYPE_CHECKING, Dict, Optional, Tuple
from funcy import cached_property
@@ -10,7 +10,7 @@ from .file import HashFile
from .stage import get_file_hash
if TYPE_CHECKING:
from .db.base import ObjectDB
from dvc.hash_info import HashInfo
logger = logging.getLogger(__name__)
@@ -20,7 +20,7 @@ class Tree(HashFile):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._dict = {}
self._dict: Dict[Tuple[str], "HashFile"] = {}
@cached_property
def trie(self):
@@ -32,19 +32,23 @@ class Tree(HashFile):
self.__dict__.pop("trie", None)
self._dict[key] = obj
def digest(self):
def digest(self, hash_info: Optional["HashInfo"] = None):
from dvc.fs.memory import MemoryFileSystem
from dvc.path_info import PathInfo
from dvc.path_info import CloudURLInfo
from dvc.utils import tmp_fname
memfs = MemoryFileSystem()
path_info = PathInfo(tmp_fname(""))
path_info = CloudURLInfo("memory://{}".format(tmp_fname("")))
with memfs.open(path_info, "wb") as fobj:
fobj.write(self.as_bytes())
self.fs = memfs
self.path_info = path_info
self.hash_info = get_file_hash(path_info, memfs, "md5")
self.hash_info.value += ".dir"
if hash_info:
self.hash_info = hash_info
else:
self.hash_info = get_file_hash(path_info, memfs, "md5")
assert self.hash_info.value
self.hash_info.value += ".dir"
try:
self.hash_info.size = sum(obj.size for _, obj in self)
except TypeError:
@@ -96,7 +100,6 @@ class Tree(HashFile):
@classmethod
def load(cls, odb, hash_info):
obj = odb.get(hash_info)
try:
@@ -121,39 +124,40 @@ class Tree(HashFile):
return tree
def filter(
self, odb: "ObjectDB", prefix: Tuple[str], copy: bool = False
) -> Optional[HashFile]:
"""Return filter object(s) for this tree.
def filter(self, prefix: Tuple[str]) -> Optional["Tree"]:
"""Return a filtered copy of this tree that only contains entries
inside prefix.
If copy is True, returned object will be a Tree containing
filtered entries, but with hash_info copied from the original tree.
The returned tree will contain the original tree's hash_info and
path_info.
If copy is False, returned object will be a raw HashFile or Tree with
newly computed hash_info for the filtered object.
Returns an empty tree if no object exists at the specified prefix.
"""
tree = Tree(self.path_info, self.fs, self.hash_info)
try:
for key, obj in self.trie.items(prefix):
tree.add(key, obj)
except KeyError:
pass
return tree
def get(self, prefix: Tuple[str]) -> Optional[HashFile]:
"""Return object at the specified prefix in this tree.
Returns None if no object exists at the specified prefix.
"""
obj = self._dict.get(prefix)
if obj:
if copy:
tree = Tree(self.path_info, self.fs, self.hash_info)
tree.add(prefix, obj)
return tree
return obj
if copy:
tree = Tree(self.path_info, self.fs, self.hash_info)
depth = 0
else:
tree = Tree(None, None, None)
depth = len(prefix)
tree = Tree(None, None, None)
depth = len(prefix)
try:
for key, obj in self.trie.items(prefix):
tree.add(key[depth:], obj)
except KeyError:
return None
if not copy:
tree.digest()
odb.add(tree.path_info, tree.fs, tree.hash_info)
tree.digest()
return tree
+101 -42
View File
@@ -25,16 +25,15 @@ from .fs.s3 import S3FileSystem
from .hash_info import HashInfo
from .istextfile import istextfile
from .objects import Tree
from .objects import save as osave
from .objects.errors import ObjectFormatError
from .objects.stage import stage as ostage
from .objects.transfer import transfer as otransfer
from .scheme import Schemes
from .utils import relpath
from .utils.fs import path_isin
if TYPE_CHECKING:
from .objects.db.base import ObjectDB
from .objects.file import HashFile
logger = logging.getLogger(__name__)
@@ -404,21 +403,21 @@ class Output:
return self.odb.hash_to_path_info(self.hash_info.value).url
def get_hash(self):
if not self.use_cache:
return ostage(
self.repo.odb.local,
self.path_info,
self.fs,
self.fs.PARAM_CHECKSUM,
dvcignore=self.dvcignore,
).hash_info
return ostage(
self.odb,
if self.use_cache:
odb = self.odb
name = self.odb.fs.PARAM_CHECKSUM
else:
odb = self.repo.odb.local
name = self.fs.PARAM_CHECKSUM
_, obj = ostage(
odb,
self.path_info,
self.fs,
self.odb.fs.PARAM_CHECKSUM,
name,
dvcignore=self.dvcignore,
).hash_info
dry_run=not self.use_cache,
)
return obj.hash_info
@property
def is_dir_checksum(self):
@@ -545,7 +544,7 @@ class Output:
logger.debug("Output '%s' didn't change. Skipping saving.", self)
return
self.obj = ostage(
_, self.obj = ostage(
self.odb,
self.path_info,
self.fs,
@@ -566,14 +565,28 @@ class Output:
assert self.hash_info
if self.use_cache:
obj = ostage(
self.odb,
filter_info or self.path_info,
self.fs,
self.odb.fs.PARAM_CHECKSUM,
dvcignore=self.dvcignore,
granular = (
self.is_dir_checksum
and filter_info
and filter_info != self.path_info
)
objects.save(self.odb, obj)
if granular:
obj = self._commit_granular_dir(filter_info)
else:
staging, obj = ostage(
self.odb,
filter_info or self.path_info,
self.fs,
self.odb.fs.PARAM_CHECKSUM,
dvcignore=self.dvcignore,
)
otransfer(
staging,
self.odb,
{obj.hash_info},
shallow=False,
move=True,
)
checkout(
filter_info or self.path_info,
self.fs,
@@ -585,6 +598,26 @@ class Output:
)
self.set_exec()
def _commit_granular_dir(self, filter_info):
prefix = filter_info.relative_to(self.path_info).parts
staging, save_obj = ostage(
self.odb,
self.path_info,
self.fs,
self.odb.fs.PARAM_CHECKSUM,
dvcignore=self.dvcignore,
)
save_obj = save_obj.filter(prefix)
checkout_obj = save_obj.get(prefix)
otransfer(
staging,
self.odb,
{save_obj.hash_info} | {entry.hash_info for _, entry in save_obj},
shallow=True,
move=True,
)
return checkout_obj
def dumpd(self):
ret = copy(self.hash_info.to_dict())
@@ -670,7 +703,7 @@ class Output:
if filter_info and filter_info != self.path_info:
prefix = filter_info.relative_to(self.path_info).parts
obj = obj.filter(self.odb, prefix, **kwargs)
obj = obj.get(prefix)
return obj
@@ -764,7 +797,7 @@ class Output:
upload = not (update and from_fs.isdir(from_info))
jobs = jobs or min((from_fs.jobs, odb.fs.jobs))
obj = ostage(
staging, obj = ostage(
odb,
from_info,
from_fs,
@@ -773,7 +806,14 @@ class Output:
jobs=jobs,
no_progress_bar=no_progress_bar,
)
osave(odb, obj, jobs=jobs, move=upload)
otransfer(
staging,
odb,
{obj.hash_info},
jobs=jobs,
move=upload,
shallow=False,
)
self.hash_info = obj.hash_info
return obj
@@ -802,8 +842,11 @@ class Output:
obj = self.odb.get(self.hash_info)
try:
objects.check(self.odb, obj)
except (FileNotFoundError, ObjectFormatError):
self.repo.cloud.pull([obj], show_checksums=False, **kwargs)
except FileNotFoundError:
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
return self.obj
try:
self.obj = objects.load(self.odb, self.hash_info)
@@ -814,8 +857,8 @@ class Output:
def collect_used_dir_cache(
self, remote=None, force=False, jobs=None, filter_info=None
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
"""Fetch dir cache and return used objects for this out."""
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
"""Fetch dir cache and return used object IDs for this out."""
try:
self.get_dir_cache(jobs=jobs, remote=remote)
@@ -824,7 +867,7 @@ class Output:
try:
objects.check(self.odb, self.odb.get(self.hash_info))
except (FileNotFoundError, ObjectFormatError):
except FileNotFoundError:
msg = (
"Missing cache for directory '{}'. "
"Cache for files inside will be lost. "
@@ -837,14 +880,16 @@ class Output:
)
return {}
obj = self.get_obj(filter_info=filter_info, copy=True)
self._set_obj_names(obj)
return {None: {obj}}
obj = self.get_obj()
if filter_info and filter_info != self.path_info:
prefix = filter_info.relative_to(self.path_info).parts
obj = obj.filter(prefix)
return {None: set(self._named_obj_ids(obj))}
def get_used_objs(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
"""Return filtered set of used objects for this out."""
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
"""Return filtered set of used object IDs for this out."""
if not self.use_cache:
return {}
@@ -877,24 +922,26 @@ class Output:
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
if not obj:
obj = self.odb.get(self.hash_info)
self._set_obj_names(obj)
return {None: {obj}}
return {None: set(self._named_obj_ids(obj))}
def _set_obj_names(self, obj):
obj.name = str(self)
def _named_obj_ids(self, obj):
name = str(self)
obj.hash_info.obj_name = name
yield obj.hash_info
if isinstance(obj, Tree):
for key, entry_obj in obj:
entry_obj.name = self.fs.sep.join([obj.name, *key])
entry_obj.hash_info.obj_name = self.fs.sep.join([name, *key])
yield entry_obj.hash_info
def get_used_external(
self, **kwargs
) -> Dict[Optional["ObjectDB"], Set["HashFile"]]:
) -> Dict[Optional["ObjectDB"], Set["HashInfo"]]:
if not self.use_cache or not self.stage.is_repo_import:
return {}
(dep,) = self.stage.deps
return dep.get_used_objs()
return dep.get_used_objs(**kwargs)
def _validate_output_path(self, path, stage=None):
from dvc.dvcfile import is_valid_filename
@@ -957,6 +1004,18 @@ class Output:
def fspath(self):
return self.path_info.fspath
@property
def is_decorated(self) -> bool:
return self.is_metric or self.is_plot
@property
def is_metric(self) -> bool:
return bool(self.metric) or bool(self.live)
@property
def is_plot(self) -> bool:
return bool(self.plot)
ARTIFACT_SCHEMA = {
**CHECKSUMS_SCHEMA,
+4 -2
View File
@@ -52,7 +52,7 @@ class PathInfo(pathlib.PurePath, _BasePath):
return relpath(path)
def __repr__(self):
return "{}: '{}'".format(type(self).__name__, self)
return f"{type(self).__name__}: '{self}'"
# This permits passing it to file utils directly in Python 3.6+
def __fspath__(self):
@@ -64,6 +64,8 @@ class PathInfo(pathlib.PurePath, _BasePath):
url = fspath
path = fspath
def relpath(self, other):
return self.__class__(relpath(self, other))
@@ -174,7 +176,7 @@ class URLInfo(_BasePath):
return self.url
def __repr__(self):
return "{}: '{}'".format(type(self).__name__, self)
return f"{type(self).__name__}: '{self}'"
def __eq__(self, other):
if isinstance(other, (str, bytes)):
+27
View File
@@ -4,6 +4,7 @@ import logging
import sys
from threading import RLock
import fsspec
from tqdm import tqdm
from dvc.env import DVC_IGNORE_ISATTY
@@ -136,6 +137,9 @@ class Tqdm(tqdm):
return wrapped
def as_callback(self, fs, path_info):
return FsspecCallback(fs, path_info, self)
def close(self):
self.postfix["info"] = ""
# remove ETA (either unknown or zero); remove completed bar
@@ -161,3 +165,26 @@ class Tqdm(tqdm):
d["ncols_desc"] = d["ncols_info"] = 1
d["prefix"] = ""
return d
class FsspecCallback(fsspec.Callback):
def __init__(self, fs, path_info, progress_bar):
self.fs = fs
self.path_info = path_info
self.progress_bar = progress_bar
super().__init__()
def set_size(self, size):
"""``set_size()`` is an API that might be called with ``None`` if the
information is not already present on the caller. In that case, we'll
retrieve the size from an ``info()`` call."""
if size is None:
size = self.fs.info(self.path_info)["size"]
self.progress_bar.total = size
self.progress_bar.refresh()
def relative_update(self, inc=1):
self.progress_bar.update(inc)
def absolute_update(self, value):
self.progress_bar.update_to(value)
-11
View File
@@ -1,11 +0,0 @@
from ..fs import get_cloud_fs
from .base import Remote
from .local import LocalRemote
def get_remote(repo, **kwargs):
cls, config, path_info = get_cloud_fs(repo, **kwargs)
fs = cls(**config)
if fs.scheme == "local":
return LocalRemote(fs, path_info, repo.tmp_dir, **config)
return Remote(fs, path_info, repo.tmp_dir, **config)
-565
View File
@@ -1,565 +0,0 @@
import errno
import hashlib
import itertools
import logging
from concurrent import futures
from concurrent.futures import ThreadPoolExecutor
from functools import partial, wraps
from dvc.exceptions import DownloadError, UploadError
from dvc.hash_info import HashInfo
from dvc.objects.tree import Tree
from ..progress import Tqdm
from .index import RemoteIndex
logger = logging.getLogger(__name__)
STATUS_OK = 1
STATUS_MISSING = 2
STATUS_NEW = 3
STATUS_DELETED = 4
STATUS_MAP = {
# (local_exists, remote_exists)
(True, True): STATUS_OK,
(False, False): STATUS_MISSING,
(True, False): STATUS_NEW,
(False, True): STATUS_DELETED,
}
def _log_exceptions(func, operation):
@wraps(func)
def wrapper(from_info, to_info, *args, **kwargs):
try:
func(from_info, to_info, *args, **kwargs)
return 0
except Exception as exc: # pylint: disable=broad-except
# NOTE: this means we ran out of file descriptors and there is no
# reason to try to proceed, as we will hit this error anyways.
# pylint: disable=no-member
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
raise
logger.exception(
"failed to %s '%s' to '%s'", operation, from_info, to_info
)
return 1
return wrapper
def index_locked(f):
@wraps(f)
def wrapper(obj, *args, **kwargs):
with obj.index:
return f(obj, *args, **kwargs)
return wrapper
class Remote:
"""Cloud remote class.
Provides methods for indexing and garbage collecting fss which contain
DVC remotes.
"""
INDEX_CLS = RemoteIndex
def __init__(self, fs, path_info, tmp_dir, **config):
from dvc.objects.db import get_odb
self.fs = fs
config["tmp_dir"] = tmp_dir
self.odb = get_odb(self.fs, path_info, **config)
url = getattr(path_info, "fspath", path_info.url)
index_name = hashlib.sha256(url.encode("utf-8")).hexdigest()
self.index = self.INDEX_CLS(
tmp_dir, index_name, dir_suffix=self.fs.CHECKSUM_DIR_SUFFIX
)
def __repr__(self):
return "{class_name}: '{path_info}'".format(
class_name=type(self).__name__,
path_info=self.odb.path_info or "No path",
)
def __eq__(self, other):
return self.odb == other.odb
def __hash__(self):
return hash(self.odb)
@classmethod
def from_odb(cls, odb):
config = odb.config
return cls(
odb.fs,
odb.path_info,
config.pop("tmp_dir"),
**config,
)
@index_locked
def gc(self, *args, **kwargs):
removed = self.odb.gc(*args, **kwargs)
if removed:
self.index.clear()
return removed
@index_locked
def status(
self,
cache,
objs,
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
# Return flattened dict containing all status info
dir_status, file_status, _ = self._status(
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=download,
log_missing=log_missing,
)
return dict(dir_status, **file_status)
def hashes_exist(self, hashes, **kwargs):
hashes = set(hashes)
indexed_hashes = set(self.index.intersection(hashes))
hashes -= indexed_hashes
indexed_hashes = list(indexed_hashes)
logger.debug("Matched '{}' indexed hashes".format(len(indexed_hashes)))
if not hashes:
return indexed_hashes
return indexed_hashes + self.odb.hashes_exist(list(hashes), **kwargs)
def _status( # pylint: disable=unused-argument
self,
cache,
objs,
jobs=None,
show_checksums=False,
download=False,
log_missing=True,
):
"""Return a tuple of (dir_status_info, file_status_info, dir_contents).
dir_status_info contains status for .dir files, file_status_info
contains status for all other files, and dir_contents is a dict of
{dir_hash: set(file_hash, ...)} which can be used to map
a .dir file to its file contents.
"""
logger.debug(f"Preparing to collect status from {self.odb.path_info}")
md5s = set()
dir_objs = {}
for obj in objs:
if obj.fs.scheme != cache.fs.scheme:
continue
md5s.add(obj.hash_info.value)
if isinstance(obj, Tree):
dir_objs[obj.hash_info.value] = obj
for _, entry_obj in obj:
md5s.add(entry_obj.hash_info.value)
logger.debug("Collecting information from local cache...")
local_exists = frozenset(
cache.hashes_exist(md5s, jobs=jobs, name=cache.cache_dir)
)
# This is a performance optimization. We can safely assume that,
# if the resources that we want to fetch are already cached,
# there's no need to check the remote storage for the existence of
# those files.
if download and local_exists == md5s:
remote_exists = local_exists
else:
logger.debug("Collecting information from remote cache...")
remote_exists = set()
if dir_objs:
remote_exists.update(self._indexed_dir_hashes(dir_objs))
md5s.difference_update(remote_exists)
if md5s:
remote_exists.update(
self.hashes_exist(
md5s, jobs=jobs, name=str(self.odb.path_info)
)
)
return self._make_status(
cache,
objs,
show_checksums,
local_exists,
remote_exists,
log_missing,
)
def _make_status(
self,
cache,
objs,
show_checksums,
local_exists,
remote_exists,
log_missing,
):
def make_names(obj):
if show_checksums or obj.name is None:
name = obj.hash_info.value
else:
name = obj.name
return {"name": name}
dir_status = {}
file_status = {}
dir_contents = {}
for obj in objs:
if obj.fs.scheme != cache.fs.scheme:
continue
hash_ = obj.hash_info.value
if isinstance(obj, Tree):
dir_status[hash_] = make_names(obj)
dir_contents[hash_] = set()
for _, child_obj in obj:
child_hash = child_obj.hash_info.value
file_status[child_hash] = make_names(child_obj)
dir_contents[hash_].add(child_hash)
else:
file_status[hash_] = make_names(obj)
self._fill_statuses(dir_status, local_exists, remote_exists)
self._fill_statuses(file_status, local_exists, remote_exists)
if log_missing:
self._log_missing_caches(dict(dir_status, **file_status))
return dir_status, file_status, dir_contents
def _indexed_dir_hashes(self, dir_objs):
# Validate our index by verifying all indexed .dir hashes
# still exist on the remote
dir_md5s = set(dir_objs.keys())
indexed_dirs = set(self.index.dir_hashes())
indexed_dir_exists = set()
if indexed_dirs:
indexed_dir_exists.update(
self.odb.list_hashes_exists(indexed_dirs)
)
missing_dirs = indexed_dirs.difference(indexed_dir_exists)
if missing_dirs:
logger.debug(
"Remote cache missing indexed .dir hashes '{}', "
"clearing remote index".format(", ".join(missing_dirs))
)
self.index.clear()
# Check if non-indexed (new) dir hashes exist on remote
dir_exists = dir_md5s.intersection(indexed_dir_exists)
dir_exists.update(self.odb.list_hashes_exists(dir_md5s - dir_exists))
# If .dir hash exists on the remote, assume directory contents
# still exists on the remote
for dir_hash in dir_exists:
file_hashes = [
obj.hash_info.value for _, obj in dir_objs[dir_hash]
]
if dir_hash not in self.index:
logger.debug(
"Indexing new .dir '{}' with '{}' nested files".format(
dir_hash, len(file_hashes)
)
)
self.index.update([dir_hash], file_hashes)
yield dir_hash
yield from file_hashes
@staticmethod
def _fill_statuses(hash_info_dir, local_exists, remote_exists):
# Using sets because they are way faster for lookups
local = set(local_exists)
remote = set(remote_exists)
for md5, info in hash_info_dir.items():
status = STATUS_MAP[(md5 in local, md5 in remote)]
info["status"] = status
def _get_plans(self, cache_obj, download, status_info, status):
cache = []
path_infos = []
names = []
hashes = []
missing = []
for md5, info in Tqdm(
status_info.items(), desc="Analysing status", unit="file"
):
if info["status"] == status:
cache.append(cache_obj.hash_to_path_info(md5))
path_infos.append(self.odb.hash_to_path_info(md5))
names.append(info["name"])
hashes.append(md5)
elif info["status"] == STATUS_MISSING:
missing.append(md5)
if download:
to_infos = cache
from_infos = path_infos
else:
to_infos = path_infos
from_infos = cache
return (from_infos, to_infos, names, hashes), missing
def _process(
self,
cache,
objs,
jobs=None,
show_checksums=False,
download=False,
):
logger.debug(
"Preparing to {} '{}'".format(
"download data from" if download else "upload data to",
self.odb.path_info,
)
)
if download:
func = _log_exceptions(self.fs.download_file, "download")
status = STATUS_DELETED
desc = "Downloading"
else:
func = _log_exceptions(self.fs.upload, "upload")
status = STATUS_NEW
desc = "Uploading"
if jobs is None:
jobs = self.fs.jobs
dir_status, file_status, dir_contents = self._status(
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=download,
)
dir_plans, _ = self._get_plans(cache, download, dir_status, status)
file_plans, missing_files = self._get_plans(
cache, download, file_status, status
)
total = len(dir_plans[0]) + len(file_plans[0])
if total == 0:
return 0
with Tqdm(total=total, unit="file", desc=desc) as pbar:
func = pbar.wrap_fn(func)
self._process_plans(
download,
dir_plans,
file_plans,
dir_contents,
missing_files,
jobs,
func,
)
return total
def _process_plans(
self,
download,
dir_plans,
file_plans,
dir_contents,
missing_files,
jobs,
func,
):
with ThreadPoolExecutor(max_workers=jobs) as executor:
processor = partial(self._create_tasks, executor, jobs, func)
processor.transfer_func = func
if download:
self._download_plans(dir_plans, file_plans, processor)
else:
self._upload_plans(
dir_plans,
file_plans,
dir_contents,
missing_files,
processor,
)
def _create_tasks(self, executor, jobs, func, file_plans):
fails = 0
file_plan_iterator = iter(file_plans)
def create_taskset(amount):
return {
executor.submit(func, from_info, to_info, name)
for from_info, to_info, name, _ in itertools.islice(
file_plan_iterator, amount
)
}
tasks = create_taskset(jobs * 5)
while tasks:
done, tasks = futures.wait(
tasks, return_when=futures.FIRST_COMPLETED
)
fails += sum(task.result() for task in done)
tasks.update(create_taskset(len(done)))
return fails
def _download_plans(self, dir_plans, file_plans, processor):
plans = [*zip(*dir_plans), *zip(*file_plans)]
fails = processor(plans)
if fails:
self.index.clear()
raise DownloadError(fails)
def _upload_plans(
self, dir_plans, file_plans, dir_contents, missing_files, processor
):
total_fails = 0
succeeded_dir_hashes = []
all_file_plans = list(zip(*file_plans))
for dir_from_info, dir_to_info, dir_name, dir_hash in zip(*dir_plans):
bound_file_plans = []
directory_hashes = dir_contents[dir_hash]
for file_plan in all_file_plans.copy():
if file_plan[-1] in directory_hashes:
bound_file_plans.append(file_plan)
all_file_plans.remove(file_plan)
dir_fails = processor(bound_file_plans)
if dir_fails:
logger.debug(
"failed to upload full contents of '{}', "
"aborting .dir file upload".format(dir_name)
)
logger.error(
f"failed to upload '{dir_from_info}'"
f" to '{dir_to_info}'"
)
total_fails += dir_fails + 1
elif directory_hashes.intersection(missing_files):
# 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_name,
)
else:
is_dir_failed = processor.transfer_func(
dir_from_info, dir_to_info, dir_name
)
total_fails += is_dir_failed
if not is_dir_failed:
succeeded_dir_hashes.append(dir_hash)
# insert the rest
total_fails += processor(all_file_plans)
if total_fails:
raise UploadError(total_fails)
# index successfully pushed dirs
for dir_hash in succeeded_dir_hashes:
file_hashes = dir_contents[dir_hash]
logger.debug(
"Indexing pushed dir '{}' with "
"'{}' nested files".format(dir_hash, len(file_hashes))
)
self.index.update([dir_hash], file_hashes)
@index_locked
def push(self, cache, objs, jobs=None, show_checksums=False):
ret = self._process(
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=False,
)
if self.fs.scheme == "local":
for obj in objs:
if obj.fs.scheme != "local":
continue
checksum = obj.hash_info.value
cache_file = self.odb.hash_to_path_info(checksum)
if self.fs.exists(cache_file):
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, checksum)
self.odb.state.save(cache_file, self.fs, hash_info)
self.odb.protect(cache_file)
return ret
@index_locked
def pull(self, cache, objs, jobs=None, show_checksums=False):
ret = self._process(
cache,
objs,
jobs=jobs,
show_checksums=show_checksums,
download=True,
)
if not self.odb.verify:
def save_state(obj):
checksum = obj.hash_info.value
cache_file = cache.hash_to_path_info(checksum)
if cache.fs.exists(cache_file):
# We can safely save here, as existing corrupted files
# will be removed upon status, while files corrupted
# during download will not be moved from tmp_file
# (see `BaseFileSystem.download()`)
hash_info = HashInfo(cache.fs.PARAM_CHECKSUM, checksum)
cache.state.save(cache_file, cache.fs, hash_info)
cache.protect(cache_file)
for obj in objs:
if obj.fs.scheme != "local":
continue
save_state(obj)
if isinstance(obj, Tree):
for _, entry_obj in obj:
save_state(entry_obj)
return ret
@staticmethod
def _log_missing_caches(hash_info_dict):
missing_caches = [
(md5, info)
for md5, info in hash_info_dict.items()
if info["status"] == STATUS_MISSING
]
if missing_caches:
missing_desc = "\n".join(
"name: {}, md5: {}".format(info["name"], md5)
for md5, info in missing_caches
)
msg = (
"Some of the cache files do not exist neither locally "
"nor on remote. Missing cache files:\n{}".format(missing_desc)
)
logger.warning(msg)
-213
View File
@@ -1,213 +0,0 @@
import logging
import os
import sqlite3
import threading
from funcy import lchunks
from dvc.state import _connect_sqlite
from dvc.utils.fs import makedirs
logger = logging.getLogger(__name__)
class RemoteIndexNoop:
"""No-op class for remotes which are not indexed (i.e. local)."""
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
pass
def __exit__(self, typ, value, tbck):
pass
def __iter__(self):
return iter([])
def __contains__(self, hash_):
return False
@staticmethod
def hashes():
return []
@staticmethod
def dir_hashes():
return []
def load(self):
pass
def dump(self):
pass
def clear(self):
pass
def update(self, *args):
pass
@staticmethod
def intersection(*args):
return []
class RemoteIndex:
"""Class for indexing remote hashes in a sqlite3 database.
Args:
repo: repo for this remote index.
name: name for this index. Index db will be loaded from and saved to
``.dvc/tmp/index/{name}.idx``.
dir_suffix: suffix used for naming directory hashes
"""
INDEX_SUFFIX = ".idx"
VERSION = 1
INDEX_TABLE = "remote_index"
INDEX_TABLE_LAYOUT = "checksum TEXT PRIMARY KEY, dir INTEGER NOT NULL"
INDEX_DIR = "index"
def __init__(self, tmp_dir, name, dir_suffix=".dir"):
self.path = os.path.join(
tmp_dir, self.INDEX_DIR, f"{name}{self.INDEX_SUFFIX}"
)
self.dir_suffix = dir_suffix
self.database = None
self.cursor = None
self.modified = False
self.lock = threading.Lock()
def __iter__(self):
cmd = f"SELECT checksum FROM {self.INDEX_TABLE}"
for (hash_,) in self._execute(cmd):
yield hash_
def __enter__(self):
self.lock.acquire()
self.load()
def __exit__(self, typ, value, tbck):
self.dump()
self.lock.release()
def __contains__(self, hash_):
cmd = "SELECT checksum FROM {} WHERE checksum = (?)".format(
self.INDEX_TABLE
)
self._execute(cmd, (hash_,))
return self.cursor.fetchone() is not None
def hashes(self):
"""Iterate over hashes stored in the index."""
return iter(self)
def dir_hashes(self):
"""Iterate over .dir hashes stored in the index."""
cmd = f"SELECT checksum FROM {self.INDEX_TABLE} WHERE dir = 1"
for (hash_,) in self._execute(cmd):
yield hash_
def is_dir_hash(self, hash_):
return hash_.endswith(self.dir_suffix)
def _execute(self, cmd, parameters=()):
return self.cursor.execute(cmd, parameters)
def _executemany(self, cmd, seq_of_parameters):
return self.cursor.executemany(cmd, seq_of_parameters)
def _prepare_db(self, empty=False):
if not empty:
cmd = "PRAGMA user_version;"
self._execute(cmd)
ret = self.cursor.fetchall()
assert len(ret) == 1
assert len(ret[0]) == 1
assert isinstance(ret[0][0], int)
version = ret[0][0]
if version != self.VERSION:
logger.error(
"Index file version '{}' will be reformatted "
"to the current version '{}'.".format(
version, self.VERSION
)
)
cmd = "DROP TABLE IF EXISTS {};"
self._execute(cmd.format(self.INDEX_TABLE))
cmd = "CREATE TABLE IF NOT EXISTS {} ({})"
self._execute(cmd.format(self.INDEX_TABLE, self.INDEX_TABLE_LAYOUT))
cmd = "PRAGMA user_version = {};"
self._execute(cmd.format(self.VERSION))
def load(self):
"""(Re)load this index database."""
retries = 1
while True:
assert self.database is None
assert self.cursor is None
empty = not os.path.isfile(self.path)
makedirs(os.path.dirname(self.path), exist_ok=True)
self.database = _connect_sqlite(self.path, {"nolock": 1})
self.cursor = self.database.cursor()
try:
self._prepare_db(empty=empty)
return
except sqlite3.DatabaseError:
self.cursor.close()
self.database.close()
self.database = None
self.cursor = None
if retries > 0:
os.unlink(self.path)
retries -= 1
else:
raise
def dump(self):
"""Save this index database."""
assert self.database is not None
self.database.commit()
self.cursor.close()
self.database.close()
self.database = None
self.cursor = None
def clear(self):
"""Clear this index (to force re-indexing later).
Changes to the index will not committed until dump() is called.
"""
cmd = f"DELETE FROM {self.INDEX_TABLE}"
self._execute(cmd)
def update(self, dir_hashes, file_hashes):
"""Update this index, adding the specified hashes.
Changes to the index will not committed until dump() is called.
"""
cmd = "INSERT OR IGNORE INTO {} (checksum, dir) VALUES (?, ?)".format(
self.INDEX_TABLE
)
self._executemany(cmd, ((hash_, True) for hash_ in dir_hashes))
self._executemany(cmd, ((hash_, False) for hash_ in file_hashes))
def intersection(self, hashes):
"""Iterate over values from `hashes` which exist in the index."""
# sqlite has a compile time limit of 999, see:
# https://www.sqlite.org/c3ref/c_limit_attached.html#sqlitelimitvariablenumber
for chunk in lchunks(999, hashes):
cmd = "SELECT checksum FROM {} WHERE checksum IN ({})".format(
self.INDEX_TABLE, ",".join("?" for hash_ in chunk)
)
for (hash_,) in self._execute(cmd, chunk):
yield hash_
-6
View File
@@ -1,6 +0,0 @@
from .base import Remote
from .index import RemoteIndexNoop
class LocalRemote(Remote):
INDEX_CLS = RemoteIndexNoop # type: ignore[assignment]
+34 -84
View File
@@ -3,9 +3,9 @@ import os
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from typing import TYPE_CHECKING, Callable, Optional
from typing import TYPE_CHECKING, Callable, Optional, Set
from funcy import cached_property, cat
from funcy import cached_property
from dvc.exceptions import FileMissingError
from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
@@ -14,11 +14,9 @@ from dvc.ignore import DvcIgnoreFilter
from dvc.path_info import PathInfo
from dvc.utils.fs import path_isin
from .graph import build_graph, build_outs_graph, get_pipelines
from .trie import build_outs_trie
if TYPE_CHECKING:
from dvc.fs.base import BaseFileSystem
from dvc.objects.file import HashFile
from dvc.scm import Base
@@ -57,7 +55,6 @@ class Repo:
DVC_DIR = ".dvc"
from dvc.repo.add import add
from dvc.repo.brancher import brancher
from dvc.repo.checkout import checkout
from dvc.repo.commit import commit
from dvc.repo.destroy import destroy
@@ -207,6 +204,12 @@ class Repo:
def __str__(self):
return self.url or self.root_dir
@cached_property
def index(self):
from dvc.repo.index import Index
return Index(self)
@staticmethod
def open(url, *args, **kwargs):
if url is None:
@@ -323,23 +326,10 @@ class Repo:
self.scm.ignore_list(flist)
def check_modified_graph(self, new_stages, old_stages=None):
"""Generate graph including the new stage to check for errors"""
# Building graph might be costly for the ones with many DVC-files,
# so we provide this undocumented hack to skip it. See [1] for
# more details. The hack can be used as:
#
# repo = Repo(...)
# repo._skip_graph_checks = True
# repo.add(...)
#
# A user should care about not duplicating outs and not adding cycles,
# otherwise DVC might have an undefined behaviour.
#
# [1] https://github.com/iterative/dvc/issues/2671
if not getattr(self, "_skip_graph_checks", False):
existing_stages = self.stages if old_stages is None else old_stages
build_graph(existing_stages + new_stages)
def brancher(self, *args, **kwargs):
from dvc.repo.brancher import brancher
return brancher(self, *args, **kwargs)
def used_objs(
self,
@@ -373,16 +363,14 @@ class Repo:
"""
used = defaultdict(set)
def _add_suffix(objs, suffix):
from dvc.objects.tree import Tree
def _add_suffix(objs: Set["HashFile"], suffix: str) -> None:
from itertools import chain
for obj in objs:
from dvc.objects import iterobjs
for obj in chain.from_iterable(map(iterobjs, objs)):
if obj.name is not None:
obj.name += suffix
if isinstance(obj, Tree):
for _, entry_obj in obj:
if entry_obj.name is not None:
entry_obj.name += suffix
for branch in self.brancher(
revs=revs,
@@ -391,25 +379,17 @@ class Repo:
all_commits=all_commits,
all_experiments=all_experiments,
):
targets = targets or [None]
pairs = cat(
self.stage.collect_granular(
target, recursive=recursive, with_deps=with_deps
)
for target in targets
)
for stage, filter_info in pairs:
for odb, objs in stage.get_used_objs(
remote=remote,
force=force,
jobs=jobs,
filter_info=filter_info,
).items():
if branch:
_add_suffix(objs, f" ({branch})")
used[odb].update(objs)
for odb, objs in self.index.used_objs(
targets,
remote=remote,
force=force,
jobs=jobs,
recursive=recursive,
with_deps=with_deps,
).items():
if branch:
_add_suffix(objs, f" ({branch})")
used[odb].update(objs)
if used_run_cache:
for odb, objs in self.stage_cache.get_used_objs(
@@ -419,39 +399,13 @@ class Repo:
return used
@cached_property
def outs_trie(self):
return build_outs_trie(self.stages)
@cached_property
def graph(self):
return build_graph(self.stages, self.outs_trie)
@cached_property
def outs_graph(self):
return build_outs_graph(self.graph, self.outs_trie)
@cached_property
def pipelines(self):
return get_pipelines(self.graph)
@cached_property
def stages(self):
"""
Walks down the root directory looking for Dvcfiles,
skipping the directories that are related with
any SCM (e.g. `.git`), DVC itself (`.dvc`), or directories
tracked by DVC (e.g. `dvc add data` would skip `data/`)
NOTE: For large repos, this could be an expensive
operation. Consider using some memoization.
"""
error_handler = self.stage_collection_error_handler
return self.stage.collect_repo(onerror=error_handler)
@property
def stages(self): # obsolete, only for backward-compatibility
return self.index.stages
def find_outs_by_path(self, path, outs=None, recursive=False, strict=True):
# using `outs_graph` to ensure graph checks are run
outs = outs or self.outs_graph
outs = outs or self.index.outs_graph
abs_path = os.path.abspath(path)
path_info = PathInfo(abs_path)
@@ -512,11 +466,7 @@ class Repo:
def _reset(self):
self.state.close()
self.scm._reset() # pylint: disable=protected-access
self.__dict__.pop("outs_trie", None)
self.__dict__.pop("outs_graph", None)
self.__dict__.pop("graph", None)
self.__dict__.pop("stages", None)
self.__dict__.pop("pipelines", None)
self.__dict__.pop("index", None)
self.__dict__.pop("dvcignore", None)
def __enter__(self):
+11 -6
View File
@@ -43,7 +43,14 @@ def check_recursive_and_fname(args):
def transform_targets(args):
args.targets = ensure_list(args.targets)
from funcy import count_reps
counts = count_reps(ensure_list(args.targets))
dupes = [key for key, count in counts.items() if count > 1]
if dupes:
msg = ", ".join(f"[b]{key}[/]" for key in dupes)
ui.error_write(f"ignoring duplicated targets: {msg}", styled=True)
args.targets = list(counts)
def check_arg_combinations(args):
@@ -162,19 +169,17 @@ def add( # noqa: C901
desc = "Collecting targets"
stages_it = create_stages(repo, add_targets, fname, transfer, **kwargs)
stages = list(ui.progress(stages_it, desc=desc, unit="file"))
msg = "Collecting stages from the workspace"
with translate_graph_error(stages), ui.status(msg) as status:
# remove existing stages that are to-be replaced with these
# new stages for the graph checks.
old_stages = set(repo.stages) - set(stages)
new_index = repo.index.update(stages)
status.update("Checking graph")
repo.check_modified_graph(stages, list(old_stages))
new_index.check_graph()
odb = None
if to_remote:
remote = repo.cloud.get_remote(kwargs.get("remote"), "add")
odb = remote.odb
odb = repo.cloud.get_remote_odb(kwargs.get("remote"), "add")
with warn_link_failures() as link_failures:
for stage, source in zip(progress_iter(stages), sources):
+1 -7
View File
@@ -28,13 +28,7 @@ def _fspath_dir(path):
def _remove_unused_links(repo):
used = [
out.fspath
for stage in repo.stages
for out in stage.outs
if out.scheme == "local"
]
used = [out.fspath for out in repo.index.outs if out.scheme == "local"]
unused = repo.state.get_unused_links(used, repo.fs)
ret = [_fspath_dir(u) for u in unused]
repo.state.remove_links(unused, repo.fs)
+9 -15
View File
@@ -20,12 +20,9 @@ DvcPaths = List[DvcPath]
def _collect_outs(
repo: "Repo", output_filter: FilterFn = None, deps: bool = False
) -> Outputs:
outs = [
out
for stage in repo.graph # using `graph` to ensure graph checks run
for out in (stage.deps if deps else stage.outs)
]
return list(filter(output_filter, outs)) if output_filter else outs
index = repo.index
index.check_graph() # ensure graph is correct
return list(filter(output_filter, index.deps if deps else index.outs))
def _collect_paths(
@@ -46,15 +43,12 @@ def _collect_paths(
target_infos.extend(repo.dvcignore.walk_files(fs, path_info))
if not fs.exists(path_info):
if not recursive:
if rev == "workspace" or rev == "":
logger.warning(
"'%s' was not found in current workspace.", path_info
)
else:
logger.warning(
"'%s' was not found at: '%s'.", path_info, rev
)
if rev == "workspace" or rev == "":
logger.warning(
"'%s' was not found in current workspace.", path_info
)
else:
logger.warning("'%s' was not found at: '%s'.", path_info, rev)
continue
target_infos.append(path_info)
return target_infos
+1 -1
View File
@@ -5,7 +5,7 @@ from . import locked
@locked
def _destroy_stages(repo):
for stage in repo.stages:
for stage in repo.index.stages:
stage.unprotect_outs()
stage.dvcfile.remove(force=True)
+19 -16
View File
@@ -5,7 +5,6 @@ from typing import Dict, List
from dvc.exceptions import PathMissingError
from dvc.objects.stage import get_file_hash
from dvc.objects.stage import stage as ostage
from dvc.repo import locked
from dvc.repo.experiments.utils import fix_exp_head
@@ -116,6 +115,7 @@ def _paths_checksums(repo, repo_fs, targets):
def _output_paths(repo, repo_fs, targets):
from dvc.fs.local import LocalFileSystem
from dvc.objects.stage import stage as ostage
on_working_fs = isinstance(repo.fs, LocalFileSystem)
@@ -134,25 +134,28 @@ def _output_paths(repo, repo_fs, targets):
def _to_checksum(output):
if on_working_fs:
return ostage(
repo.odb.local, output.path_info, repo.odb.local.fs, "md5"
).hash_info.value
repo.odb.local,
output.path_info,
repo.odb.local.fs,
"md5",
dry_run=True,
)[1].hash_info.value
return output.hash_info.value
for stage in repo.stages:
for output in stage.outs:
if _exists(output):
yield_output = targets is None or any(
output.path_info.isin_or_eq(target) for target in targets
)
for output in repo.index.outs:
if _exists(output):
yield_output = targets is None or any(
output.path_info.isin_or_eq(target) for target in targets
)
if yield_output:
yield _to_path(output), _to_checksum(output)
if yield_output:
yield _to_path(output), _to_checksum(output)
if output.is_dir_checksum and (
yield_output
or any(target.isin(output.path_info) for target in targets)
):
yield from _dir_output_paths(repo_fs, output, targets)
if output.is_dir_checksum and (
yield_output
or any(target.isin(output.path_info) for target in targets)
):
yield from _dir_output_paths(repo_fs, output, targets)
def _dir_output_paths(repo_fs, output, targets=None):
+100 -30
View File
@@ -81,6 +81,7 @@ class Experiments:
r"(?P<checkpoint>-checkpoint)?$"
)
EXEC_TMP_DIR = "exps"
EXEC_PID_DIR = os.path.join(EXEC_TMP_DIR, "run")
StashEntry = namedtuple(
"StashEntry", ["index", "rev", "baseline_rev", "branch", "name"]
@@ -351,8 +352,7 @@ class Experiments:
def _update_params(self, params: dict):
"""Update experiment params files with the specified values."""
from benedict import benedict
from dvc.utils.collections import merge_params
from dvc.utils.serialize import MODIFIERS
logger.debug("Using experiment params '%s'", params)
@@ -362,7 +362,7 @@ class Experiments:
suffix = path.suffix.lower()
modify_data = MODIFIERS[suffix]
with modify_data(path, fs=self.repo.fs) as data:
benedict(data).merge(params[params_fname], overwrite=True)
merge_params(data, params[params_fname])
# Force params file changes to be staged in git
# Otherwise in certain situations the changes to params file may be
@@ -455,7 +455,7 @@ class Experiments:
)
logger.info(
"\nTo promote an experiment to a Git branch run:\n\n"
"\tdvc exp branch <exp>\n"
"\tdvc exp branch <exp> <branch>\n"
)
@scm_locked
@@ -584,30 +584,25 @@ class Experiments:
logger.debug(
"Reproducing experiment revs '%s'",
", ".join((rev[:7] for rev in to_run)),
", ".join(rev[:7] for rev in to_run),
)
executors = self._init_executors(to_run)
exec_results = self._executors_repro(executors, **kwargs)
if keep_stash:
try:
exec_results = {}
exec_results.update(self._executors_repro(executors, **kwargs))
finally:
# only drop successfully run stashed experiments
to_drop = sorted(
(
stash_revs[rev][0]
for rev in exec_results
if rev in stash_revs
),
reverse=True,
)
else:
# drop all stashed experiments
to_drop = sorted(
(stash_revs[rev][0] for rev in to_run if rev in stash_revs),
reverse=True,
)
for index in to_drop:
self.stash.drop(index)
to_drop = [
entry.index
for rev, entry in to_run.items()
if (
entry.index is not None
and (not keep_stash or rev in exec_results)
)
]
for index in sorted(to_drop, reverse=True):
self.stash.drop(index)
result: Dict[str, str] = {}
for _, exp_result in exec_results.items():
@@ -623,6 +618,9 @@ class Experiments:
base_tmp_dir = os.path.join(self.repo.tmp_dir, self.EXEC_TMP_DIR)
if not os.path.exists(base_tmp_dir):
makedirs(base_tmp_dir)
pid_dir = os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
if not os.path.exists(pid_dir):
makedirs(pid_dir)
for stash_rev, item in to_run.items():
self.scm.set_ref(EXEC_HEAD, item.rev)
self.scm.set_ref(EXEC_MERGE, stash_rev)
@@ -649,9 +647,10 @@ class Experiments:
return executors
@unlocked_repo
def _executors_repro(
self, executors: dict, jobs: Optional[int] = 1
) -> Mapping[str, Mapping[str, str]]:
) -> Dict[str, Dict[str, str]]:
"""Run dvc repro for the specified BaseExecutors in parallel.
Returns:
@@ -667,6 +666,11 @@ class Experiments:
with ProcessPoolExecutor(max_workers=jobs) as workers:
futures = {}
for rev, executor in executors.items():
pidfile = os.path.join(
self.repo.tmp_dir,
self.EXEC_PID_DIR,
f"{rev}{executor.PIDFILE_EXT}",
)
future = workers.submit(
executor.reproduce,
executor.dvc_dir,
@@ -675,6 +679,8 @@ class Experiments:
name=executor.name,
rel_cwd=rel_cwd,
log_level=logger.getEffectiveLevel(),
pidfile=pidfile,
git_url=executor.git_url,
)
futures[future] = (rev, executor)
@@ -683,12 +689,15 @@ class Experiments:
except KeyboardInterrupt:
# forward SIGINT to any running executor processes and
# cancel any remaining futures
workers.shutdown(wait=False)
pids = {}
while not pid_q.empty():
rev, pid = pid_q.get()
pids[rev] = pid
for future, (rev, _) in futures.items():
if future.running():
# if future has already been started by the scheduler
# we still have to wait until it tells us its PID
while rev not in pids:
rev, pid = pid_q.get()
pids[rev] = pid
os.kill(pids[rev], signal.SIGINT)
elif not future.done():
future.cancel()
@@ -735,7 +744,10 @@ class Experiments:
raise ExperimentExistsError(ref_info.name)
for ref in executor.fetch_exps(
self.scm, force=exec_result.force, on_diverged=on_diverged
self.scm,
executor.git_url,
force=exec_result.force,
on_diverged=on_diverged,
):
exp_rev = self.scm.get_ref(ref)
if exp_rev:
@@ -747,6 +759,8 @@ class Experiments:
@unlocked_repo
def _workspace_repro(self) -> Mapping[str, str]:
"""Run the most recently stashed experiment in the workspace."""
from dvc.utils.fs import makedirs
from .executor.base import BaseExecutor
entry = first(self.stash_revs.values())
@@ -766,12 +780,19 @@ class Experiments:
self.scm.remove_ref(EXEC_BRANCH)
try:
orig_checkpoint = self.scm.get_ref(EXEC_CHECKPOINT)
pid_dir = os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
if not os.path.exists(pid_dir):
makedirs(pid_dir)
pidfile = os.path.join(
pid_dir, f"workspace{BaseExecutor.PIDFILE_EXT}"
)
exec_result = BaseExecutor.reproduce(
None,
rev,
name=entry.name,
rel_cwd=relpath(os.getcwd(), self.scm.root_dir),
log_errors=False,
pidfile=pidfile,
)
if not exec_result.exp_hash:
@@ -858,9 +879,58 @@ class Experiments:
exclude = f"{EXEC_NAMESPACE}/*"
ref = self.scm.describe(rev, base=EXPS_NAMESPACE, exclude=exclude)
if ref:
return ExpRefInfo.from_ref(ref).name
name = ExpRefInfo.from_ref(ref).name
if name:
return name
if rev in self.stash_revs:
return self.stash_revs[rev].name
return None
def get_running_exps(self) -> Dict[str, int]:
"""Return info for running experiments."""
from dvc.utils.serialize import load_yaml
from .executor.base import BaseExecutor, ExecutorInfo
result = {}
for pidfile in self.repo.fs.walk_files(
os.path.join(self.repo.tmp_dir, self.EXEC_PID_DIR)
):
rev, _ = os.path.splitext(os.path.basename(pidfile))
try:
info = ExecutorInfo.from_dict(load_yaml(pidfile))
if rev == "workspace":
# If we are appending to a checkpoint branch in a workspace
# run, show the latest checkpoint as running.
last_rev = self.scm.get_ref(EXEC_BRANCH)
if last_rev:
result[last_rev] = info.to_dict()
else:
result[rev] = info.to_dict()
else:
result[rev] = info.to_dict()
if info.git_url:
def on_diverged(_ref: str, _checkpoint: bool):
return False
for ref in BaseExecutor.fetch_exps(
self.scm,
info.git_url,
on_diverged=on_diverged,
):
logger.debug(
"Updated running experiment '%s'.", ref
)
last_rev = self.scm.get_ref(ref)
result[rev]["last"] = last_rev
if last_rev:
result[last_rev] = info.to_dict()
except OSError:
pass
return result
def apply(self, *args, **kwargs):
from dvc.repo.experiments.apply import apply
+2 -2
View File
@@ -35,8 +35,8 @@ def diff(repo, *args, a_rev=None, b_rev=None, param_deps=False, **kwargs):
return {
key: _diff(
format_dict(old[key]),
format_dict(new[key]),
format_dict(old.get("data", {}).get(key, {})),
format_dict(new.get("data", {}).get(key, {})),
with_unchanged=with_unchanged,
)
for key in ["metrics", "params"]
+128 -13
View File
@@ -3,6 +3,7 @@ import os
import pickle
from abc import ABC, abstractmethod
from contextlib import contextmanager
from dataclasses import dataclass
from functools import partial
from typing import (
TYPE_CHECKING,
@@ -15,8 +16,10 @@ from typing import (
from funcy import cached_property
from dvc.env import DVC_EXP_AUTO_PUSH, DVC_EXP_GIT_REMOTE
from dvc.exceptions import DvcException
from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.repo.experiments.base import (
EXEC_BASELINE,
EXEC_BRANCH,
@@ -35,7 +38,7 @@ from dvc.scm import SCM
from dvc.stage import PipelineStage
from dvc.stage.monitor import CheckpointKilledError
from dvc.stage.serialize import to_lockfile
from dvc.utils import dict_sha256
from dvc.utils import dict_sha256, env2bool
from dvc.utils.fs import remove
if TYPE_CHECKING:
@@ -52,6 +55,36 @@ class ExecutorResult(NamedTuple):
force: bool
@dataclass
class ExecutorInfo:
PARAM_PID = "pid"
PARAM_GIT_URL = "git"
PARAM_BASELINE_REV = "baseline"
PARAM_LOCATION = "location"
pid: Optional[int]
git_url: Optional[str]
baseline_rev: Optional[str]
location: Optional[str]
@classmethod
def from_dict(cls, d):
return cls(
d.get(cls.PARAM_PID),
d.get(cls.PARAM_GIT_URL),
d.get(cls.PARAM_BASELINE_REV),
d.get(cls.PARAM_LOCATION),
)
def to_dict(self):
return {
self.PARAM_PID: self.pid,
self.PARAM_GIT_URL: self.git_url,
self.PARAM_BASELINE_REV: self.baseline_rev,
self.PARAM_LOCATION: self.location,
}
class BaseExecutor(ABC):
"""Base class for executing experiments in parallel.
@@ -66,6 +99,8 @@ class BaseExecutor(ABC):
PACKED_ARGS_FILE = "repro.dat"
WARN_UNTRACKED = False
QUIET = False
PIDFILE_EXT = ".run"
DEFAULT_LOCATION: Optional[str] = "workspace"
def __init__(
self,
@@ -161,9 +196,11 @@ class BaseExecutor(ABC):
data = pickle.load(fobj)
return data["args"], data["kwargs"]
@classmethod
def fetch_exps(
self,
cls,
dest_scm: "Git",
url: str,
force: bool = False,
on_diverged: Callable[[str, bool], None] = None,
) -> Iterable[str]:
@@ -171,13 +208,17 @@ class BaseExecutor(ABC):
Args:
dest_scm: Destination Git instance.
url: Git remote URL to fetch from.
force: If True, diverged refs will be overwritten
on_diverged: Callback in the form on_diverged(ref, is_checkpoint)
to be called when an experiment ref has diverged.
"""
refs = []
for ref in self.scm.iter_refs(base=EXPS_NAMESPACE):
if not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
has_checkpoint = False
for ref in dest_scm.iter_remote_refs(url, base=EXPS_NAMESPACE):
if ref == EXEC_CHECKPOINT:
has_checkpoint = True
elif not ref.startswith(EXEC_NAMESPACE) and ref != EXPS_STASH:
refs.append(ref)
def on_diverged_ref(orig_ref: str, new_rev: str):
@@ -185,29 +226,43 @@ class BaseExecutor(ABC):
logger.debug("Replacing existing experiment '%s'", orig_ref)
return True
checkpoint = self.scm.get_ref(EXEC_CHECKPOINT) is not None
self._raise_ref_conflict(dest_scm, orig_ref, new_rev, checkpoint)
cls._raise_ref_conflict(
dest_scm, orig_ref, new_rev, has_checkpoint
)
if on_diverged:
on_diverged(orig_ref, checkpoint)
on_diverged(orig_ref, has_checkpoint)
logger.debug("Reproduced existing experiment '%s'", orig_ref)
return False
# fetch experiments
dest_scm.fetch_refspecs(
self.git_url,
url,
[f"{ref}:{ref}" for ref in refs],
on_diverged=on_diverged_ref,
force=force,
)
# update last run checkpoint (if it exists)
if self.scm.get_ref(EXEC_CHECKPOINT):
if has_checkpoint:
dest_scm.fetch_refspecs(
self.git_url,
url,
[f"{EXEC_CHECKPOINT}:{EXEC_CHECKPOINT}"],
force=True,
)
return refs
@classmethod
def _validate_remotes(cls, dvc: "Repo", git_remote: Optional[str]):
if git_remote == dvc.root_dir:
logger.warning(
f"'{git_remote}' points to the current Git repo, experiment "
"Git refs will not be pushed. But DVC cache and run cache "
"will automatically be pushed to the default DVC remote "
"(if any) on each experiment commit."
)
dvc.scm.validate_git_remote(git_remote)
dvc.cloud.get_remote_odb()
@classmethod
def reproduce(
cls,
@@ -218,6 +273,7 @@ class BaseExecutor(ABC):
name: Optional[str] = None,
log_errors: bool = True,
log_level: Optional[int] = None,
**kwargs,
) -> "ExecutorResult":
"""Run dvc repro and return the result.
@@ -229,6 +285,9 @@ class BaseExecutor(ABC):
from dvc.repo.checkout import checkout as dvc_checkout
from dvc.repo.reproduce import reproduce as dvc_reproduce
auto_push = env2bool(DVC_EXP_AUTO_PUSH)
git_remote = os.getenv(DVC_EXP_GIT_REMOTE, None)
unchanged = []
if queue is not None:
@@ -245,7 +304,15 @@ class BaseExecutor(ABC):
exp_ref: Optional["ExpRefInfo"] = None
repro_force: bool = False
with cls._repro_dvc(dvc_dir, rel_cwd, log_errors) as dvc:
with cls._repro_dvc(
dvc_dir,
rel_cwd,
log_errors,
**kwargs,
) as dvc:
if auto_push:
cls._validate_remotes(dvc, git_remote)
args, kwargs = cls._repro_args(dvc)
if args:
targets: Optional[Union[list, str]] = args[0]
@@ -281,10 +348,12 @@ class BaseExecutor(ABC):
quiet=True,
allow_missing=True,
checkpoint_reset=checkpoint_reset,
recursive=kwargs.get("recursive", False),
)
checkpoint_func = partial(
cls.checkpoint_callback,
dvc,
dvc.scm,
name,
repro_force or checkpoint_reset,
@@ -315,6 +384,8 @@ class BaseExecutor(ABC):
force=repro_force,
checkpoint=is_checkpoint,
)
if auto_push:
cls._auto_push(dvc, dvc.scm, git_remote)
except UnchangedExperimentError:
pass
ref = dvc.scm.get_ref(EXEC_BRANCH, follow=False)
@@ -339,9 +410,15 @@ class BaseExecutor(ABC):
@classmethod
@contextmanager
def _repro_dvc(
cls, dvc_dir: Optional[str], rel_cwd: Optional[str], log_errors: bool
cls,
dvc_dir: Optional[str],
rel_cwd: Optional[str],
log_errors: bool,
pidfile: Optional[str] = None,
git_url: Optional[str] = None,
**kwargs,
):
from dvc.repo import Repo
from dvc.utils.serialize import modify_yaml
dvc = Repo(dvc_dir)
if cls.QUIET:
@@ -354,6 +431,15 @@ class BaseExecutor(ABC):
os.chdir(dvc.root_dir)
else:
old_cwd = None
if pidfile is not None:
info = ExecutorInfo(
os.getpid(),
git_url,
dvc.scm.get_rev(),
cls.DEFAULT_LOCATION,
)
with modify_yaml(pidfile) as d:
d.update(info.to_dict())
logger.debug("Running repro in '%s'", os.getcwd())
try:
@@ -369,6 +455,8 @@ class BaseExecutor(ABC):
logger.exception("unexpected error")
raise
finally:
if pidfile is not None:
remove(pidfile)
dvc.close()
if old_cwd:
os.chdir(old_cwd)
@@ -386,9 +474,32 @@ class BaseExecutor(ABC):
kwargs = {}
return args, kwargs
@staticmethod
def _auto_push(
dvc: "Repo",
scm: "Git",
git_remote: Optional[str],
push_cache=True,
run_cache=True,
):
branch = scm.get_ref(EXEC_BRANCH, follow=False)
try:
dvc.experiments.push(
git_remote,
branch,
push_cache=push_cache,
run_cache=run_cache,
)
except BaseException as exc:
logger.warning(
"Something went wrong while auto pushing experiment "
f"to the remote '{git_remote}': {exc}"
)
@classmethod
def checkpoint_callback(
cls,
dvc: "Repo",
scm: "Git",
name: Optional[str],
force: bool,
@@ -400,6 +511,10 @@ class BaseExecutor(ABC):
exp_rev = cls.commit(
scm, exp_hash, exp_name=name, force=force, checkpoint=True
)
if env2bool(DVC_EXP_AUTO_PUSH):
git_remote = os.getenv(DVC_EXP_GIT_REMOTE)
cls._auto_push(dvc, scm, git_remote)
logger.info("Checkpoint experiment iteration '%s'.", exp_rev[:7])
except UnchangedExperimentError:
pass
+1
View File
@@ -45,6 +45,7 @@ class TempDirExecutor(BaseLocalExecutor):
# suggestions) that are not applicable outside of workspace runs
WARN_UNTRACKED = True
QUIET = True
DEFAULT_LOCATION: Optional[str] = "temp"
def __init__(
self,
+10 -3
View File
@@ -2,6 +2,7 @@ import logging
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo import locked
from dvc.repo.experiments.base import ExpRefInfo
from dvc.repo.scm_context import scm_context
from .utils import exp_commits, exp_refs_by_name
@@ -12,7 +13,13 @@ logger = logging.getLogger(__name__)
@locked
@scm_context
def push(
repo, git_remote, exp_name, *args, force=False, push_cache=False, **kwargs
repo,
git_remote,
exp_name: str,
*args,
force=False,
push_cache=False,
**kwargs,
):
exp_ref = _get_exp_ref(repo, exp_name)
@@ -35,9 +42,9 @@ def push(
_push_cache(repo, exp_ref, **kwargs)
def _get_exp_ref(repo, exp_name):
def _get_exp_ref(repo, exp_name: str) -> ExpRefInfo:
if exp_name.startswith("refs/"):
return exp_name
return ExpRefInfo.from_ref(exp_name)
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
if not exp_refs:
+83 -25
View File
@@ -1,52 +1,85 @@
import logging
from typing import List, Optional
from dvc.exceptions import InvalidArgumentError
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm.base import RevError
from .base import EXPS_NAMESPACE, ExpRefInfo
from .utils import exp_refs_by_name, remove_exp_refs
from .utils import exp_refs, exp_refs_by_name, remove_exp_refs
logger = logging.getLogger(__name__)
@locked
@scm_context
def remove(repo, exp_names=None, queue=False, **kwargs):
if not exp_names and not queue:
def remove(
repo,
exp_names=None,
queue=False,
clear_all=False,
**kwargs,
):
if not any([exp_names, queue, clear_all]):
return 0
removed = 0
if queue:
removed += len(repo.experiments.stash)
repo.experiments.stash.clear()
removed += _clear_stash(repo)
if clear_all:
removed += _clear_all(repo)
if exp_names:
ref_infos = list(_get_exp_refs(repo, exp_names))
remove_exp_refs(repo.scm, ref_infos)
removed += len(ref_infos)
remained = _remove_commited_exps(repo, exp_names)
remained = _remove_queued_exps(repo, remained)
if remained:
raise InvalidArgumentError(
"'{}' is not a valid experiment".format(";".join(remained))
)
removed += len(exp_names) - len(remained)
return removed
def _get_exp_refs(repo, exp_names):
def _clear_stash(repo):
removed = len(repo.experiments.stash)
repo.experiments.stash.clear()
return removed
def _clear_all(repo):
ref_infos = list(exp_refs(repo.scm))
remove_exp_refs(repo.scm, ref_infos)
return len(ref_infos)
def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
stash_revs = repo.experiments.stash_revs
for _, ref_info in stash_revs.items():
if ref_info.name == ref_or_rev:
return ref_info.index
try:
rev = repo.scm.resolve_rev(ref_or_rev)
if rev in stash_revs:
return stash_revs.get(rev).index
except RevError:
pass
return None
def _get_exp_ref(repo, exp_name: str) -> Optional[ExpRefInfo]:
cur_rev = repo.scm.get_rev()
for name in exp_names:
if name.startswith(EXPS_NAMESPACE):
if not repo.scm.get_ref(name):
raise InvalidArgumentError(
f"'{name}' is not a valid experiment name"
)
yield ExpRefInfo.from_ref(name)
else:
exp_refs = list(exp_refs_by_name(repo.scm, name))
if not exp_refs:
raise InvalidArgumentError(
f"'{name}' is not a valid experiment name"
)
yield _get_ref(exp_refs, name, cur_rev)
if exp_name.startswith(EXPS_NAMESPACE):
if repo.scm.get_ref(exp_name):
return ExpRefInfo.from_ref(exp_name)
else:
exp_ref_list = list(exp_refs_by_name(repo.scm, exp_name))
if exp_ref_list:
return _get_ref(exp_ref_list, exp_name, cur_rev)
return None
def _get_ref(ref_infos, name, cur_rev):
def _get_ref(ref_infos, name, cur_rev) -> Optional[ExpRefInfo]:
if len(ref_infos) > 1:
for info in ref_infos:
if info.baseline_sha == cur_rev:
@@ -61,3 +94,28 @@ def _get_ref(ref_infos, name, cur_rev):
msg.extend([f"\t{info}" for info in ref_infos])
raise InvalidArgumentError("\n".join(msg))
return ref_infos[0]
def _remove_commited_exps(repo, refs: List[str]) -> List[str]:
remain_list = []
remove_list = []
for ref in refs:
ref_info = _get_exp_ref(repo, ref)
if ref_info:
remove_list.append(ref_info)
else:
remain_list.append(ref)
if remove_list:
remove_exp_refs(repo.scm, remove_list)
return remain_list
def _remove_queued_exps(repo, refs_or_revs: List[str]) -> List[str]:
remain_list = []
for ref_or_rev in refs_or_revs:
stash_index = _get_exp_stash_index(repo, ref_or_rev)
if stash_index is None:
remain_list.append(ref_or_rev)
else:
repo.experiments.stash.drop(stash_index)
return remain_list
+71 -32
View File
@@ -1,22 +1,32 @@
import logging
from collections import OrderedDict, defaultdict
from datetime import datetime
from typing import Any, Callable, Dict, Optional
from dvc.exceptions import InvalidArgumentError
from dvc.repo import locked
from dvc.repo.experiments.base import ExpRefInfo
from dvc.repo.experiments.executor.base import ExecutorInfo
from dvc.repo.experiments.utils import fix_exp_head
from dvc.repo.metrics.show import _collect_metrics, _read_metrics
from dvc.repo.params.show import _collect_configs, _read_params
from dvc.repo.metrics.show import _gather_metrics
from dvc.repo.params.show import _gather_params
from dvc.scm.base import SCMError
from dvc.utils import error_handler, onerror_collect
logger = logging.getLogger(__name__)
@error_handler
def _collect_experiment_commit(
repo, exp_rev, stash=False, sha_only=True, param_deps=False
repo,
exp_rev,
stash=False,
sha_only=True,
param_deps=False,
running=None,
onerror: Optional[Callable] = None,
):
res = defaultdict(dict)
res: Dict[str, Optional[Any]] = defaultdict(dict)
for rev in repo.brancher(revs=[exp_rev]):
if rev == "workspace":
if exp_rev != "workspace":
@@ -26,17 +36,23 @@ def _collect_experiment_commit(
commit = repo.scm.resolve_commit(rev)
res["timestamp"] = datetime.fromtimestamp(commit.commit_time)
params, params_path_infos = _collect_configs(repo, rev=rev)
params = _read_params(
repo, params, params_path_infos, rev, deps=param_deps
params = _gather_params(
repo, rev=rev, targets=None, deps=param_deps, onerror=onerror
)
if params:
res["params"] = params
res["queued"] = stash
if running is not None and exp_rev in running:
res["running"] = True
res["executor"] = running[exp_rev].get(ExecutorInfo.PARAM_LOCATION)
else:
res["running"] = False
res["executor"] = None
if not stash:
metrics = _collect_metrics(repo, None, rev, False)
vals = _read_metrics(repo, metrics, rev)
vals = _gather_metrics(
repo, targets=None, rev=rev, recursive=False, onerror=onerror
)
res["metrics"] = vals
if not sha_only and rev != "workspace":
@@ -45,10 +61,7 @@ def _collect_experiment_commit(
if name:
break
if not name:
if stash:
pass
else:
name = repo.experiments.get_exact_name(rev)
name = repo.experiments.get_exact_name(rev)
if name:
name = name.rsplit("/")[-1]
res["name"] = name
@@ -56,28 +69,34 @@ def _collect_experiment_commit(
return res
def _collect_experiment_branch(res, repo, branch, baseline, **kwargs):
def _collect_experiment_branch(
res, repo, branch, baseline, onerror: Optional[Callable] = None, **kwargs
):
exp_rev = repo.scm.resolve_rev(branch)
prev = None
revs = list(repo.scm.branch_revs(exp_rev, baseline))
for rev in revs:
collected_exp = _collect_experiment_commit(repo, rev, **kwargs)
collected_exp = _collect_experiment_commit(
repo, rev, onerror=onerror, **kwargs
)
if len(revs) > 1:
exp = {"checkpoint_tip": exp_rev}
if prev:
res[prev]["checkpoint_parent"] = rev
res[prev]["data"][ # type: ignore[unreachable]
"checkpoint_parent"
] = rev
if rev in res:
res[rev].update(exp)
res[rev]["data"].update(exp)
res.move_to_end(rev)
else:
exp.update(collected_exp)
exp.update(collected_exp["data"])
else:
exp = collected_exp
exp = collected_exp["data"]
if rev not in res:
res[rev] = exp
res[rev] = {"data": exp}
prev = rev
if len(revs) > 1:
res[prev]["checkpoint_parent"] = baseline
res[prev]["data"]["checkpoint_parent"] = baseline
return res
@@ -91,8 +110,12 @@ def show(
sha_only=False,
num=1,
param_deps=False,
onerror: Optional[Callable] = None,
):
res = defaultdict(OrderedDict)
if onerror is None:
onerror = onerror_collect
res: Dict[str, Dict] = defaultdict(OrderedDict)
if num < 1:
raise InvalidArgumentError(f"Invalid number of commits '{num}'")
@@ -117,9 +140,16 @@ def show(
)
)
running = repo.experiments.get_running_exps()
for rev in revs:
res[rev]["baseline"] = _collect_experiment_commit(
repo, rev, sha_only=sha_only, param_deps=param_deps
repo,
rev,
sha_only=sha_only,
param_deps=param_deps,
running=running,
onerror=onerror,
)
if rev == "workspace":
@@ -142,14 +172,23 @@ def show(
rev,
sha_only=sha_only,
param_deps=param_deps,
running=running,
onerror=onerror,
)
# collect queued (not yet reproduced) experiments
for stash_rev, entry in repo.experiments.stash_revs.items():
if entry.baseline_rev in revs:
experiment = _collect_experiment_commit(
repo, stash_rev, stash=True, param_deps=param_deps
)
res[entry.baseline_rev][stash_rev] = experiment
# collect queued (not yet reproduced) experiments
for stash_rev, entry in repo.experiments.stash_revs.items():
if entry.baseline_rev in revs:
if stash_rev not in running or not running[stash_rev].get(
"last"
):
experiment = _collect_experiment_commit(
repo,
stash_rev,
sha_only=sha_only,
stash=stash_rev not in running,
param_deps=param_deps,
running=running,
onerror=onerror,
)
res[entry.baseline_rev][stash_rev] = experiment
return res
+17 -79
View File
@@ -1,9 +1,8 @@
import logging
from dvc.config import NoRemoteError
from dvc.exceptions import DownloadError
from dvc.exceptions import DownloadError, FileTransferError
from dvc.scheme import Schemes
from ..scheme import Schemes
from . import locked
logger = logging.getLogger(__name__)
@@ -16,7 +15,6 @@ def fetch(
jobs=None,
remote=None,
all_branches=False,
show_checksums=False,
with_deps=False,
all_tags=False,
recursive=False,
@@ -36,8 +34,6 @@ def fetch(
config.NoRemoteError: thrown when downloading only local files and no
remote is configured
"""
from dvc.objects.db.git import GitObjectDB
if isinstance(targets, str):
targets = [targets]
@@ -63,36 +59,18 @@ def fetch(
except DownloadError as exc:
failed += exc.amount
external = set()
for odb, objs in used.items():
if odb is None:
# objs contains naive objects to be pulled from specified remote
d, f = _fetch_naive_objs(
self,
objs,
jobs=jobs,
remote=remote,
show_checksums=show_checksums,
)
downloaded += d
failed += f
elif isinstance(odb, GitObjectDB):
# objs contains staged import objects which should be saved
# last (after all other objects have been pulled)
external.update(objs)
else:
d, f = fetch_from_odb(
self,
odb,
objs,
jobs=jobs,
show_checksums=show_checksums,
)
downloaded += d
failed += f
if external:
d, f = _fetch_external(self, external, jobs=jobs)
for odb, obj_ids in sorted(
used.items(),
key=lambda item: item[0] is not None
and item[0].fs.scheme == Schemes.MEMORY,
):
d, f = _fetch(
self,
obj_ids,
jobs=jobs,
remote=remote,
odb=odb,
)
downloaded += d
failed += f
@@ -102,51 +80,11 @@ def fetch(
return downloaded
def _fetch_naive_objs(repo, objs, **kwargs):
# objs contains naive objects to be pulled from specified remote
def _fetch(repo, obj_ids, **kwargs):
downloaded = 0
failed = 0
try:
downloaded += repo.cloud.pull(objs, **kwargs)
except NoRemoteError:
if any(obj.fs.scheme == Schemes.LOCAL for obj in objs):
raise
except DownloadError as exc:
downloaded += repo.cloud.pull(obj_ids, **kwargs)
except FileTransferError as exc:
failed += exc.amount
return downloaded, failed
def fetch_from_odb(repo, odb, objs, **kwargs):
from dvc.remote.base import Remote
downloaded = 0
failed = 0
remote = Remote.from_odb(odb)
try:
downloaded += remote.pull(
repo.odb.local,
objs,
**kwargs,
)
except DownloadError as exc:
failed += exc.amount
return downloaded, failed
def _fetch_external(repo, objs, **kwargs):
from dvc.objects import save
from dvc.objects.errors import ObjectError
results = []
failed = 0
def callback(result):
results.append(result)
for obj in objs:
try:
save(repo.odb.local, obj, download_callback=callback, **kwargs)
except ObjectError:
failed += 1
return sum(results), failed
+10 -18
View File
@@ -1,14 +1,9 @@
import logging
from typing import TYPE_CHECKING, Set
from dvc.exceptions import InvalidArgumentError
from ..scheme import Schemes
from . import locked
if TYPE_CHECKING:
from dvc.objects.file import HashFile
logger = logging.getLogger(__name__)
@@ -49,19 +44,20 @@ def gc(
from contextlib import ExitStack
from dvc.objects.db import get_index
from dvc.repo import Repo
if not repos:
repos = []
all_repos = [Repo(path) for path in repos]
used_objs: Set["HashFile"] = set()
used_obj_ids = set()
with ExitStack() as stack:
for repo in all_repos:
stack.enter_context(repo.lock)
for repo in all_repos + [self]:
for objs in repo.used_objs(
for obj_ids in repo.used_objs(
all_branches=all_branches,
with_deps=with_deps,
all_tags=all_tags,
@@ -71,26 +67,22 @@ def gc(
force=force,
jobs=jobs,
).values():
used_objs.update(objs)
used_obj_ids.update(obj_ids)
for scheme, odb in self.odb.by_scheme():
if not odb:
continue
removed = odb.gc(
{obj for obj in used_objs if obj.fs.scheme == scheme},
jobs=jobs,
)
removed = odb.gc(used_obj_ids, jobs=jobs)
if not removed:
logger.info(f"No unused '{scheme}' cache to remove.")
if not cloud:
return
remote = self.cloud.get_remote(remote, "gc -c")
removed = remote.gc(
{obj for obj in used_objs if obj.fs.scheme == Schemes.LOCAL},
jobs=jobs,
)
if not removed:
odb = self.cloud.get_remote_odb(remote, "gc -c")
removed = odb.gc(used_obj_ids)
if removed:
get_index(odb).clear()
else:
logger.info("No unused cache to remove from remote.")
+6 -3
View File
@@ -67,15 +67,18 @@ def imp_url(
dvcfile.remove()
try:
self.check_modified_graph([stage])
new_index = self.index.add(stage)
new_index.check_graph()
except OutputDuplicationError as exc:
raise OutputDuplicationError(exc.output, set(exc.stages) - {stage})
if no_exec:
stage.ignore_outs()
elif to_remote:
remote = self.cloud.get_remote(remote, "import-url")
stage.outs[0].transfer(url, odb=remote.odb, jobs=jobs)
remote_odb = self.cloud.get_remote_odb(remote, "import-url")
stage.outs[0].transfer(url, odb=remote_odb, jobs=jobs)
stage.save_deps()
stage.md5 = stage.compute_md5()
else:
stage.run(jobs=jobs)
+284
View File
@@ -0,0 +1,284 @@
from contextlib import suppress
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Sequence,
Set,
)
from funcy import cached_property, memoize, nullcontext
from dvc.utils import dict_md5
if TYPE_CHECKING:
from networkx import DiGraph
from pygtrie import Trie
from dvc.dependency import Dependency, ParamsDependency
from dvc.fs.base import BaseFileSystem
from dvc.objects import HashInfo, ObjectDB
from dvc.output import Output
from dvc.repo.stage import StageLoad
from dvc.stage import Stage
from dvc.types import StrPath, TargetType
ObjectContainer = Dict[Optional["ObjectDB"], Set["HashInfo"]]
class Index:
def __init__(
self,
repo: "Repo", # pylint: disable=redefined-outer-name
fs: "BaseFileSystem" = None,
stages: List["Stage"] = None,
) -> None:
"""Index is an immutable collection of stages.
Generally, Index is a complete collection of stages at a point in time.
With "a point in time", it means it is collected from the user's
workspace or a git revision.
And, since Index is immutable, the collection is frozen in time.
Index provides multiple ways to view this collection:
stages - provides direct access to this collection
outputs - provides direct access to the outputs
objects - provides direct access to the objects
graph -
... and many more.
Index also provides ways to slice and dice this collection.
Some `views` might not make sense when sliced (eg: pipelines/graph).
"""
self.repo: "Repo" = repo
self.fs: "BaseFileSystem" = fs or repo.fs
self.stage_collector: "StageLoad" = repo.stage
if stages is not None:
self.stages: List["Stage"] = stages
@cached_property
def stages(self) -> List["Stage"]: # pylint: disable=method-hidden
# note that ideally we should be keeping this in a set as it is unique,
# hashable and has no concept of orderliness on its own. But we depend
# on this to be somewhat ordered for status/metrics/plots, etc.
onerror = self.repo.stage_collection_error_handler
return self.stage_collector.collect_repo(onerror=onerror)
def __repr__(self) -> str:
from dvc.fs.local import LocalFileSystem
rev = "workspace"
if not isinstance(self.fs, LocalFileSystem):
rev = self.repo.get_rev()[:7]
return f"Index({self.repo}, fs@{rev})"
def __len__(self) -> int:
return len(self.stages)
def __contains__(self, stage: "Stage") -> bool:
# as we are keeping stages inside a list, it might be slower.
return stage in self.stages
def __iter__(self) -> Iterator["Stage"]:
yield from self.stages
def filter(self, filter_fn: Callable[["Stage"], bool]) -> "Index":
stages_it = filter(filter_fn, self)
return Index(self.repo, self.fs, stages=list(stages_it))
def slice(self, path: "StrPath") -> "Index":
from dvc.utils import relpath
from dvc.utils.fs import path_isin
target_path = relpath(path, self.repo.root_dir)
def is_stage_inside_path(stage: "Stage") -> bool:
return path_isin(stage.path_in_repo, target_path)
return self.filter(is_stage_inside_path)
@property
def outs(self) -> Iterator["Output"]:
for stage in self:
yield from stage.outs
@property
def decorated_outs(self) -> Iterator["Output"]:
for output in self.outs:
if output.is_decorated:
yield output
@property
def metrics(self) -> Iterator["Output"]:
for output in self.outs:
if output.is_metric:
yield output
@property
def plots(self) -> Iterator["Output"]:
for output in self.outs:
if output.is_plot:
yield output
@property
def deps(self) -> Iterator["Dependency"]:
for stage in self:
yield from stage.deps
@property
def params(self) -> Iterator["ParamsDependency"]:
from dvc.dependency import ParamsDependency
for dep in self.deps:
if isinstance(dep, ParamsDependency):
yield dep
@cached_property
def outs_trie(self) -> "Trie":
from dvc.repo.trie import build_outs_trie
return build_outs_trie(self.stages)
@property
def graph(self) -> "DiGraph":
return self.build_graph()
@cached_property
def outs_graph(self) -> "DiGraph":
from dvc.repo.graph import build_outs_graph
return build_outs_graph(self.graph, self.outs_trie)
def used_objs(
self,
targets: "TargetType" = None,
with_deps: bool = False,
remote: str = None,
force: bool = False,
recursive: bool = False,
jobs: int = None,
) -> "ObjectContainer":
from collections import defaultdict
from itertools import chain
from dvc.utils.collections import ensure_list
used: "ObjectContainer" = defaultdict(set)
collect_targets: Sequence[Optional[str]] = (None,)
if targets:
collect_targets = ensure_list(targets)
pairs = chain.from_iterable(
self.stage_collector.collect_granular(
target, recursive=recursive, with_deps=with_deps
)
for target in collect_targets
)
for stage, filter_info in pairs:
for odb, objs in stage.get_used_objs(
remote=remote,
force=force,
jobs=jobs,
filter_info=filter_info,
).items():
used[odb].update(objs)
return used
# Following methods help us treat the collection as a set-like structure
# and provides faux-immutability.
# These methods do not preserve stages order.
def update(self, stages: Iterable["Stage"]) -> "Index":
new_stages = set(stages)
# we remove existing stages with same hashes at first
# and then re-add the new ones later.
stages_set = (set(self.stages) - new_stages) | new_stages
return Index(self.repo, self.fs, stages=list(stages_set))
def add(self, stage: "Stage") -> "Index":
return self.update([stage])
def remove(
self, stage: "Stage", ignore_not_existing: bool = False
) -> "Index":
stages = self._discard_stage(
stage, ignore_not_existing=ignore_not_existing
)
return Index(self.repo, self.fs, stages=stages)
def discard(self, stage: "Stage") -> "Index":
return self.remove(stage, ignore_not_existing=True)
def difference(self, stages: Iterable["Stage"]) -> "Index":
stages_set = set(self.stages) - set(stages)
return Index(self.repo, self.fs, stages=list(stages_set))
def _discard_stage(
self, stage: "Stage", ignore_not_existing: bool = False
) -> List["Stage"]:
stages = self.stages[:]
ctx = suppress(ValueError) if ignore_not_existing else nullcontext()
with ctx:
stages.remove(stage)
return stages
@memoize
def build_graph(self) -> "DiGraph":
from dvc.repo.graph import build_graph
return build_graph(self.stages, self.outs_trie)
def check_graph(self) -> None:
if not getattr(self.repo, "_skip_graph_checks", False):
self.build_graph()
def dumpd(self) -> Dict[str, Dict]:
def dump(stage: "Stage"):
key = stage.path_in_repo
try:
key += ":" + stage.name # type: ignore[attr-defined]
except AttributeError:
pass
return key, stage.dumpd()
return dict(dump(stage) for stage in self)
@cached_property
def identifier(self) -> str:
"""Unique identifier for the index.
We can use this to optimize and skip opening some indices
eg: on push/pull/fetch/gc --all-commits.
Currently, it is unique to the platform (windows vs posix).
"""
return dict_md5(self.dumpd())
if __name__ == "__main__":
from funcy import log_durations
from dvc.repo import Repo
repo = Repo()
index = Index(repo, repo.fs)
print(index)
with log_durations(print, "collecting stages"):
# pylint: disable=pointless-statement
print("no of stages", len(index.stages))
with log_durations(print, "building graph"):
index.build_graph()
with log_durations(print, "calculating hash"):
print(index.identifier)
with log_durations(print, "updating"):
index2 = index.update(index.stages)
with log_durations(print, "calculating hash"):
print(index2.identifier)
+1 -10
View File
@@ -1,10 +1,7 @@
import contextlib
import logging
import os
from typing import TYPE_CHECKING, List, Optional
from dvc.exceptions import MetricDoesNotExistError, MetricsError
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
@@ -44,15 +41,9 @@ class Live:
if revs:
revs = ["workspace", *revs]
if not os.path.exists(target):
raise MetricDoesNotExistError([target])
metrics_path = target + ".json"
metrics = None
with contextlib.suppress(MetricsError):
metrics = self.repo.metrics.show(targets=[metrics_path])
metrics = self.repo.metrics.show(targets=[metrics_path])
plots = self.repo.plots.show(target, recursive=True, revs=revs)
return metrics, plots
+3 -12
View File
@@ -1,17 +1,8 @@
from dvc.exceptions import MetricsError
from dvc.repo.experiments.utils import fix_exp_head
from dvc.utils.diff import diff as _diff
from dvc.utils.diff import format_dict
def _get_metrics(repo, *args, revs=None, **kwargs):
try:
metrics = repo.metrics.show(*args, **kwargs, revs=revs)
return metrics
except MetricsError:
return {}
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
if repo.scm.no_commits:
return {}
@@ -22,9 +13,9 @@ def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
a_rev = fix_exp_head(repo.scm, a_rev)
b_rev = fix_exp_head(repo.scm, b_rev) or "workspace"
metrics = _get_metrics(repo, *args, **kwargs, revs=[a_rev, b_rev])
old = metrics.get(a_rev, {})
new = metrics.get(b_rev, {})
metrics = repo.metrics.show(*args, **kwargs, revs=[a_rev, b_rev])
old = metrics.get(a_rev, {}).get("data", {})
new = metrics.get(b_rev, {}).get("data", {})
return _diff(
format_dict(old), format_dict(new), with_unchanged=with_unchanged
+34 -40
View File
@@ -1,19 +1,14 @@
import logging
from typing import List
from dvc.exceptions import (
MetricDoesNotExistError,
NoMetricsFoundError,
NoMetricsParsedError,
)
from dvc.fs.repo import RepoFileSystem
from dvc.output import Output
from dvc.path_info import PathInfo
from dvc.repo import locked
from dvc.repo.collect import collect
from dvc.repo.collect import DvcPaths, collect
from dvc.repo.live import summary_path_info
from dvc.scm.base import SCMError
from dvc.utils.serialize import YAMLFileCorruptedError, load_yaml
from dvc.utils import error_handler, errored_revisions, onerror_collect
from dvc.utils.serialize import load_yaml
logger = logging.getLogger(__name__)
@@ -22,7 +17,7 @@ def _is_metric(out: Output) -> bool:
return bool(out.metric) or bool(out.live)
def _to_path_infos(metrics: List[Output]) -> List[PathInfo]:
def _to_path_infos(metrics: List[Output]) -> DvcPaths:
result = []
for out in metrics:
if out.metric:
@@ -70,7 +65,14 @@ def _extract_metrics(metrics, path, rev):
return ret
def _read_metrics(repo, metrics, rev):
@error_handler
def _read_metric(path, fs, rev, **kwargs):
val = load_yaml(path, fs=fs)
val = _extract_metrics(val, path, rev)
return val or {}
def _read_metrics(repo, metrics, rev, onerror=None):
fs = RepoFileSystem(repo)
res = {}
@@ -78,21 +80,16 @@ def _read_metrics(repo, metrics, rev):
if not fs.isfile(metric):
continue
try:
val = load_yaml(metric, fs=fs)
except (FileNotFoundError, YAMLFileCorruptedError):
logger.debug(
"failed to read '%s' on '%s'", metric, rev, exc_info=True
)
continue
val = _extract_metrics(val, metric, rev)
if val not in (None, {}):
res[str(metric)] = val
res[str(metric)] = _read_metric(metric, fs, rev, onerror=onerror)
return res
def _gather_metrics(repo, targets, rev, recursive, onerror=None):
metrics = _collect_metrics(repo, targets, rev, recursive)
return _read_metrics(repo, metrics, rev, onerror=onerror)
@locked
def show(
repo,
@@ -102,33 +99,21 @@ def show(
recursive=False,
revs=None,
all_commits=False,
onerror=None,
):
res = {}
metrics_found = False
if onerror is None:
onerror = onerror_collect
res = {}
for rev in repo.brancher(
revs=revs,
all_branches=all_branches,
all_tags=all_tags,
all_commits=all_commits,
):
metrics = _collect_metrics(repo, targets, rev, recursive)
if not metrics_found and metrics:
metrics_found = True
vals = _read_metrics(repo, metrics, rev)
if vals:
res[rev] = vals
if not res:
if metrics_found:
raise NoMetricsParsedError("metrics")
elif targets:
raise MetricDoesNotExistError(targets)
else:
raise NoMetricsFoundError("metrics", "-m/-M")
res[rev] = error_handler(_gather_metrics)(
repo, targets, rev, recursive, onerror=onerror
)
# Hide workspace metrics if they are the same as in the active branch
try:
@@ -141,4 +126,13 @@ def show(
if res.get("workspace") == res.get(active_branch):
res.pop("workspace", None)
errored = errored_revisions(res)
if errored:
from dvc.ui import ui
ui.error_write(
"DVC failed to load some metrics for following revisions:"
f" '{', '.join(errored)}'."
)
return res
+4 -13
View File
@@ -2,16 +2,6 @@ from dvc.repo.experiments.utils import fix_exp_head
from dvc.utils.diff import diff as _diff
from dvc.utils.diff import format_dict
from .show import NoParamsError
def _get_params(repo, *args, revs=None, **kwargs):
try:
params = repo.params.show(*args, **kwargs, revs=revs)
return params
except NoParamsError:
return {}
def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
if repo.scm.no_commits:
@@ -23,9 +13,10 @@ def diff(repo, *args, a_rev=None, b_rev=None, **kwargs):
a_rev = fix_exp_head(repo.scm, a_rev)
b_rev = fix_exp_head(repo.scm, b_rev) or "workspace"
params = _get_params(repo, *args, **kwargs, revs=[a_rev, b_rev])
old = params.get(a_rev, {})
new = params.get(b_rev, {})
params = repo.params.show(*args, **kwargs, revs=[a_rev, b_rev])
old = params.get(a_rev, {}).get("data", {})
new = params.get(b_rev, {}).get("data", {})
return _diff(
format_dict(old), format_dict(new), with_unchanged=with_unchanged
+59 -37
View File
@@ -1,16 +1,17 @@
import logging
from collections import defaultdict
from copy import copy
from typing import TYPE_CHECKING, List, Tuple
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
from dvc.dependency.param import ParamsDependency
from dvc.exceptions import DvcException
from dvc.path_info import PathInfo
from dvc.repo import locked
from dvc.repo.collect import collect
from dvc.scm.base import SCMError
from dvc.stage import PipelineStage
from dvc.utils.serialize import LOADERS, ParseError
from dvc.ui import ui
from dvc.utils import error_handler, errored_revisions, onerror_collect
from dvc.utils.serialize import LOADERS
if TYPE_CHECKING:
from dvc.output import Output
@@ -20,10 +21,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class NoParamsError(DvcException):
pass
def _is_params(dep: "Output"):
return isinstance(dep, ParamsDependency)
@@ -31,6 +28,7 @@ def _is_params(dep: "Output"):
def _collect_configs(
repo: "Repo", rev, targets=None
) -> Tuple[List["Output"], List["DvcPath"]]:
params, path_infos = collect(
repo,
targets=targets or [],
@@ -43,47 +41,49 @@ def _collect_configs(
default_params = (
PathInfo(repo.root_dir) / ParamsDependency.DEFAULT_PARAMS_FILE
)
if default_params not in all_path_infos:
if default_params not in all_path_infos and repo.fs.exists(
default_params
):
path_infos.append(default_params)
return params, path_infos
def _read_path_info(fs, path_info, rev):
if not fs.exists(path_info):
return None
@error_handler
def _read_path_info(fs, path_info, **kwargs):
suffix = path_info.suffix.lower()
loader = LOADERS[suffix]
try:
return loader(path_info, fs=fs)
except ParseError:
logger.debug(
"failed to read '%s' on '%s'", path_info, rev, exc_info=True
)
return None
return loader(path_info, fs=fs)
def _read_params(repo, params, params_path_infos, rev, deps=False):
res = defaultdict(dict)
def _read_params(
repo,
params,
params_path_infos,
deps=False,
onerror: Optional[Callable] = None,
):
res: Dict[str, Dict] = defaultdict(dict)
path_infos = copy(params_path_infos)
if deps:
for param in params:
res[str(param.path_info)].update(param.read_params_d())
params_dict = error_handler(param.read_params_d)(onerror=onerror)
if params_dict:
res[str(param.path_info)] = params_dict
else:
path_infos += [param.path_info for param in params]
for path_info in path_infos:
from_path = _read_path_info(repo.fs, path_info, rev)
from_path = _read_path_info(repo.fs, path_info, onerror=onerror)
if from_path:
res[str(path_info)] = from_path
return res
def _collect_vars(repo, params):
vars_params = defaultdict(dict)
for stage in repo.stages:
def _collect_vars(repo, params) -> Dict:
vars_params: Dict[str, Dict] = defaultdict(dict)
for stage in repo.index.stages:
if isinstance(stage, PipelineStage) and stage.tracked_vars:
for file, vars_ in stage.tracked_vars.items():
# `params` file are shown regardless of `tracked` or not
@@ -96,24 +96,19 @@ def _collect_vars(repo, params):
@locked
def show(repo, revs=None, targets=None, deps=False):
def show(repo, revs=None, targets=None, deps=False, onerror: Callable = None):
if onerror is None:
onerror = onerror_collect
res = {}
for branch in repo.brancher(revs=revs):
params, params_path_infos = _collect_configs(repo, branch, targets)
params = _read_params(repo, params, params_path_infos, branch, deps)
vars_params = _collect_vars(repo, params)
# NOTE: only those that are not added as a ParamDependency are included
# so we don't need to recursively merge them yet.
params.update(vars_params)
params = error_handler(_gather_params)(
repo=repo, rev=branch, targets=targets, deps=deps, onerror=onerror
)
if params:
res[branch] = params
if not res:
raise NoParamsError("no parameter configs files in this repository")
# Hide workspace params if they are the same as in the active branch
try:
active_branch = repo.scm.active_branch()
@@ -125,4 +120,31 @@ def show(repo, revs=None, targets=None, deps=False):
if res.get("workspace") == res.get(active_branch):
res.pop("workspace", None)
errored = errored_revisions(res)
if errored:
ui.error_write(
"DVC failed to load some parameters for following revisions:"
f" '{', '.join(errored)}'."
)
return res
def _gather_params(repo, rev, targets=None, deps=False, onerror=None):
param_outs, params_path_infos = _collect_configs(
repo, rev, targets=targets
)
params = _read_params(
repo,
params=param_outs,
params_path_infos=params_path_infos,
deps=deps,
onerror=onerror,
)
vars_params = _collect_vars(repo, params)
# NOTE: only those that are not added as a ParamDependency are
# included so we don't need to recursively merge them yet.
for key, vals in vars_params.items():
params[key]["data"] = vals
return params
+134 -109
View File
@@ -1,17 +1,22 @@
import csv
import io
import logging
import os
from typing import TYPE_CHECKING, Dict, List, Optional
from collections import OrderedDict
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
from funcy import cached_property, first, project
from dvc.exceptions import (
DvcException,
MetricDoesNotExistError,
NoMetricsFoundError,
NoMetricsParsedError,
)
from dvc.exceptions import DvcException
from dvc.repo.plots.data import PlotMetricTypeError
from dvc.types import StrPath
from dvc.utils import relpath
from dvc.utils import (
error_handler,
errored_revisions,
onerror_collect,
relpath,
)
from dvc.utils.serialize import LOADERS
if TYPE_CHECKING:
from dvc.output import Output
@@ -41,18 +46,17 @@ class Plots:
targets: List[str] = None,
revs: List[str] = None,
recursive: bool = False,
onerror: Optional[Callable] = None,
props: Optional[Dict] = None,
) -> Dict[str, Dict]:
"""Collects all props and data for plots.
Returns a structure like:
{rev: {plots.csv: {
props: {x: ..., "header": ..., ...},
data: "...data as a string...",
data: "unstructured data (as stored for given extension)",
}}}
Data parsing is postponed, since it's affected by props.
"""
from dvc.config import NoRemoteError
from dvc.fs.repo import RepoFileSystem
from dvc.utils.collections import ensure_list
targets = ensure_list(targets)
@@ -62,70 +66,68 @@ class Plots:
if revs is not None and rev not in revs:
continue
rev = rev or "workspace"
data[rev] = self._collect_from_revision(
revision=rev,
targets=targets,
recursive=recursive,
onerror=onerror,
props=props,
)
fs = RepoFileSystem(self.repo)
plots = _collect_plots(self.repo, targets, rev, recursive)
for path_info, props in plots.items():
errored = errored_revisions(data)
if errored:
from dvc.ui import ui
if rev not in data:
data[rev] = {}
if fs.isdir(path_info):
plot_files = []
try:
for pi in fs.walk_files(path_info):
plot_files.append(
(pi, relpath(pi, self.repo.root_dir))
)
except NoRemoteError:
logger.debug(
(
"Could not find cache for directory '%s' on "
"'%s'. Files inside will not be plotted."
),
path_info,
rev,
)
continue
else:
plot_files = [
(path_info, relpath(path_info, self.repo.root_dir))
]
for path, repo_path in plot_files:
data[rev].update({repo_path: {"props": props}})
# Load data from git or dvc cache
try:
with fs.open(path) as fd:
data[rev][repo_path]["data"] = fd.read()
except FileNotFoundError:
# This might happen simply because cache is absent
logger.debug(
(
"Could not find '%s' on '%s'. "
"File will not be plotted"
),
path,
rev,
)
except UnicodeDecodeError:
logger.debug(
(
"'%s' at '%s' is binary file. It will not be "
"plotted."
),
path,
rev,
)
ui.error_write(
"DVC failed to load some plots for following revisions: "
f"'{', '.join(errored)}'."
)
return data
@error_handler
def _collect_from_revision(
self,
targets: Optional[List[str]] = None,
revision: Optional[str] = None,
recursive: bool = False,
onerror: Optional[Callable] = None,
props: Optional[Dict] = None,
):
from dvc.fs.repo import RepoFileSystem
fs = RepoFileSystem(self.repo)
plots = _collect_plots(self.repo, targets, revision, recursive)
res = {}
for path_info, rev_props in plots.items():
if fs.isdir(path_info):
plot_files = []
for pi in fs.walk_files(path_info):
plot_files.append((pi, relpath(pi, self.repo.root_dir)))
else:
plot_files = [
(path_info, relpath(path_info, self.repo.root_dir))
]
props = props or {}
for path, repo_path in plot_files:
res[repo_path] = {"props": rev_props}
res[repo_path].update(
parse(
fs,
path,
rev_props=rev_props,
props=props,
onerror=onerror,
)
)
return res
@staticmethod
def render(data, revs=None, props=None, templates=None):
"""Renders plots"""
from dvc.repo.plots.data import PlotParsingError
props = props or {}
# Merge data by plot file and apply overriding props
@@ -133,20 +135,14 @@ class Plots:
result = {}
for datafile, desc in plots.items():
try:
result[datafile] = _render(
datafile, desc["data"], desc["props"], templates
)
except PlotParsingError as e:
logger.debug(
"failed to read '%s' on '%s'",
e.path,
e.revision,
exc_info=True,
)
if not any(result.values()):
raise NoMetricsParsedError("plots")
rendered = _render(
datafile,
desc["data"],
desc["props"],
templates,
)
if rendered:
result[datafile] = rendered
return result
@@ -157,25 +153,13 @@ class Plots:
props=None,
templates=None,
recursive=False,
onerror=None,
):
from dvc.utils.collections import ensure_list
data = self.collect(targets, revs, recursive)
# If any mentioned plot doesn't have any data then that's an error
for target in ensure_list(targets):
rpath = relpath(target, self.repo.root_dir)
if not any(
"data" in rev_data[key]
for rev_data in data.values()
for key, d in rev_data.items()
if rpath in key
):
raise MetricDoesNotExistError([target])
# No data at all is a special error with a special message
if not data:
raise NoMetricsFoundError("plots", "--plots/--plots-no-cache")
if onerror is None:
onerror = onerror_collect
data = self.collect(
targets, revs, recursive, onerror=onerror, props=props
)
if templates is None:
templates = self.templates
@@ -273,11 +257,27 @@ def _collect_plots(
rev=rev,
recursive=recursive,
)
result = {plot.path_info: _plot_props(plot) for plot in plots}
result.update({path_info: {} for path_info in path_infos})
return result
@error_handler
def parse(fs, path, props=None, rev_props=None, **kwargs):
props = props or {}
rev_props = rev_props or {}
_, extension = os.path.splitext(path)
if extension in (".tsv", ".csv"):
header = {**rev_props, **props}.get("header", True)
if extension == ".csv":
return _load_sv(path=path, fs=fs, delimiter=",", header=header)
return _load_sv(path=path, fs=fs, delimiter="\t", header=header)
if extension in LOADERS or extension in (".yml", ".yaml"):
return LOADERS[extension](path=path, fs=fs)
raise PlotMetricTypeError(path)
def _plot_props(out: "Output") -> Dict:
from dvc.schema import PLOT_PROPS
@@ -305,7 +305,7 @@ def _prepare_plots(data, revs, props):
if rev not in data:
continue
for datafile, desc in data[rev].items():
for datafile, desc in data[rev].get("data", {}).items():
# We silently skip on an absent data file,
# see also try/except/pass in .collect()
if "data" not in desc:
@@ -355,19 +355,44 @@ def _render(datafile, datas, props, templates):
# Parse all data, preprocess it and collect as a list of dicts
data = []
for rev, datablob in datas.items():
rev_data = plot_data(datafile, rev, datablob).to_datapoints(
for rev, unstructured_data in datas.items():
rev_data = plot_data(datafile, rev, unstructured_data).to_datapoints(
fields=fields,
path=props.get("path"),
header=props.get("header", True),
append_index=props.get("append_index", False),
)
data.extend(rev_data)
# If y is not set then use last field not used yet
if not props.get("y") and template.has_anchor("y"):
fields = list(first(data))
skip = (PlotData.REVISION_FIELD, props.get("x"))
props["y"] = first(f for f in reversed(fields) if f not in skip)
if data:
if not props.get("y") and template.has_anchor("y"):
fields = list(first(data))
skip = (PlotData.REVISION_FIELD, props.get("x"))
props["y"] = first(f for f in reversed(fields) if f not in skip)
return template.render(data, props=props)
return template.render(data, props=props)
return None
def _load_sv(path, fs, delimiter=",", header=True):
with fs.open(path, "r") as fd:
content = fd.read()
first_row = first(csv.reader(io.StringIO(content)))
if header:
reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
else:
reader = csv.DictReader(
io.StringIO(content),
delimiter=delimiter,
fieldnames=[str(i) for i in range(len(first_row))],
)
fieldnames = reader.fieldnames
data = list(reader)
return [
OrderedDict([(field, data_point[field]) for field in fieldnames])
for data_point in data
]
+15 -93
View File
@@ -1,13 +1,10 @@
import csv
import io
import os
from collections import OrderedDict
from copy import copy
from funcy import first, reraise
from funcy import first
from dvc.exceptions import DvcException
from dvc.utils.serialize import ParseError, parse_json, parse_yaml
from dvc.utils.serialize import ParseError
class PlotMetricTypeError(DvcException):
@@ -31,19 +28,15 @@ class PlotParsingError(ParseError):
self.path = path
self.revision = revision
super().__init__(path, f"revision: {revision}")
super().__init__(path, f"revision: '{revision}'")
def plot_data(filename, revision, content):
_, extension = os.path.splitext(filename.lower())
if extension == ".json":
return JSONPlotData(filename, revision, content)
if extension == ".csv":
return CSVPlotData(filename, revision, content)
if extension == ".tsv":
return CSVPlotData(filename, revision, content, delimiter="\t")
if extension == ".yaml":
return YAMLPlotData(filename, revision, content)
if extension in (".json", ".yaml"):
return DictData(filename, revision, content)
if extension in (".csv", ".tsv"):
return ListData(filename, revision, content)
raise PlotMetricTypeError(filename)
@@ -71,34 +64,6 @@ def _filter_fields(data_points, filename, revision, fields=None, **kwargs):
return new_data
def _apply_path(data, path=None, **kwargs):
if not path or not isinstance(data, dict):
return data
import jsonpath_ng
found = jsonpath_ng.parse(path).find(data)
first_datum = first(found)
if (
len(found) == 1
and isinstance(first_datum.value, list)
and isinstance(first(first_datum.value), dict)
):
data_points = first_datum.value
elif len(first_datum.path.fields) == 1:
field_name = first(first_datum.path.fields)
data_points = [{field_name: datum.value} for datum in found]
else:
raise PlotDataStructureError()
if not isinstance(data_points, list) or not (
isinstance(first(data_points), dict)
):
raise PlotDataStructureError()
return data_points
def _lists(dictionary):
for _, value in dictionary.items():
if isinstance(value, dict):
@@ -148,18 +113,11 @@ class PlotData:
self.revision = revision
self.content = content
def raw(self, **kwargs):
raise NotImplementedError
def _processors(self):
return [_filter_fields, _append_index, _append_revision]
def to_datapoints(self, **kwargs):
with reraise(
[ParseError, csv.Error],
PlotParsingError(self.filename, self.revision),
):
data = self.raw(**kwargs)
data = self.content
for data_proc in self._processors():
data = data_proc(
@@ -168,49 +126,13 @@ class PlotData:
return data
class JSONPlotData(PlotData):
def raw(self, **kwargs):
return parse_json(
self.content, self.filename, object_pairs_hook=OrderedDict
)
def _processors(self):
parent_processors = super()._processors()
return [_apply_path, _find_data] + parent_processors
class CSVPlotData(PlotData):
def __init__(self, filename, revision, content, delimiter=","):
super().__init__(filename, revision, content)
self.delimiter = delimiter
def raw(self, header=True, **kwargs): # pylint: disable=arguments-differ
first_row = first(csv.reader(io.StringIO(self.content)))
if header:
reader = csv.DictReader(
io.StringIO(self.content), delimiter=self.delimiter
)
else:
reader = csv.DictReader(
io.StringIO(self.content),
delimiter=self.delimiter,
fieldnames=[str(i) for i in range(len(first_row))],
)
fieldnames = reader.fieldnames
data = list(reader)
return [
OrderedDict([(field, data_point[field]) for field in fieldnames])
for data_point in data
]
class YAMLPlotData(PlotData):
def raw(self, **kwargs):
return parse_yaml(self.content, self.filename, typ="rt")
class DictData(PlotData):
# For files usually parsed as dicts: eg JSON, Yaml
def _processors(self):
parent_processors = super()._processors()
return [_find_data] + parent_processors
class ListData(PlotData):
# For files parsed as list: CSV, TSV
pass
+1 -1
View File
@@ -84,7 +84,7 @@ class Template:
@classmethod
def _anchor_str(cls, name):
return '"{}"'.format(cls.anchor(name))
return f'"{cls.anchor(name)}"'
@classmethod
def _fill_metadata(cls, content, props):
+9 -3
View File
@@ -1,3 +1,5 @@
from dvc.exceptions import FileTransferError, UploadError
from ..utils import glob_targets
from . import locked
@@ -39,7 +41,11 @@ def push(
)
pushed = len(used_run_cache)
for odb, objs in used.items():
if odb is None:
pushed += self.cloud.push(objs, jobs, remote=remote)
for odb, obj_ids in used.items():
if odb and odb.read_only:
continue
try:
pushed += self.cloud.push(obj_ids, jobs, remote=remote, odb=odb)
except FileTransferError as exc:
raise UploadError(exc.amount)
return pushed
+2 -2
View File
@@ -108,7 +108,7 @@ def reproduce(
stages = set()
if pipeline or all_pipelines:
pipelines = get_pipelines(self.graph)
pipelines = get_pipelines(self.index.graph)
if all_pipelines:
used_pipelines = pipelines
else:
@@ -132,7 +132,7 @@ def reproduce(
)
)
return _reproduce_stages(self.graph, list(stages), **kwargs)
return _reproduce_stages(self.index.graph, list(stages), **kwargs)
def _reproduce_stages(
+20 -11
View File
@@ -110,8 +110,9 @@ def locked(f):
class StageLoad:
def __init__(self, repo: "Repo") -> None:
def __init__(self, repo: "Repo", fs=None) -> None:
self.repo: "Repo" = repo
self._fs = fs
@locked
def add(
@@ -164,7 +165,6 @@ class StageLoad:
is_valid_name,
prepare_file_path,
validate_kwargs,
validate_state,
)
stage_data = validate_kwargs(
@@ -184,7 +184,13 @@ class StageLoad:
stage_cls, repo=self.repo, path=path, **stage_data
)
if validate:
validate_state(self.repo, stage, force=force)
if not force:
from dvc.stage.utils import check_stage_exists
check_stage_exists(self.repo, stage, stage.path)
new_index = self.repo.index.add(stage)
new_index.check_graph()
restore_meta(stage)
return stage
@@ -305,11 +311,13 @@ class StageLoad:
@property
def fs(self):
if self._fs:
return self._fs
return self.repo.fs
@property
def graph(self) -> "DiGraph":
return self.repo.graph
return self.repo.index.graph
def collect(
self,
@@ -351,9 +359,9 @@ class StageLoad:
glob: Use `target` as a pattern to match stages in a file.
"""
if not target:
return list(graph) if graph else self.repo.stages
return list(graph) if graph else list(self.repo.index)
if recursive and self.repo.fs.isdir(target):
if recursive and self.fs.isdir(target):
from dvc.repo.graph import collect_inside_path
path = os.path.abspath(target)
@@ -392,7 +400,7 @@ class StageLoad:
(see `collect()` for other arguments)
"""
if not target:
return [StageInfo(stage) for stage in self.repo.stages]
return [StageInfo(stage) for stage in self.repo.index]
stages, file, _ = _collect_specific_target(
self, target, with_deps, recursive, accept_group
@@ -432,7 +440,7 @@ class StageLoad:
return [StageInfo(stage) for stage in stages]
def collect_repo(self, onerror: Callable[[str, Exception], None] = None):
def _collect_repo(self, onerror: Callable[[str, Exception], None] = None):
"""Collects all of the stages present in the DVC repo.
Args:
@@ -465,7 +473,6 @@ class StageLoad:
# trailing slash needed to check if a directory is gitignored
return dir_path in outs or is_ignored(f"{dir_path}{sep}")
stages = []
for root, dirs, files in self.repo.dvcignore.walk(
self.fs, self.repo.root_dir
):
@@ -480,7 +487,7 @@ class StageLoad:
continue
raise
stages.extend(new_stages)
yield from new_stages
outs.update(
out.fspath
for stage in new_stages
@@ -488,4 +495,6 @@ class StageLoad:
if out.scheme == "local"
)
dirs[:] = [d for d in dirs if not is_out_or_ignored(root, d)]
return stages
def collect_repo(self, onerror: Callable[[str, Exception], None] = None):
return list(self._collect_repo(onerror))
+5 -17
View File
@@ -76,8 +76,6 @@ def _cloud_status(
{ "bar": "deleted" }
"""
import dvc.remote.base as cloud
used = self.used_objs(
targets,
all_branches=all_branches,
@@ -91,26 +89,16 @@ def _cloud_status(
)
ret = {}
for odb, objs in used.items():
for odb, obj_ids in used.items():
if odb is not None:
# ignore imported objects
continue
status_info = self.cloud.status(
objs, jobs, remote=remote, log_missing=False
obj_ids, jobs, remote=remote, log_missing=False
)
for info in status_info.values():
name = info["name"]
status_ = info["status"]
if status_ == cloud.STATUS_OK:
continue
prefix_map = {
cloud.STATUS_DELETED: "deleted",
cloud.STATUS_NEW: "new",
cloud.STATUS_MISSING: "missing",
}
ret[name] = prefix_map[status_]
for status_ in ("deleted", "new", "missing"):
for hash_info in getattr(status_info, status_, []):
ret[hash_info.obj_name] = status_
return ret
+1 -3
View File
@@ -28,9 +28,7 @@ class RWLockFileCorruptedError(DvcException):
class RWLockFileFormatError(DvcException):
def __init__(self, path):
super().__init__(
"RWLock-file '{}' format error.".format(relpath(path))
)
super().__init__(f"RWLock-file '{relpath(path)}' format error.")
@contextmanager

Some files were not shown because too many files have changed in this diff Show More