Compare commits

..
324 Commits
Author SHA1 Message Date
Peter Rowlands (변기호) 73f2fa8c93 git: use asyncssh as dulwich SSH vendor (#6797)
* git: support client kwargs for auth in remote methods

* tests: use docker for git/ssh server

* move dulwich backend into subdir

* add base sync<->async support

* dulwich: add initial asyncssh SSH vendor

* dulwich: use asyncssh as the default SSH vendor

* add test for git over ssh

* test read (fetch) over ssh

* ci: re-enable ssh tests

temporary workaround for #6788, #6789

* move async stubs into dvc.scm.asyn

* tests: use sshfs and not dvc.fs.ssh

* allow explicit 0-length read in asyncssh vendor
2021-10-21 14:36:45 +09:00
b652670cbf Dependabot/pip/pylint 2.11.1 (#6843)
* build(deps): Bump pylint from 2.9.6 to 2.11.1

Bumps [pylint](https://github.com/PyCQA/pylint) from 2.9.6 to 2.11.1.
- [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.9.6...v2.11.1)

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

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

* fix pylint problems

* Updation after rebase.

* system: use binary mode

* Set remote execution script encoding type locale

* Update dvc/machine/__init__.py

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-10-20 14:34:10 +03:00
Gao 6de3e734ed Revert "build(deps): Bump pylint from 2.9.6 to 2.11.1 (#6636)" (#6842)
This reverts commit 464620806c.
2021-10-20 12:41:00 +03:00
Gao 4b2bec89ec dvc machine: add a new command rename to it (#6633)
* `dvc machine`: add a new command `rename` to it

fix #6481
1. add new command `dvc machine rename`
2. add a unit test for the call.
3. add several functional tests for it.t push myself fix6481

* Move rename logic to tpi

* use tpi.state_mv instead of rename

* Rebase to a newer version

* Update tpi version

* Remove unused assert and try catch
2021-10-20 15:57:44 +09:00
Saugat Pachhai 6c0bc9563c enable pywin32 for Python3.10, make it an optional dependency (#6841) 2021-10-20 11:25:00 +05:45
f964a44e7c Exp show md (#6799)
* Added `--md`/`--show-md` option to `exp show`.

* Added test_show_experiments_md

* Fixed re-defining all_experiments

* Fixed all_experiments

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

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

* Added dest markdown

* Added textwrap.dedent

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

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

* Update experiments.py

* Apply suggestions from code review

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

* Update tests/unit/command/test_compat_flag.py

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Saugat Pachhai <[email protected]>
2021-10-20 11:04:57 +05:45
464620806c build(deps): Bump pylint from 2.9.6 to 2.11.1 (#6636)
* build(deps): Bump pylint from 2.9.6 to 2.11.1

Bumps [pylint](https://github.com/PyCQA/pylint) from 2.9.6 to 2.11.1.
- [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.9.6...v2.11.1)

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

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

* fix pylint problems
* system: use binary mode

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: karajan1001 <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-10-20 10:32:44 +08:00
Saugat Pachhai 4244e5a475 fix #6835 (#6837) 2021-10-19 21:59:09 +03:00
Saugat Pachhai 0c41a25bbe move some configs to pyproject.toml (#6834) 2021-10-19 19:47:51 +03:00
Matthew Beckers 26f435a433 exp run: throw error if new parameters are added by --set-param (#6521)
* throw error if new parameters are added by dvc run exp -S

Throws an error if dvc run exp --set-param references a parameter key path
that is not in the target config file. This is done to prevent silent errors
in experimentation when new params are accidentally added instead of modifying
current ones due to typos.

This implementation is based on initial suggestions in issue #5477.

* fix mypy confusion from overwriting a var

* tests: remove modify param test that was adding params

One of the test in test_modify_params was still adding a parameter

* tests: remove param modification during exp run in test_untracked

Spurious usage of modifying a parameter when the params.yaml is not yet existing in test_untracked.

* remove benedict dependency for verifying params

Instead, the keypath util module from benedict is adapted into the dvc codebased and used
natively to verify that a keypath exists within a dict.

* exp: list all invalid param modifications in error message

* exp-run: more specific exception for new params via --set-param

Uses established MissingPramsError instead of DvcException. Modified message to be more similar to other uses of this exception.

* Revert "remove benedict dependency for verifying params"

This reverts commit 6170f16dcc4033667fbacb0d0bc13c60898f58c4.

* exp: use internal benedict import for validating params

* exp: combine new param validation into merge_params

Moves new param validation into existing merge_params function as an optional check

* exp: minor ammendments to error message

* Revert "tests: remove param modification during exp run in test_untracked"

This reverts commit c9eb1a62af58586509ded362cc79e75ddc4b119c.

* exp: fix test_untracked to not fail due to new params

Fixed by creating the params file with the new parameter already present.
2021-10-19 17:46:36 +09:00
966dc8a1ca machine: Add a new command dvc machine status (#6649)
* machine: Add a new command `dvc machine status`

fix#6482
1. add a new command `dvc machien status`
2. add a unit test for the cli call.
3. add a unit test for the shown result.

* Update dvc/command/machine.py

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

* Update dvc/command/machine.py

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

* Update machine.py

* Remove sensitive fields

* Update dvc/machine/__init__.py

Co-authored-by: Casper da Costa-Luis <[email protected]>

* Update tests/func/machine/__init__.py

Co-authored-by: Casper da Costa-Luis <[email protected]>

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

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

* Use white list to limit shown fields

Co-authored-by: Saugat Pachhai <[email protected]>
Co-authored-by: Casper da Costa-Luis <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-10-19 17:38:11 +09:00
eggqq007andZhenyu Gao 6d96ecb376 dvcfs: handle new workspace subdirectories in walk (#6811)
1. add try-catch to catch KeyError exception while walking dvc folder
2. Fix #6695

Co-authored-by: Zhenyu Gao <[email protected]>
2021-10-19 17:33:02 +09:00
Batuhan Taskaya 9e57d1c32d gdrive: remove redundant _gdrive_retry function (#6831) 2021-10-18 21:52:39 +00:00
Batuhan Taskaya c08655ac38 plots: fix the dictionary to return PathInfos (#6830) 2021-10-18 22:04:21 +02:00
David de la Iglesia Castro 4f16852005 exp/metrics/params diff: Replaced Old/New with a_rev/b_rev (#6814)
* Replaced Old/New with a_rev/b_rev

* Fixed tests
2021-10-18 21:09:02 +03:00
Ruslan Kuprieiev 7bef6fd64f setup: bump fsspec deps (#6828) 2021-10-18 21:01:03 +03:00
pre-commit-ci[bot] 422d883e18 [pre-commit.ci] pre-commit autoupdate (#6829)
updates:
- [github.com/lovesegfault/beautysh: v6.1.0 → v6.2.1](https://github.com/lovesegfault/beautysh/compare/v6.1.0...v6.2.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-10-18 20:47:48 +03:00
Saugat Pachhai 48004b1340 Revert "webdav: limit httpx to fix the CI" (#6827)
* Revert "webdav: limit httpx to fix the CI (#6800)"

This reverts commit af34f5a0f1.

* Bump webdav4 requirement
2021-10-18 20:32:46 +03:00
dependabot[bot] 2ae421cea2 build(deps-dev): Bump filelock from 3.3.0 to 3.3.1 (#6825)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.3.0 to 3.3.1.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/py-filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.3.0...3.3.1)

---
updated-dependencies:
- dependency-name: filelock
  dependency-type: direct:development
  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-10-18 19:53:37 +05:45
dependabot[bot] 812fde9891 build(deps-dev): Bump types-requests from 2.25.9 to 2.25.11 (#6820)
Bumps [types-requests](https://github.com/python/typeshed) from 2.25.9 to 2.25.11.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-type: direct:development
  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-10-18 19:51:13 +05:45
dependabot[bot] 4ed4766692 build(deps): Bump actions/checkout from 2.3.4 to 2.3.5 (#6817)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2.3.4 to 2.3.5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2.3.4...v2.3.5)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-10-18 19:43:41 +05:45
dependabot[bot] 9943cd43b1 build(deps-dev): Bump types-tabulate from 0.8.2 to 0.8.3 (#6790)
Bumps [types-tabulate](https://github.com/python/typeshed) from 0.8.2 to 0.8.3.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-tabulate
  dependency-type: direct:development
  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-10-18 19:42:50 +05:45
dependabot[bot] 7d3f35e6ab build(deps-dev): Bump types-toml from 0.10.0 to 0.10.1 (#6821)
Bumps [types-toml](https://github.com/python/typeshed) from 0.10.0 to 0.10.1.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-toml
  dependency-type: direct:development
  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-10-18 20:57:43 +09:00
dependabot[bot] 4de261f717 build(deps-dev): Bump dvclive from 0.4.0 to 0.4.1 (#6786)
Bumps [dvclive](https://github.com/iterative/dvclive) from 0.4.0 to 0.4.1.
- [Release notes](https://github.com/iterative/dvclive/releases)
- [Commits](https://github.com/iterative/dvclive/compare/0.4.0...0.4.1)

---
updated-dependencies:
- dependency-name: dvclive
  dependency-type: direct:development
  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-10-18 13:33:26 +03:00
Paweł Redzyński db75878bef plots: make generator out of collect (#6793) 2021-10-15 17:24:02 +03:00
Ruslan Kuprieiev 7f5c53c10e ui: write_json: don't forget json kwargs (#6810)
Regression from #6743

Fixes #6803
2021-10-15 01:30:22 +03:00
Ruslan Kuprieiev 259d590527 setup.cfg: remove types-paramiko (#6807)
We are no longer using them since migration to sshfs.

Closes #6806
2021-10-14 16:00:10 +03:00
dependabot[bot]andRuslan Kuprieiev 709bfe598f build(deps): Update flufl-lock requirement from <4,>=3.2 to >=5 (#6576)
* build(deps): Update flufl-lock requirement from <4,>=3.2 to >=3.2,<7

Updates the requirements on [flufl-lock](https://gitlab.com/warsaw/flufl.lock) to permit the latest version.
- [Release notes](https://gitlab.com/warsaw/flufl.lock/tags)
- [Commits](https://gitlab.com/warsaw/flufl.lock/compare/3.2...6.0)

---
updated-dependencies:
- dependency-name: flufl-lock
  dependency-type: direct:production
...

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

* upgrade flufl.lock

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-10-14 15:26:40 +03:00
David de la Iglesia Castro 7e4f443002 Added test_show_experiments_sort_by (#6798)
* Added test_show_experiments_sort_by

* Added comment

* help formatting
2021-10-14 14:49:00 +03:00
2a3a93314b pathspec 0.9.0 quickfix (#6689)
* pathspec 0.9.0 quickfix

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

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

* restrict requirement properly

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

* revert change_rule update

* adjust tests to pathspec 0.9.0

* lower bound restrict pathspec

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Gao <[email protected]>
Co-authored-by: Saugat Pachhai (सौगात) <[email protected]>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2021-10-14 14:25:03 +03:00
Paweł Redzyński a1b146669c plots: load templates from package (#6550)
* plots: vega: make interactive linear default plot

* plots: stop dumping default templates

* plots: move templates to package initial

* plots: update default plots schema

* plots: write defaults on init

* templates: migrate to importlib.resources

* fixup

* fixup
2021-10-14 08:40:31 +02:00
Peter Rowlands (변기호) b8478a8b04 machine: re-add instance dir management from TPI (#6748)
* machine: re-add instance dir management from TPI

* update default constructor

* machine: lazy load Repo.machine
2021-10-14 12:40:30 +09:00
Michael Lingelbach 0ae9130052 dvc: add 'repos' list to pre-commit config if it does not exist (#6795) 2021-10-13 22:50:25 +03:00
Batuhan Taskaya af34f5a0f1 webdav: limit httpx to fix the CI (#6800) 2021-10-13 16:49:54 +03:00
Ruslan Kuprieiev e9a7e0657d dvc: drop 3.6 support (#6792)
* dvc: drop 3.6 support

Considering that 3.6 has ~2 months until EOL, and our deps are
dropping support for it already (e.g. pygit2), we are droppipping
it as well. This will reduce the load on our test CI as well, since
we've started supporting 3.10 recently.

* setup.cfg: adjust requirements

* daemon: remove 3.6 logic

* tests: utils: remove pre 3.7 logic
2021-10-12 18:53:28 +03:00
Saugat PachhaiandRuslan Kuprieiev 8abda93e3e use setuptools_scm and build_requires (#6775)
* use setuptools_scm and build_requires

* remove kwargs from setup.py

* fix tests; fetch in depth in github actions

* use build to build sdist/wheel

* try updating pip and setuptools

* update pip

* remove or unpin wheel

* bring back the version in pyinstaller

* Update scripts/build.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-10-12 17:58:14 +03:00
Ruslan Kuprieiev 53255c22b3 gha: tests: temporarily disable some remote tests (#6789)
* gha: tests: temporarily disable some remote tests

See #6788

* machine: upgrade tpi version

```
dvc/machine/backend/terraform.py:28:19: E1101: Instance of 'TerraformBackend' has no '_default_resource' member; maybe 'default_resource'? (no-member)
```

* tests: remotes: require --s3 for s3 tests
2021-10-12 17:26:47 +03:00
Paweł RedzyńskiandJorge Orpinel 6da15629c4 plots: modify: add note on modyfying image type plots (#6742)
* plots: modify: add note on modyfying image type plots

* Update dvc/command/plots.py

Co-authored-by: Jorge Orpinel <[email protected]>

Co-authored-by: Jorge Orpinel <[email protected]>
2021-10-12 16:08:49 +03:00
Ruslan Kuprieiev 5cfe61928e tree: use oids instead of objects (#6785) 2021-10-12 05:39:10 +03:00
Ruslan Kuprieiev 32467ada32 objects: extract size/nfiles to Meta (#6784)
* stage: capture metadata

* tree: capture metadata

* objects: extract size/nfiles from HashInfo/HashFile/Tree to Meta
2021-10-12 03:06:18 +03:00
pre-commit-ci[bot] 5cbaa8d98e [pre-commit.ci] pre-commit autoupdate (#6782)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.910 → v0.910-1](https://github.com/pre-commit/mirrors-mypy/compare/v0.910...v0.910-1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-10-12 02:27:56 +03:00
Ruslan Kuprieiev 4762c38ff7 dvc: bump to 2.8.1 2021-10-11 16:09:51 +03:00
Ruslan Kuprieiev 66acbc892a setup.cfg: use text/x-rst instead of text/rst
Per https://packaging.python.org/specifications/core-metadata/#description-content-type
2021-10-11 16:09:29 +03:00
Ruslan Kuprieiev d2edbae7e3 dvc: bump to 2.8.0 2021-10-11 15:47:58 +03:00
Batuhan Taskaya be0c827b95 fs: add basic type annotations to FsspecWrapper (#6779) 2021-10-11 15:15:56 +03:00
Paweł Redzyński d6ecef7169 plots: fix wrong link (#6693) 2021-10-11 12:22:20 +02:00
eggqq007andZhenyu Gao e8b649a75f Fix exp show --sort-by issue #6675 (#6764)
Co-authored-by: Zhenyu Gao <[email protected]>
2021-10-11 16:46:38 +09:00
Batuhan Taskaya 8767f42f39 gs: use 3-char prefixes (#6759)
Resolves #6691. Normally it should have inherited this from the `ObjectFSWrapper`, but this seems like a leftover.
2021-10-11 16:16:17 +09:00
Paweł Redzyński 8580e66d60 gha: move cancelaltion into other workflows (#6771) 2021-10-08 18:03:07 +03:00
Saugat Pachhai c24ae2b0c3 exp init: show defaults in help message (#6763) 2021-10-08 20:14:12 +05:45
Saugat Pachhai 4e17279440 exp init: add config support (#6761) 2021-10-08 20:14:01 +05:45
Batuhan Taskaya 50cdf4f213 fsspec: normalize checksum() to return string (#6767) 2021-10-08 16:57:37 +03:00
Saugat Pachhai f628f670c9 advertise Python 3.10 support (#6772)
* advertise Python 3.10 support

Pygit2 does not have windows wheels yet and no pyarrow
for 3.10. But these platform-dependent things and
remote-dependent things should not stop us from
advertising support as we are pure-python based
package and we cannot claim support for all of our
dependencies.

* set pygit2 for Python 3.6 to <1.7
2021-10-08 15:50:05 +03:00
Batuhan Taskaya d7489bb65f hdfs: enable mocked hdfs tests by default (#6766) 2021-10-08 12:42:41 +02:00
dependabot[bot]andPaweł Redzyński 5a4f4d3a8e build(deps): Bump filelock from 3.2.0 to 3.3.0 (#6737)
* build(deps): Bump filelock from 3.2.0 to 3.3.0

Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.2.0...3.3.0)

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

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

* tests: remotes: ignore abstract class instantiation for filelock

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paweł Redzyński <[email protected]>
2021-10-08 15:36:33 +05:45
dependabot[bot] 2c24b24562 build(deps-dev): Bump types-requests from 2.25.6 to 2.25.9 (#6770)
Bumps [types-requests](https://github.com/python/typeshed) from 2.25.6 to 2.25.9.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-type: direct:development
  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-10-08 14:59:09 +05:45
dependabot[bot] df9f76c2c0 build(deps-dev): Bump types-paramiko from 0.1.9 to 2.7.1 (#6769)
Bumps [types-paramiko](https://github.com/python/typeshed) from 0.1.9 to 2.7.1.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-paramiko
  dependency-type: direct:development
  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-10-08 14:58:19 +05:45
dependabot[bot] 0db3ece060 build(deps-dev): Bump types-toml from 0.1.5 to 0.10.0 (#6768)
Bumps [types-toml](https://github.com/python/typeshed) from 0.1.5 to 0.10.0.
- [Release notes](https://github.com/python/typeshed/releases)
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-toml
  dependency-type: direct:development
  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-10-08 14:58:08 +05:45
Saugat Pachhai 7c437cc349 make dependabot updates work (#6760)
Dependabot was throwing Illformed requirements `"< '3.10'"` for the
hdfs. But I noticed that it did accept other requirements where we
do similar. So it seems the indentation fixes it.

Even then, dependabot did not update any dependencies. I removed
setup.py file, after which immediately it started creating PRs.
So, I recknoed that it must have been due to some mechanism
in place from dependabot to disallow execution, so I tried to
remove few blocks of code from setup.py. And, I was eventually
able to figure out that it was from the block where we import version.

So, I added a try-except block and it magically again worked. :)

Let's see how it goes.
2021-10-08 14:46:47 +05:45
Saugat Pachhai 3ed5efceb7 move defaults to the class level (#6762) 2021-10-08 12:18:28 +05:45
dependabot[bot]andPaweł Redzyński d33df60e41 build(deps): Bump dvclive from 0.3.0 to 0.4.0 (#6753)
* build(deps): Bump dvclive from 0.3.0 to 0.4.0

Bumps [dvclive](https://github.com/iterative/dvclive) from 0.3.0 to 0.4.0.
- [Release notes](https://github.com/iterative/dvclive/releases)
- [Commits](https://github.com/iterative/dvclive/compare/0.3.0...0.4.0)

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

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

* live: adjust tests to dvclive==0.4.0

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paweł Redzyński <[email protected]>
2021-10-08 07:02:54 +05:45
Saugat Pachhai c5415342d0 move requirements to setup.cfg (#6758)
* move requirements to setup.cfg

* hash based on setup.cfg

* do not break webhdfs installation
2021-10-08 07:00:14 +05:45
Paweł Redzyński dd86076ba6 gha: cancel previous workflows (#6756) 2021-10-07 17:59:44 +03:00
Ruslan Kuprieiev 7dc3859400 pyinstaller: hooks: remove webhdfs 2021-10-07 17:30:22 +03:00
Batuhan Taskaya 4f3b28e268 webhdfs: migrate to fsspec (#6662)
* webhdfs: migrate to fsspec

* skip the tests that can't use hdfs
2021-10-07 17:02:31 +03:00
Ruslan Kuprieiev 32f435c47f gha: benchmarks: fix pytest flags (#6749)
Kudos @skshetry for catching.
2021-10-06 17:07:41 +03:00
Batuhan Taskaya 054feae778 hdfs: migrate to fsspec (#6604)
* hdfs: migrate to fsspec

* normalize windows paths when delegating back to fsspec
2021-10-06 15:44:27 +03:00
Saugat Pachhai 2abe515391 Pin dvclive to 0.3.0 2021-10-06 14:28:36 +05:45
eggqq007andZhenyu Gao 1eb89a2dc0 command: Should not consume 'print' to output default remote info (#6650)
1. should not consume 'print' to output default remote info,
   otherwise the --quiet would not work.

Co-authored-by: Zhenyu Gao <[email protected]>
2021-10-06 09:27:47 +05:45
eggqq007andZhenyu Gao 9c300a0a1c command: output prettify json in tty (#6743)
1. update diff.py,experiments.py,metrics.py,params.py,status.py to
print prettify json
2. rewrite write_json in __init__.py to accept default function
3. add new unit test for write_json in tests/unit/ui/test_console.py
4. Fix #6712

Co-authored-by: Zhenyu Gao <[email protected]>
2021-10-05 20:19:56 +05:45
Ruslan Kuprieiev 7b68a6f0ca gha: run benchmarks on PRs (#6733) 2021-10-05 14:18:43 +03:00
Saugat Pachhai ee823a59b9 Run on Python3.10 (#6745) 2021-10-05 16:46:53 +05:45
David de la Iglesia CastroandSaugat Pachhai 16f404b16a Fix dvclive running subdir (#6740)
* Fix dvclive running subdir

* Apply suggestions from code review

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

* Applied suggestions

* Update tests/func/test_live.py

Co-authored-by: Saugat Pachhai <[email protected]>
2021-10-05 13:16:33 +05:45
Saugat Pachhai 0eda4bd91c exp init: only ask for that are not provided in an interactive mode (#6739) 2021-10-04 14:33:08 +05:45
Saugat Pachhai a098a50c8d move exp init logic to dvc.repo.experiments.init (#6738) 2021-10-04 12:26:34 +05:45
dependabot[bot] f57a378c0c build(deps): Bump jaraco-windows from 5.6.0 to 5.7.0 (#6735)
Bumps [jaraco-windows](https://github.com/jaraco/jaraco.windows) from 5.6.0 to 5.7.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.6.0...v5.7.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-10-04 10:12:47 +05:45
dependabot[bot] 3e0a50e52e build(deps): Bump pytest-cov from 2.12.1 to 3.0.0 (#6736)
Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 2.12.1 to 3.0.0.
- [Release notes](https://github.com/pytest-dev/pytest-cov/releases)
- [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-cov/compare/v2.12.1...v3.0.0)

---
updated-dependencies:
- dependency-name: pytest-cov
  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-10-04 09:31:27 +05:45
Guillaume COTER ec72866eb9 Bump typing_extensions to >=3.7.4 (#6731) (#6732) 2021-10-03 20:10:28 +05:45
Batuhan Taskaya e53d390ff9 http: explicitly ignore all http-related errors (from server side to client connection drops) (#6728) 2021-10-02 20:42:23 +03:00
Batuhan Taskaya dd330ac592 setup: bump fsspec/s3fs/gcsfs (#6727) 2021-10-02 18:54:09 +02:00
pwoolvett 4ccb712b6d build(setup): avoid leaking tests package into site-packages (#6585)
Signed-off-by: Pablo Woolvett <[email protected]>
2021-10-02 16:46:45 +03:00
Saugat Pachhai ecee2d3522 Move static metadata to setup.cfg (#6581) 2021-10-02 16:08:30 +03:00
20bb6ddf3c doctor: fixes unknown cache directory error on some windows mapped network shares (#6364)
* doctor: fixes unknown cache director error on some windows mapped network shares

On Windows, resolve calls os.path._getfinalpathname, which calls GetFinalPathNameByHandle.
This starts from the final resolved path of the file in the NT namespace (e.g. \Device\Mup\server\share...)
and works backward as required to get the VOLUME_NAME_DOS. If the final path is a UNC share (i.e. \Device\Mup),
We fix this by calling resolve() also on the partition mountpoints
We also take the opportunity to make checks in case a PermissionError is returned to us

Fixes github.com/iterative/dvc/issues/6305

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

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

* doctor: fixes unknown cache director error on some windows mapped network shares

On Windows, resolve calls os.path._getfinalpathname, which calls GetFinalPathNameByHandle.
This starts from the final resolved path of the file in the NT namespace (e.g. \Device\Mup\server\share...)
and works backward as required to get the VOLUME_NAME_DOS. If the final path is a UNC share (i.e. \Device\Mup),
We fix this by calling resolve() also on the partition mountpoints
We also take the opportunity to make checks in case a PermissionError is returned to us

Fixes #6305

Co-authored-by: CHETOUAN <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: CHETOUAN <[email protected]>
2021-10-02 16:06:42 +03:00
Saugat Pachhai e58fcd864b [WIP] exp init: introduce --with={default,live} flag (#6716)
* exp init: introduce `--with={default,live}` flag

This flag will select the different mode of outputs of stage.
With `--with=live`, it'll enable `live` setting in the stage.
On interactive mode, it'll always ask user if they want to
use live (unless `--with=live` is specified), and then it'll
skip metrics/plots question and moves on to ask for path to
dvclive path.

Also, if live setting is enabled in the stage, it'll set the
`models` to be a checkpoint output.

* s/with/type and don't ask for live interactively
2021-10-02 16:12:44 +05:45
Saugat Pachhai 0514db3bfa deps: loose requirements for dataclasses and aiohttp-retry (#6724) 2021-10-01 17:00:03 +05:45
Saugat Pachhai 1e0b2d7af0 benedict: monkeypatch, performance improvements, bugfixes (#6706)
Fixes #6476, fixes #6423 and closes #6484.
2021-10-01 18:16:47 +09:00
Casper da Costa-Luis bfcc8f877c add licence year + company (#6717) 2021-10-01 09:54:55 +02:00
Saugat Pachhai 46ad8745d3 Use latest pygit2 from master for 3.10 (#6723)
* Use latest pygit2 from master for 3.10

* ci: update brew
2021-10-01 11:48:11 +05:45
dependabot[bot] eb13d3ca83 build(deps): Bump google-cloud-storage from 1.42.2 to 1.42.3 (#6722)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.42.2 to 1.42.3.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.42.2...v1.42.3)

---
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-10-01 08:27:40 +05:45
Ruslan Kuprieiev f46beb4958 checkout: move to objects (#6721)
Checkout operation is pretty much the opposite of staging, so it too belongs in objects.
2021-09-30 23:06:03 +03:00
Saugat Pachhai 9a1d74048a cli: add short alias for --show-{csv,json,md} (#6711)
As `--csv`/`--json`/`--md`
2021-09-30 20:38:32 +05:45
dependabot[bot] cce6aed378 build(deps): Bump filelock from 3.1.0 to 3.2.0 (#6710)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.1.0...3.2.0)

---
updated-dependencies:
- dependency-name: filelock
  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-09-30 08:49:39 +05:45
Saugat Pachhai 612696cf4a deps: add contextvars as a dependency for Python3.6 (#6705) 2021-09-29 10:39:14 +05:45
Saugat Pachhai (सौगात) 3f5f983f5f ui: import sys on write_json 2021-09-29 09:59:30 +05:45
eggqq007andZhenyu Gao fed9f088ca command: Show dvc list output with pretty JSON format if output to tty (#6647)
1. update ls __init__.py to output pretty JSON format
2. introduce write_json function into ui __init__.py

Co-authored-by: Zhenyu Gao <[email protected]>
2021-09-29 08:44:33 +05:45
Saugat Pachhai 05eaeef012 utils.http: remove open url (#6700)
This was used before on opening http urls as a stream,
but we no longer use those (as we use fsspec's open method).

One thing to note however is that, compared to previous
implementation, fsspec's http filesystem's open is not very
robust to failures.
2021-09-28 19:19:52 +05:45
Saugat Pachhai 48c0527755 exp init: show heading based on the presence of dvc.yaml file (#6701) 2021-09-28 15:11:58 +05:45
Saugat Pachhai 4668a35f64 exp init: support interactive and explicit options (#6681) 2021-09-28 14:36:21 +05:45
dependabot[bot] 8107ec849b build(deps): Bump filelock from 3.0.12 to 3.1.0 (#6698)
Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.0.12 to 3.1.0.
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Commits](https://github.com/tox-dev/py-filelock/compare/v3.0.12...3.1.0)

---
updated-dependencies:
- dependency-name: filelock
  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-09-28 14:18:17 +05:45
dependabot[bot]andSaugat Pachhai 19284b3b4c build(deps): Bump urllib3 from 1.26.6 to 1.26.7 (#6697)
* build(deps): Bump urllib3 from 1.26.6 to 1.26.7

Bumps [urllib3](https://github.com/urllib3/urllib3) from 1.26.6 to 1.26.7.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/1.26.6...1.26.7)

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

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

* Get rid of urllib3 as a test dependency

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Saugat Pachhai <[email protected]>
2021-09-28 13:52:02 +05:45
dependabot[bot] 315d1cde99 build(deps): Bump pytest-xdist from 2.3.0 to 2.4.0 (#6696)
Bumps [pytest-xdist](https://github.com/pytest-dev/pytest-xdist) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/pytest-dev/pytest-xdist/releases)
- [Changelog](https://github.com/pytest-dev/pytest-xdist/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-xdist/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: pytest-xdist
  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-09-28 07:50:59 +00:00
dependabot[bot] 1d415efd0b build(deps): Bump google-cloud-storage from 1.42.1 to 1.42.2 (#6635)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.42.1 to 1.42.2.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.42.1...v1.42.2)

---
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-09-28 13:12:18 +05:45
Saugat Pachhai e7c20aa1d8 better exception messages for schema errors (#6679)
* Generate prettier schema error messages

* add test for #6285
2021-09-28 11:12:16 +05:45
Saugat Pachhai 058377f33e ui: support pager universally (#6692) 2021-09-27 18:11:20 +05:45
Saugat Pachhai c7b1b14905 ui: use ui.rich_text instead of rich.text.Text (#6690) 2021-09-27 12:04:19 +05:45
Amey Kulkarni fbd8f5c185 Issue 6651: Added alias ls for dvc list (#6683) 2021-09-25 21:14:11 +03:00
whysage 7fe370b729 remote: add support for Amazon S3 timeout configuration (#6634)
Fixes #6241
2021-09-25 14:06:43 +03:00
Batuhan Taskaya f9df6d2a16 http: allow reading proxies from the current environment; (#6688) 2021-09-25 11:32:44 +02:00
Ruslan Kuprieiev 2e4fb376da system: get rid of speedcopy (#6686)
speedcopy only patches shutil.copyfile, which we don't even use
anywhere because it lacks callbacks/progress and we instead use
our own copyfile implementation, so this is totally useless for
us and can be removed.
2021-09-25 04:42:45 +03:00
Ruslan Kuprieiev 6d6a3f17b1 system: remove legacy code (#6685) 2021-09-25 03:04:54 +03:00
Ruslan Kuprieiev bdaf4aced6 state: cleanup redundant or localfs-specific logic (#6684)
Pre-requisite for making state work with other filesystems.
2021-09-25 02:11:35 +03:00
Batuhan Taskaya 0bf1802d4b ssh: fix path normalization on Windows (#6517) 2021-09-23 18:23:40 +03:00
Batuhan Taskaya 6d3045320b ssh: use a docker-based testing environment (#6677) 2021-09-23 16:00:48 +03:00
Batuhan Taskaya 3469dda8b8 ssh: bundle bcrypt by default (#6678) 2021-09-23 11:00:13 +02:00
Saugat Pachhai eefede92b2 merge yaml parsing and schema validation into one, aka strictyaml.load (#6661) 2021-09-23 00:03:25 +03:00
Ruslan Kuprieiev e295434ec6 checkout: start switching to obj-based workflow (#6648)
* webhdfs: info/checksum: properly raise FileNotFoundError

* checkout: start switching to obj-based workflow

Related #6594
2021-09-22 02:16:29 +03:00
Saugat Pachhai b1f1b01ffc debug: add yappi, callgrind output support (#6664)
* debug: add yappi, callgrind output support

* support custom path and callables
2021-09-21 20:33:08 +03:00
Casper da Costa-Luis f41d7effdb fix dvc[all] (#6657)
* fix dvc[all]

- related https://github.com/iterative/dvc-snap/issues/24

* fix pylint
2021-09-21 18:54:55 +03:00
Saugat Pachhai 6a417885b8 exceptional exceptions (#6654)
* exceptional exceptions

* backward compatibility with previous exceptions

* make linters happy :)
2021-09-21 11:27:57 +05:45
pre-commit-ci[bot] 4221d9f70b [pre-commit.ci] pre-commit autoupdate (#6659)
updates:
- [github.com/ambv/black: 21.8b0 → 21.9b0](https://github.com/ambv/black/compare/21.8b0...21.9b0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-09-21 09:28:51 +05:45
Ruslan Kuprieiev 5822d53687 tests: upgrade azurite
```
2021-09-21T00:15:31.6789059Z ____________________ ERROR at setup of test_get_url[azure] _____________________
2021-09-21T00:15:31.6790443Z [gw0] linux -- Python 3.7.12 /opt/hostedtoolcache/Python/3.7.12/x64/bin/python
2021-09-21T00:15:31.6791202Z 
2021-09-21T00:15:31.6792291Z request = <FixtureRequest for <Function test_get_url[azure]>>
2021-09-21T00:15:31.6793561Z 
2021-09-21T00:15:31.6794120Z     def fill(request):
2021-09-21T00:15:31.6794792Z         item = request._pyfuncitem
2021-09-21T00:15:31.6795627Z         fixturenames = getattr(item, "fixturenames", None)
2021-09-21T00:15:31.6797090Z         if fixturenames is None:
2021-09-21T00:15:31.6797922Z             fixturenames = request.fixturenames
2021-09-21T00:15:31.6798497Z     
2021-09-21T00:15:31.6799393Z         if hasattr(item, 'callspec'):
2021-09-21T00:15:31.6800256Z             for param, val in sorted_by_dependency(item.callspec.params, fixturenames):
2021-09-21T00:15:31.6801309Z                 if val is not None and is_lazy_fixture(val):
2021-09-21T00:15:31.6802233Z >                   item.callspec.params[param] = request.getfixturevalue(val.name)
2021-09-21T00:15:31.6802912Z 
2021-09-21T00:15:31.6803937Z /opt/hostedtoolcache/Python/3.7.12/x64/lib/python3.7/site-packages/pytest_lazyfixture.py:35: 
2021-09-21T00:15:31.6804952Z _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
2021-09-21T00:15:31.6806085Z /home/runner/work/dvc/dvc/tests/remotes/azure.py:102: in azure
2021-09-21T00:15:31.6807090Z     container.get_container_properties()
2021-09-21T00:15:31.6808748Z /opt/hostedtoolcache/Python/3.7.12/x64/lib/python3.7/site-packages/azure/core/tracing/decorator.py:83: in wrapper_use_tracer
2021-09-21T00:15:31.6822551Z     return func(*args, **kwargs)
2021-09-21T00:15:31.6824143Z /opt/hostedtoolcache/Python/3.7.12/x64/lib/python3.7/site-packages/azure/storage/blob/_container_client.py:497: in get_container_properties
2021-09-21T00:15:31.6825038Z     process_storage_error(error)
2021-09-21T00:15:31.6825652Z _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
2021-09-21T00:15:31.6825908Z 
2021-09-21T00:15:31.6826916Z storage_error = HttpResponseError("Operation returned an invalid status 'The value for one of the HTTP headers is not in the correct format.'")
2021-09-21T00:15:31.6827586Z 
2021-09-21T00:15:31.6828351Z     def process_storage_error(storage_error):   # pylint:disable=too-many-statements
2021-09-21T00:15:31.6829086Z         raise_error = HttpResponseError
2021-09-21T00:15:31.6829597Z         serialized = False
2021-09-21T00:15:31.6830066Z         if not storage_error.response:
2021-09-21T00:15:31.6830554Z             raise storage_error
2021-09-21T00:15:31.6831558Z         # If it is one of those three then it has been serialized prior by the generated layer.
2021-09-21T00:15:31.6832480Z         if isinstance(storage_error, (PartialBatchErrorException,
2021-09-21T00:15:31.6833636Z                                       ClientAuthenticationError, ResourceNotFoundError, ResourceExistsError)):
2021-09-21T00:15:31.6834687Z             serialized = True
2021-09-21T00:15:31.6835753Z         error_code = storage_error.response.headers.get('x-ms-error-code')
2021-09-21T00:15:31.6836524Z         error_message = storage_error.message
2021-09-21T00:15:31.6837001Z         additional_data = {}
2021-09-21T00:15:31.6837398Z         error_dict = {}
2021-09-21T00:15:31.6837739Z         try:
2021-09-21T00:15:31.6838506Z             error_body = ContentDecodePolicy.deserialize_from_http_generics(storage_error.response)
2021-09-21T00:15:31.6839345Z             # If it is an XML response
2021-09-21T00:15:31.6839837Z             if isinstance(error_body, Element):
2021-09-21T00:15:31.6840285Z                 error_dict = {
2021-09-21T00:15:31.6840750Z                     child.tag.lower(): child.text
2021-09-21T00:15:31.6841435Z                     for child in error_body
2021-09-21T00:15:31.6841822Z                 }
2021-09-21T00:15:31.6842195Z             # If it is a JSON response
2021-09-21T00:15:31.6842692Z             elif isinstance(error_body, dict):
2021-09-21T00:15:31.6843413Z                 error_dict = error_body.get('error', {})
2021-09-21T00:15:31.6843893Z             elif not error_code:
2021-09-21T00:15:31.6844347Z                 _LOGGER.warning(
2021-09-21T00:15:31.6845611Z                     'Unexpected return type % from ContentDecodePolicy.deserialize_from_http_generics.', type(error_body))
2021-09-21T00:15:31.6846887Z                 error_dict = {'message': str(error_body)}
2021-09-21T00:15:31.6847294Z     
2021-09-21T00:15:31.6847730Z             # If we extracted from a Json or XML response
2021-09-21T00:15:31.6848205Z             if error_dict:
2021-09-21T00:15:31.6848792Z                 error_code = error_dict.get('code')
2021-09-21T00:15:31.6849704Z                 error_message = error_dict.get('message')
2021-09-21T00:15:31.6850578Z                 additional_data = {k: v for k, v in error_dict.items() if k not in {'code', 'message'}}
2021-09-21T00:15:31.6851392Z         except DecodeError:
2021-09-21T00:15:31.6851801Z             pass
2021-09-21T00:15:31.6852290Z     
2021-09-21T00:15:31.6852601Z         try:
2021-09-21T00:15:31.6853156Z             # This check would be unnecessary if we have already serialized the error
2021-09-21T00:15:31.6853821Z             if error_code and not serialized:
2021-09-21T00:15:31.6854423Z                 error_code = StorageErrorCode(error_code)
2021-09-21T00:15:31.6855164Z                 if error_code in [StorageErrorCode.condition_not_met,
2021-09-21T00:15:31.6856229Z                                   StorageErrorCode.blob_overwritten]:
2021-09-21T00:15:31.6856938Z                     raise_error = ResourceModifiedError
2021-09-21T00:15:31.6857739Z                 if error_code in [StorageErrorCode.invalid_authentication_info,
2021-09-21T00:15:31.6858641Z                                   StorageErrorCode.authentication_failed]:
2021-09-21T00:15:31.6859468Z                     raise_error = ClientAuthenticationError
2021-09-21T00:15:31.6860256Z                 if error_code in [StorageErrorCode.resource_not_found,
2021-09-21T00:15:31.6861256Z                                   StorageErrorCode.cannot_verify_copy_source,
2021-09-21T00:15:31.6861998Z                                   StorageErrorCode.blob_not_found,
2021-09-21T00:15:31.6862716Z                                   StorageErrorCode.queue_not_found,
2021-09-21T00:15:31.6863718Z                                   StorageErrorCode.container_not_found,
2021-09-21T00:15:31.6864526Z                                   StorageErrorCode.parent_not_found,
2021-09-21T00:15:31.6865432Z                                   StorageErrorCode.share_not_found]:
2021-09-21T00:15:31.6866549Z                     raise_error = ResourceNotFoundError
2021-09-21T00:15:31.6867609Z                 if error_code in [StorageErrorCode.account_already_exists,
2021-09-21T00:15:31.6868499Z                                   StorageErrorCode.account_being_created,
2021-09-21T00:15:31.6869383Z                                   StorageErrorCode.resource_already_exists,
2021-09-21T00:15:31.6870728Z                                   StorageErrorCode.resource_type_mismatch,
2021-09-21T00:15:31.6871688Z                                   StorageErrorCode.blob_already_exists,
2021-09-21T00:15:31.6872429Z                                   StorageErrorCode.queue_already_exists,
2021-09-21T00:15:31.6873252Z                                   StorageErrorCode.container_already_exists,
2021-09-21T00:15:31.6874124Z                                   StorageErrorCode.container_being_deleted,
2021-09-21T00:15:31.6874922Z                                   StorageErrorCode.queue_being_deleted,
2021-09-21T00:15:31.6875857Z                                   StorageErrorCode.share_already_exists,
2021-09-21T00:15:31.6876608Z                                   StorageErrorCode.share_being_deleted]:
2021-09-21T00:15:31.6877289Z                     raise_error = ResourceExistsError
2021-09-21T00:15:31.6877807Z         except ValueError:
2021-09-21T00:15:31.6878259Z             # Got an unknown error code
2021-09-21T00:15:31.6878648Z             pass
2021-09-21T00:15:31.6878942Z     
2021-09-21T00:15:31.6879424Z         # Error message should include all the error properties
2021-09-21T00:15:31.6879908Z         try:
2021-09-21T00:15:31.6880416Z             error_message += "\nErrorCode:{}".format(error_code.value)
2021-09-21T00:15:31.6881289Z         except AttributeError:
2021-09-21T00:15:31.6883082Z             error_message += "\nErrorCode:{}".format(error_code)
2021-09-21T00:15:31.6883807Z         for name, info in additional_data.items():
2021-09-21T00:15:31.6884446Z             error_message += "\n{}:{}".format(name, info)
2021-09-21T00:15:31.6884932Z     
2021-09-21T00:15:31.6885741Z         # No need to create an instance if it has already been serialized by the generated layer
2021-09-21T00:15:31.6886538Z         if serialized:
2021-09-21T00:15:31.6887019Z             storage_error.message = error_message
2021-09-21T00:15:31.6887526Z             error = storage_error
2021-09-21T00:15:31.6887908Z         else:
2021-09-21T00:15:31.6888499Z             error = raise_error(message=error_message, response=storage_error.response)
2021-09-21T00:15:31.6889380Z         # Ensure these properties are stored in the error instance as well (not just the error message)
2021-09-21T00:15:31.6890078Z         error.error_code = error_code
2021-09-21T00:15:31.6890630Z         error.additional_info = additional_data
2021-09-21T00:15:31.6891956Z         # error.args is what's surfaced on the traceback - show error message in all cases
2021-09-21T00:15:31.6892672Z         error.args = (error.message,)
2021-09-21T00:15:31.6893112Z         try:
2021-09-21T00:15:31.6893784Z             # `from None` prevents us from double printing the exception (suppresses generated layer error context)
2021-09-21T00:15:31.6894873Z >           exec("raise error from None")   # pylint: disable=exec-used # nosec
2021-09-21T00:15:31.6896126Z E           azure.core.exceptions.HttpResponseError: The value for one of the HTTP headers is not in the correct format.
2021-09-21T00:15:31.6897385Z E           RequestId:d83b8102-bde1-4a77-aa7a-b67bbc2c4a80
2021-09-21T00:15:31.6898080Z E           Time:2021-09-21T00:09:53.815Z
2021-09-21T00:15:31.6898578Z E           ErrorCode:InvalidHeaderValue
2021-09-21T00:15:31.6899259Z E           headername:x-ms-version
2021-09-21T00:15:31.6900066Z E           headervalue:2020-10-02
2021-09-21T00:15:31.6900355Z 
2021-09-21T00:15:31.6901781Z /opt/hostedtoolcache/Python/3.7.12/x64/lib/python3.7/site-packages/azure/storage/blob/_shared/response_handlers.py:177: HttpResponseError
```
2021-09-21 03:53:06 +03:00
Batuhan Taskaya a478a0ea98 progress: ignore when set_size callback is called with None (#6612) 2021-09-21 03:34:17 +03:00
Ruslan Kuprieiev e81c88e501 gha: increase timeouts (#6645) 2021-09-19 23:47:15 +03:00
b286c311f4 reincorporate PR 6111 into current master, re: #4420 (#6419)
* rebased PR 6111 onto master

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

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

* use make_tmp_dir fixture in tests

Co-authored-by: U119511 <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Alexander Meier <[email protected]>
Co-authored-by: meierale <[email protected]>
2021-09-18 22:36:50 +03:00
Ruslan Kuprieiev d39c0ce618 dvc: bump to 2.7.4 2021-09-18 00:30:41 +03:00
Saugat PachhaiandRuslan Kuprieiev 1a8680de7d tests: additional callbacks test for remaining filesystems (#6641)
* tests: additional callbacks test for remaining filesystems

* requirements: bump pydrive2 to 1.9.4

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-09-17 21:31:25 +03:00
Nikita Kodenko 696c704632 tests: replace several unittest.mock usages with mocker (#6544)
* tests: several unittest.mock usages replaced with mocker

* tests: fixes in tests/unit/repo/test_reproduce.py

* tests: rolled back WiP tests
2021-09-17 18:22:34 +03:00
Paweł Redzyński 3e68cd7105 plots: use dpath to explore data (#6639) 2021-09-17 15:09:28 +02:00
Saugat Pachhai 73088fc7b9 dvc.objects: remove unused callback (#6638) 2021-09-17 13:39:54 +03:00
Saugat PachhaiandDave Berenbaum 978d5ac227 exp init: add simple dvc exp init command (#6621)
* exp init: add simple dvc exp init command

* Apply suggestions from code review

Co-authored-by: Dave Berenbaum <[email protected]>

Co-authored-by: Dave Berenbaum <[email protected]>
2021-09-16 16:21:26 +05:45
David de la Iglesia Castro 21585d18ab Force sorted order in plots rendering (#6601)
* Force sorted order in plots

* Fix type hint

* Fixed mypy

* Updates test_plots

* Removed reduntant test

* Removed unused import
2021-09-16 14:25:03 +08:00
Saugat Pachhai 198cdeec06 tests: scriptify function into module (#6619)
so that we can write tests having larger side-effects than
the run_copy that we use as a hack
2021-09-15 22:01:10 +03:00
Saugat Pachhai e049c4bab1 Set timeout for tests back to 30
Reverts 93a200c
2021-09-15 19:10:52 +05:45
Saugat Pachhai e2ed671cc9 get rid of pytest-timeout (#6624)
Trying to see if getting rid of this dependency, our issue with timeout
gets solved or not.

We use timeout-minutes anyway on GHA, so we probably don't need it.
Locally, users can monitor this themselves.
2021-09-15 19:09:16 +05:45
Batuhan Taskaya 8a97b21b76 ssh: ask password before setting it (#6615) 2021-09-15 15:26:57 +03:00
Saugat Pachhai a113097817 fs.download: remove unused copy between same fs (#6623) 2021-09-15 17:36:37 +05:45
Saugat Pachhai fbcc3f389c TmpDir: add serialize utilities (#6620) 2021-09-15 16:13:06 +05:45
Saugat Pachhai 7521e91d05 Strip bucket on GdriveFileSystem (#6618) 2021-09-15 16:12:46 +05:45
GaoandPeter Rowlands 18d147e2f8 machine: add machine list&machine modify (#6602)
* machine: add `machine list`&`machine modify`

1. add new command `dvc machine list`
2. complete `dvc machine modify`
3. add new unit tests for this two commands to ensure the call.
4. add functionality tests for two commands

* Update dvc/config_schema.py

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

* Update tests/func/machine/test_machine_config.py

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

* Some reviewed problems

1. capitalize global variables.
2. remove precision restriction.
3. solve windows path seperator problem.

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-09-15 15:40:16 +09:00
Amey Kulkarni 39d5d36033 dvc cli: fixed error in 'dvc config --list' in non repo dir (#6587)
* dvc cli: fixed error in 'dvc config --list' in non repo dir

Fixes #6496

* Issue 6496: Added test that covers the issue

* dvc cli: fixes error in 'dvc config <name>' in non dvc repo dir

Fixes the specified error. Did some refactoring based on previous commits.
Also added a test case for setting config in non dvc repo dir.

Fixes #6496

* Issue-6496: dvc config --<level> and dvc config --list --<level> will now throw config error if ran in non dvc repo with repo only level

* Issue 6496: Segregated config levels into repo and system levels that helped in simplifying the logic to decide appropriate level at the time of cli operation.
2021-09-15 15:16:23 +09:00
Saugat Pachhai 091c59e663 fs.download_dir: add callback support (#6613) 2021-09-15 08:51:25 +05:45
Ruslan Kuprieiev f3010d1d65 requirements: require grandalf 0.6 2021-09-14 22:51:53 +03:00
Gao 36a2bf472b Fix ci failures (#6609)
The current ci failure was caused by wrong order of the experiments.
Because our test script runs so fast that they might share the same
commit timestamp, that makes them in an uncertain order. And then gives
a wrong branch_base.
2021-09-14 18:44:20 +03:00
Ruslan Kuprieiev 93a200cf5f gha: increase timeout 2021-09-14 18:37:10 +03:00
dae08255d0 output: use remote kwarg instead of odb (#6600)
* output: use remote kwarg instead of odb

Fixes #6596

* Update dvc/output.py

Co-authored-by: Dave Berenbaum <[email protected]>

* add test

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

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

Co-authored-by: Dave Berenbaum <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-09-14 18:34:20 +03:00
Saugat Pachhai 237e9d4354 fs.download: add callback support (#6610)
* fs.download: add callback support

* fix lint issues

* compat for gdrive, use fs to upload text for tests

* fix typing issues

* skip gdrive test

* Update tests/func/test_fs.py
2021-09-14 15:53:16 +05:45
Saugat Pachhaiandpre-commit-ci[bot] 788b4d3bb5 fs.upload: add support for optional callback (#6546)
* fs.upload: add support for optional callback

Also removes _upload methods in other fses, and replaces them
with fsspec-compatible put_file which supports fsspec callback
even if they are not fsspec based filesystem.

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-09-14 10:55:45 +05:45
Saugat Pachhai 40c61c4f62 Use GDriveURLInfo instead of CloudURLInfo (#6608)
* Use GDriveURLInfo instead of CloudURLInfo

* Update gdrive.py
2021-09-14 10:18:22 +05:45
dependabot[bot] 1a1294848b build(deps): Bump codecov/codecov-action from 2.0.3 to 2.1.0 (#6606)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.0.3 to 2.1.0.
- [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.3...v2.1.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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-09-14 08:46:38 +05:45
dependabot[bot] 93dab26313 build(deps): Bump mock-ssh-server from 0.9.0 to 0.9.1 (#6607)
Bumps [mock-ssh-server](https://github.com/carletes/mock-ssh-server) from 0.9.0 to 0.9.1.
- [Release notes](https://github.com/carletes/mock-ssh-server/releases)
- [Commits](https://github.com/carletes/mock-ssh-server/compare/0.9.0...0.9.1)

---
updated-dependencies:
- dependency-name: mock-ssh-server
  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-09-14 08:46:08 +05:45
Ruslan Kuprieiev 049a856cd9 dvc: bump to 2.7.3 2021-09-13 21:52:48 +03:00
dependabot[bot] eb6562207b build(deps): Bump adlfs from 2021.8.2 to 2021.9.1 (#6597)
Bumps [adlfs](https://github.com/dask/adlfs) from 2021.8.2 to 2021.9.1.
- [Release notes](https://github.com/dask/adlfs/releases)
- [Changelog](https://github.com/dask/adlfs/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dask/adlfs/compare/2021.08.2...2021.09.1)

---
updated-dependencies:
- dependency-name: adlfs
  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-09-13 17:59:53 +09:00
5c8a642500 Added GitAuthError (#6493)
* Specify SSH protocol for `git_remote`

* Update experiments.py

* Added "a valid"

* Format

* Added GitRemoteAuthError

* Added GitRemoteAuthError

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

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

* Removed unused import

* Lint

* Removed GitRemoteAuthError

* Reformatted

* Updated error message

* Use shorter title

* Fix URL

* flake8

* Flake8

* Removed extra line

* Apply suggestions from code review

Co-authored-by: Jorge Orpinel <[email protected]>

* valid -> authenticated

* Added test

* Update experiments.py

* Removed authenticated

* Minor edit

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jorge Orpinel <[email protected]>
2021-09-13 17:56:34 +09:00
Saugat Pachhai d2d6c730c4 objects.transfer: minor refactoring, move lazy taskset inside custom executor (#6591) 2021-09-13 11:34:43 +05:45
Ruslan Kuprieiev 3a100382bc diff: don't forget to use dvcignore and state (#6595) 2021-09-11 00:13:07 +03:00
dependabot[bot]andSaugat Pachhai 61fe8bc59c build(deps): Bump grandalf from 0.6 to 0.7 (#6575)
* build(deps): Bump grandalf from 0.6 to 0.7

Bumps [grandalf](https://github.com/bdcht/grandalf) from 0.6 to 0.7.
- [Release notes](https://github.com/bdcht/grandalf/releases)
- [Commits](https://github.com/bdcht/grandalf/compare/v0.6...v0.7)

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

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

* grandalf dependency require range >=0.6

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Saugat Pachhai <[email protected]>
2021-09-09 16:23:49 +05:45
dependabot[bot] eaac8d08a6 build(deps): Bump hdfs from 2.5.8 to 2.6.0 (#6577)
Bumps [hdfs](https://github.com/mtth/hdfs) from 2.5.8 to 2.6.0.
- [Release notes](https://github.com/mtth/hdfs/releases)
- [Changelog](https://github.com/mtth/hdfs/blob/master/CHANGES)
- [Commits](https://github.com/mtth/hdfs/commits)

---
updated-dependencies:
- dependency-name: hdfs
  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-09-09 16:06:40 +05:45
dependabot[bot] 3522318dc9 build(deps): Bump google-cloud-storage from 1.42.0 to 1.42.1 (#6568)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.42.0 to 1.42.1.
- [Release notes](https://github.com/googleapis/python-storage/releases)
- [Changelog](https://github.com/googleapis/python-storage/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-storage/compare/v1.42.0...v1.42.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-09-09 16:04:22 +05:45
Saugat Pachhai 4bd6d9f172 fs: merge upload_fobj and upload (#6570)
* fs: merge upload_fobj and upload

* Update dvc/fs/base.py
2021-09-09 13:08:04 +03:00
Saugat Pachhai ef017befbe Move install requires to requirements/default.txt (#6569)
* Move install requires to requirements/default.txt

In the same spirit as #6560.

* Bump typing_extensions to >=3.10.0.2
2021-09-09 15:23:01 +05:45
Paweł Redzyński fa31fd1ef4 tests: requirements: remove beautifoulsoup4 (#6571) 2021-09-09 08:59:20 +00:00
Batuhan Taskaya 5592330769 project: formalize extra dependencies (#6560) 2021-09-09 09:01:45 +05:45
Batuhan Taskaya ff6e88296d ssh: support passphrases for private keys (#6566) 2021-09-09 01:51:02 +03:00
Ruslan Kuprieiev 2d9f36176a state: get rid of self.fs (#6567) 2021-09-09 01:43:09 +03:00
Ruslan Kuprieiev 31479f02ab dvc: bump to 2.7.2 2021-09-08 20:40:32 +03:00
Batuhan Taskaya b4e0c75a30 http: pass prepared filesystem arguments (#6565) 2021-09-08 20:40:01 +03:00
Saugat Pachhai 378486dbf5 Update setup.py (#6557) 2021-09-08 15:13:07 +05:45
GaoandPeter Rowlands 6106b1341b Show csv format of experiments (#6468)
* Show csv format of experiments

fix #5989 and #5446 

1. add --show-csv to dvc exp show
2. add a new functional test for csv format
3. add a new unit test for show_experiements
4. solve name duplication in table common headers (Created,Parent, etc... ), metrics files and param files.

Co-authored-by: Peter Rowlands (변기호) <[email protected]>
2021-09-08 16:59:01 +08:00
Casper da Costa-Luis 73c493500b remove tpi.base dependency (#6554)
* remove tpi.base dependency

* potential future-proof constructor
2021-09-08 06:09:52 +00:00
Casper da Costa-Luis 820d89606c add contrib avatars (#6552) 2021-09-08 11:19:49 +05:45
Ruslan Kuprieiev 2b63cedfcd dvc: bump to 2.7.1 2021-09-07 21:16:49 +03:00
Saugat Pachhai 1744028e09 Allow running package build manually on other PRs (#6553) 2021-09-07 22:28:50 +05:45
Saugat Pachhai 82db0867fe Update hook-dvc.py (#6551) 2021-09-07 22:17:03 +05:45
Batuhan Taskaya 6b582bd4d9 http: migrate to fsspec & aiohttp (#6525) 2021-09-07 16:41:30 +03:00
Saugat Pachhai f9bb38b96f Set timeout for jobs in Github Actions (#6545)
* Set timeout for jobs in Github Actions

Sometimes tests are getting frozen(most likely an issue with `pytest-xdist`), and sometimes the pip resolver is slow due to some weird constraints set by us or one of our dependencies.

The default timeout is 360 minutes (6hrs), but our test completes on ~18 minutes at maximum, so I think 30mins should be enough to account for occasional CI slowdown.

Also keeping the total job timeout at 45 mins, we usually require just 4 mins of extra time to do installation and setup but giving it 11 more minutes just in case.

Similarly, `lint` completes in <5 mins usually, but giving it a 10 mins timeout.

This should give us early feedback on the issue.

* Update setup.cfg

* Update setup.cfg
2021-09-07 14:08:22 +05:45
Peter Rowlands (변기호) f7a82f6fc0 machine: support instance size & startup script config (#6537)
* machine: install dvc via startup_script by default

* machine: support configuring instance type and startup script

* accept startup_script as a config relpath

* use pushd/popd in default install script
2021-09-07 15:07:22 +09:00
Ruslan Kuprieiev cbd49365c6 dvc: bump to 2.7.0 2021-09-06 20:21:55 +03:00
Saugat Pachhai c944d4af5f extract transfer related logic from odb to fs.utils.transfer (#6539)
* extract transfer related logic from odb to fs.utils.transfer

* don't makedirs on dirinfo

As local odb needs to set a mode for dir, we cannot create a directory
in the transfer
2021-09-06 22:02:00 +05:45
Saugat Pachhai 0db0128d28 try to fix flaky dvclive test (#6541)
Making sure that script takes a bit more time than the await time
I am not really sure this was the cause though.
Also the tests are flaky only in MacOS, but I haven't been able to reproduce.
2021-09-06 21:37:34 +05:45
Batuhan Taskaya b8b6ace687 setup: bump fsspec/s3fs/gcsfs/adlfs/sshfs to 08.01 (#6524)
* setup: bump fsspec/s3fs/gcsfs/adlfs/sshfs to 08.01

* local: use os.path.lexists() for the time being
2021-09-06 13:44:24 +03:00
Batuhan Taskaya 348dfc0496 s3: inline bucket region caching (#6406) 2021-09-06 12:20:52 +03:00
Saugat Pachhai db93709f21 Remove travis ci badge (#6538)
Also stopping from misleading users with always green badge.
2021-09-06 11:55:15 +03:00
Hongbo Miao 23d6697610 docs: add missing stage name (#6535)
Fix

> ERROR: `-n|--name` is required
2021-09-05 02:24:35 +00:00
4cc06339c8 Clean up remotes's exps (#6471)
* Clean up remotes's exps

fix #6006
1. add a new argument `--git-remote` to `dvc exp remove`
2. support remote a special remote exp
3. add tests for it
4. fix  #6421
5. add a test for #6421.
6. extract some functions from the `dvc pull`, `dvc push`, `dvc remove` to utils.
7. add a tests for this new util function `resolve_exp_ref`
8. add `__init__.py` to some test package for the pytest fail

Co-authored-by: Jorge Orpinel <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Dave Berenbaum <[email protected]>
2021-09-05 10:06:07 +08:00
David de la Iglesia Castroandpre-commit-ci[bot] b99cd0be11 Added dvclive to tests (#6518)
* Added dvclive to tests

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

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

* Moved dvclive to rest_requirements

* Update setup.py

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-09-03 22:50:41 +03:00
Casper da Costa-LuisandPeter Rowlands 0578643f08 machine: move to tpi[ssh] (#6528)
* machine: move to tpi[ssh]

- depends on https://github.com/iterative/tpi

* lazy import tpi

* fix lint warning

Co-authored-by: Peter Rowlands <[email protected]>
2021-09-03 14:37:24 +09:00
dependabot[bot]andSaugat Pachhai 2778c05e49 build(deps): Remove google-crc32c (#6529)
* build(deps): Update google-crc32c requirement from <=1.1.2 to <1.1.5

Updates the requirements on [google-crc32c](https://github.com/googleapis/python-crc32c) to permit the latest version.
- [Release notes](https://github.com/googleapis/python-crc32c/releases)
- [Changelog](https://github.com/googleapis/python-crc32c/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-crc32c/compare/v0.0.1...v1.1.4)

---
updated-dependencies:
- dependency-name: google-crc32c
  dependency-type: direct:production
...

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

* Update test_requirements.txt

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Saugat Pachhai <[email protected]>
2021-09-03 08:20:53 +05:45
Ruslan Kuprieiev 6287b13ba3 gha: allow running tests on other branches (#6520)
Simplifies development for all contributors.
2021-09-02 17:55:54 +03:00
Peter Rowlands (변기호) a69edcc2c8 tpi: lazily import python_terraform (#6526)
* machine: lazy load tpi/python_terraform only when needed

* remove terraform from package installers

* update tpi test imports
2021-09-02 17:09:31 +03:00
Batuhan Taskaya 64bf633b0f ssh: support multiple keys (#6519) 2021-09-02 12:48:34 +03:00
David de la Iglesia Castroandpre-commit-ci[bot] e5cf9b203d Add auto-refresh option to html (#6400)
* Added refresh tag

* Formatted

* Added refresh_seconds to live

* Added refresh test

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

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

* Add refresh_seconds to render

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

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

* Update html.py

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-09-01 17:17:23 +08:00
Peter Rowlands (변기호) 9fc4c60749 machine: add dvc machine ssh (#6501)
* machine: add naive asyncssh shell client

* machine: add `dvc machine ssh` command

* add ssh requirements to terraform install extras

* update asyncssh comments

* fix py <3.7

* fix async mocking in py<3.8

* use mock instead of unittest.mock
2021-09-01 16:20:13 +09:00
Saugat Pachhai b5fe9b4596 restrict google-crc32c for Python3.10 (#6510) 2021-08-31 09:06:28 +05:45
dependabot[bot] 03e692b4c2 build(deps): Bump pytest from 6.2.4 to 6.2.5 (#6509)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.4 to 6.2.5.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/6.2.4...6.2.5)

---
updated-dependencies:
- dependency-name: pytest
  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-08-31 08:50:20 +05:45
pre-commit-ci[bot] d06a96720f [pre-commit.ci] pre-commit autoupdate (#6504)
updates:
- [github.com/ambv/black: 21.7b0 → 21.8b0](https://github.com/ambv/black/compare/21.7b0...21.8b0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-08-30 21:31:00 +03:00
Saugat Pachhai ea2a38a207 restrict typing_extensions version for py3.10 (#6503) 2021-08-30 17:19:39 +05:45
Saugat Pachhai 5b9bdac965 add terraform to tests dependencies (#6502) 2021-08-30 17:04:47 +05:45
Ruslan Kuprieiev 5d7476a539 gha: packages: don't forget terraform 2021-08-27 23:43:28 +03:00
Ruslan Kuprieiev 341ab4fe9b gha: packages: don't forget terraform 2021-08-27 23:38:21 +03:00
Ruslan Kuprieiev 37717a7ef3 hdfs: don't read the whole file into memory (#6498)
* hdfs: don't read the whole file into memory

Oversight from #4973 🤦

* hdfs: use default block size of 128MB
2021-08-27 23:19:47 +03:00
Peter Rowlands (변기호) 4ee98299ee dvc machine: implement initial terraform support (#6462)
* tpi: add naive terraform-provider-iterative wrapper

* executor: add naive backend/terraform wrapper

* setup: add dvc[terraform] install extras

* config: add feature.executor feature flag

* fix named executor config sections

* executor: add poc add/modify/remove/init/destroy commands

* relax python-terraform requirement

* ci: install terraform extras

* tpi: support .tf.json format

* s/executor/machine

* terraform: use separate dirs per machine

* s/init/create/

* reserve local machine name

* add initial ssh skeleton

* add initial machine unit tests

* add initial machine command tests

* add tests for add/remove

* enable feature by default in test env

* close temp pemfile object so it can be used on windows

* cleanup pemfile syntax
2021-08-27 12:46:24 +03:00
Ruslan Kuprieiev 4382b80d3b dvcfile: support remote per output (#6486)
Related to #2095
2021-08-27 12:45:25 +03:00
Ruslan Kuprieiev 95ba68d181 ref: use fs.checksum instead of get_mtime_and_size (#6495) 2021-08-27 12:37:08 +09:00
Paweł Redzyński ab08db5065 plots: introduce support for images (#6431)
* plots: support images initial

* plots: data: unify PlotsData

* plots: data conversion refactor

* plots: renderers: abstract functionality

* plots: uncommenting tests

* plots: self implement grouping

* plots: rename group

* plots: extract rendering tests

* plots: move rendering tests to unit

* plots: add image rendering tests

* plots: dont use dpath to find vega plot

* plots: test find_vega

* plots: test render function

* plots: move rendering out of repo package

* plots: refactoring

* plots: self review refactor
2021-08-26 20:00:31 +03:00
Saugat Pachhai 73811f67d7 fix console appearing on daemon in Windows (#6450)
* fix console appearing on daemon in Windows

* fix entrypoint
2021-08-25 22:15:22 +03:00
HimanshuandJorge Orpinel 60d4b4791b Update README.rst (#6461)
* Update README.rst

Fixes typos and makes the related technologies stand out from their description.

* Apply suggestions from code review

* Update README.rst

* Wrapped modified portions to 80 chars or less

Left others untouched to simplify review

Co-authored-by: Jorge Orpinel <[email protected]>
2021-08-25 22:14:27 +03:00
dependabot[bot] ceb0964fcf build(deps): Bump codecov/codecov-action from 2.0.2 to 2.0.3 (#6487)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.0.2 to 2.0.3.
- [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.2...v2.0.3)

---
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-08-25 08:22:52 +05:45
b13aec0f2b [6478] Fix 401 unauthorized for directories in basic auth protected http remote (#6479)
* added auth to iter_url and created custom open for http fs

* added default value to auth

* Update dvc/utils/http.py

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

* Update dvc/fs/http.py

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

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

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

* pr fixes

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-08-24 18:39:29 +03:00
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
Ruslan Kuprieiev c33776eaee dvc: bump to 2.5.2 2021-07-08 18:30:11 +03:00
kareemjanoandpre-commit-ci[bot] 22781f5ef0 fs: s3: don't forget to pass verify option (#6298)
* Update s3.py

pass verify flag when creating a session

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-07-08 21:12:36 +05:45
Ruslan Kuprieiev ae478ccdc0 dvc: bump to 2.5.1 2021-07-08 17:48:15 +03:00
Batuhan Taskaya 87b49fd9f9 staging: optimize the number of info() calls (#6281)
* staging: optimize the number of info() calls

* fs: make info() give details about the object
2021-07-07 22:53:43 +03:00
Batuhan Taskaya f90b9cdee1 doctor: show versions for fs requirements (#6273)
* doctor: show versions for fs requirements

* plugins => supports
2021-07-07 21:07:16 +03:00
Christian ClaussandSaugat Pachhai 40f24e5668 pre-commit: introduce codespell check
* codespell --ignore-words-list="ba,datas,fo"

* up-to-date -> uptodate

* Add pre-commit hook...

* Pre-commit needs quotes?

* Single quotes?

* Arrrgggsss

* Update .pre-commit-config.yaml

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

Co-authored-by: Saugat Pachhai <[email protected]>
2021-07-07 17:14:15 +02:00
Hadas Arikandpre-commit-ci[bot] 9d9ff10be1 diff: only show states with values in summary (#6229)
* Fixed diff summary to only show states with values.

* Removed redundant comma by using join.

* Swithced for loop to generator for better readability.

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-07-06 21:03:32 +09:00
Batuhan Taskaya 98f7d1e481 objects: hash_file: retrieve size from hash_info (#6284)
* objects: hash_file: retrieve size from hash_info

* objects: drop Tree.size implementation
2021-07-06 10:56:13 +03:00
Saugat Pachhai 9cf22d3edc add: reduce os.fspath calls on recursive (#6287) 2021-07-06 06:43:56 +00:00
Saugat Pachhai 4b6c6a0e79 add: fix progress bar on single target (#6288) 2021-07-06 12:28:16 +05:45
Saugat Pachhai 6bccb21af6 prevent-tqdm-interleaving (#6280) 2021-07-06 13:37:37 +09:00
Saugat Pachhai beb6277d13 cleaning up add, progress bars and status (#6224)
* cleanup code

* fix progress bar updating message even when index > total

* cleanup
2021-07-06 01:08:35 +03:00
Kurian Benoyandpre-commit-ci[bot] 2485779d59 run: update -h message (#6197)
* update dvc exp run -h message

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-07-03 23:02:19 +03:00
Ruslan Kuprieiev 247b9c8be8 dvc: bump to 2.5.0 2021-07-03 22:34:11 +03:00
Saugat Pachhai 8d5f9ab6e5 add: do not delete stage files before add (#6239)
* add: do not delete stage files before add

Because of the way we collect stages and cache them, we were not able to
collect them for the `add` without removing them from the workspace.
As doing so, we'd have two same/similar stages - one collected from the
workspace and the other just created from the `dvc add` in-memory.

This would raise errors during graph checks, so we started to delete them
and reset them (which is very recently, see #2886 and #3349).

By deleting the file before we even do any checks, we are making DVC
fragile, and results in data loss for the users with even simple mistakes.
This should make it more reliable and robust.

And, recently, we have started to keep state of a lot of things, that by
resetting them on each stage, we waste a lot of performance, especially
on gitignores. We cache the dulwich's IgnoreManager, which when resetted
too many times, will waste a lot of our time just collecting them again
next time (see #6227).

It's hard to say how much this improves, as this very much depends on
no. of gitignores in the repo (which can be assumed to be quite in number
for a dvc repo) and the amount of files that we are adding
(eg: `-R` adding a large directory).

On a directory with 10,000 files (in a datadet-registry repo),
creating stages on `dvc add -R` went from 64 files/sec to 1.1k files/sec.

* add tests

* make the test more specific
2021-07-03 22:02:06 +03:00
Ruslan Kuprieiev 07f5f6b633 ssh: don't use path_info for connections (#6259) 2021-07-03 21:54:14 +03:00
Ruslan Kuprieiev aa255a1102 output: optimize obj collection (#6277)
* output: str only once

Calling `__str__` takes a long time and if we are dealing with a large
dataset, it will result in a very substantial time. For example,
locally, for 700K objects, it took 20sec more.

Related to #6276

* output: don't use os.path.join

Takes around 2sec for 700K objects.

* output: use fs.sep

* output: remove unused sep
2021-07-03 21:53:55 +03:00
Peter Rowlands (변기호) cfcb7e1294 pygit: handle annotated tags in resolve_refish (#6274) 2021-07-02 18:00:47 +03:00
Saugat Pachhai b8d9800e3c separate validation and transformation of args from the add func (#6220)
* separate validation and transformation of args from add func

* make tests pass for Python3.6

* cleanup

* use dict for attr-access and patch boundarguments.arguments
2021-07-01 21:15:02 +05:45
dependabot[bot] fe46d50847 build(deps): bump google-cloud-storage from 1.39.0 to 1.40.0 (#6261)
Bumps [google-cloud-storage](https://github.com/googleapis/python-storage) from 1.39.0 to 1.40.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.39.0...v1.40.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-01 21:14:20 +05:45
Batuhan Taskaya e3ff6d54a9 fsspec: loosen the prefix check to cover both None and False (#6246)
* fsspec: loosen the prefix check to cover both None and False

* gs: disable prefix-based search
2021-07-01 14:59:35 +03:00
Saugat Pachhai ce45ec8a28 align metrics to right and params to left in exp table (#6254)
Made a mistake during refactoring on styling before
2021-06-30 16:05:17 +05:45
Miguel Méndez cc5ba985b0 import: error message when imported file does not exist (#6240)
* Fix exception handling

* Replace exceptions with PathMissingError
2021-06-30 15:49:15 +09:00
Ruslan Kuprieiev 87cdbf8ace dvc: bump to 2.4.3 2021-06-29 17:27:21 +03:00
Tomasz Pietruszka eda8cd5846 git: Keep trailing slash when checking if directory is gitignored (#6232)
* Keep trailing slash when checking if directory is gitignored

* Calling is_ignored with correct type (could be Path-like)

* Fix test to comply to annotated types

* Fix separator issues

* Relaxed typing in is_ignored

* Added test

* Test scm

* Fix test on Windows

* Expanded test_ignored_dir_unignored_subdirs

* Even more assertions
2021-06-29 21:49:29 +09:00
pre-commit-ci[bot] 61835f6511 [pre-commit.ci] pre-commit autoupdate (#6243)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.902 → v0.910](https://github.com/pre-commit/mirrors-mypy/compare/v0.902...v0.910)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-06-29 07:24:12 +05:45
Jon Crall e72354e98e Loosen versions for fsspec and pygtrie (#6242) 2021-06-28 23:57:49 +03:00
354 changed files with 14571 additions and 10577 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ coverage:
status:
project:
default:
target: 85
target: 90
threshold: 10%
patch:
default:
+23
View File
@@ -0,0 +1,23 @@
name: benchmarks
on: [pull_request, workflow_dispatch]
jobs:
bench:
name: run benchmarks
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-18.04]
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- uses: actions/setup-python@v2
with:
python-version: 3.7
- uses: iterative/dvc-bench@master
with:
pytest_options: "-k 'test_init or test_help'"
+19 -6
View File
@@ -5,11 +5,14 @@ on:
- master
release:
types: [released, prereleased]
workflow_dispatch:
jobs:
deb:
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/[email protected]
with:
@@ -38,7 +41,9 @@ jobs:
rpm:
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/[email protected]
with:
@@ -67,7 +72,9 @@ jobs:
osxpkg:
runs-on: macos-10.15
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/[email protected]
with:
@@ -99,7 +106,9 @@ jobs:
exe:
runs-on: windows-2019
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/[email protected]
with:
@@ -127,7 +136,9 @@ jobs:
pip:
runs-on: ubuntu-18.04
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python 3.7
uses: actions/[email protected]
with:
@@ -135,7 +146,9 @@ jobs:
- name: Install
run: pip install ".[all,tests]"
- name: Build packages
run: ./scripts/build_package.sh
run: |
pip install -U pip
./scripts/build_package.sh
- name: Publish packages to PyPI
if: github.event_name == 'release'
uses: pypa/gh-action-pypi-publish@master
+29 -23
View File
@@ -1,12 +1,8 @@
name: Tests
on:
pull_request:
branches:
- master
push:
branches:
- master
pull_request: {}
push: {}
schedule:
- cron: '5 1 * * *' # every day at 01:05
@@ -17,9 +13,16 @@ env:
jobs:
lint:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/[email protected]
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- uses: actions/[email protected]
with:
fetch-depth: 0
- name: Set up Python 3.8
uses: actions/[email protected]
with:
@@ -27,7 +30,7 @@ jobs:
- name: Install requirements
run: |
pip install wheel
pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
pip install ".[all,tests,terraform]" pre-commit
- name: install temporary dependencies
run: |
pip install git+https://github.com/isidentical/gdrivefs@service-account
@@ -36,22 +39,21 @@ jobs:
run: python setup.py checkdocs
- uses: pre-commit/[email protected]
tests:
timeout-minutes: 45
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-18.04, windows-2019, macos-10.15]
pyv: ["3.6", "3.7", "3.8", "3.9"]
# macos 11.0 workers take too long to start up
# include:
# - os: "macos-11.0"
# pyv: "3.9"
os: [ubuntu-20.04, windows-latest, macos-latest]
pyv: ["3.7", "3.8", "3.9", "3.10"]
exclude:
- os: macos-10.15
pyv: "3.9"
# no wheels for pygit2 yet
- os: windows-latest
pyv: "3.10"
steps:
- uses: actions/[email protected].4
- uses: actions/[email protected].5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/[email protected]
with:
@@ -65,14 +67,14 @@ jobs:
uses: actions/[email protected]
with:
path: ${{ steps.pip-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
key: ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}
restore-keys: |
${{ runner.os }}-pip-
- name: install
if: steps.cache.pip-cache-dir.cache-hit != 'true'
run: |
pip install wheel
pip install -e ".[all,tests]"
pip install --upgrade pip setuptools wheel
pip install -e ".[all,tests,terraform]"
- name: install temporary dependencies
run: |
pip install git+https://github.com/isidentical/gdrivefs@service-account
@@ -96,16 +98,20 @@ jobs:
run: |
echo "extra_test_args=--disable-gdrive" >> $GITHUB_ENV
- name: run tests
timeout-minutes: 40
env:
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 }}
--enable-ssh ${{ env.extra_test_args }}
- name: upload coverage report
uses: codecov/codecov-action@v1.5.2
uses: codecov/codecov-action@v2.1.0
with:
file: ./coverage.xml
fail_ci_if_error: false
+2
View File
@@ -13,6 +13,7 @@ build/
dist/
*.egg-info/
.eggs
hooks/*.pyc
@@ -38,3 +39,4 @@ scripts/ci/gcp-creds.json
.gcp-creds.json
*~
/dvc/_dvc_version.py
+16 -4
View File
@@ -6,12 +6,24 @@ repos:
- id: black
language_version: python3
repo: https://github.com/ambv/black
rev: 21.6b0
rev: 21.9b0
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
hooks:
- id: check-toml
- id: check-yaml
- hooks:
- id: codespell
args:
- --ignore-words-list
- ba,datas,fo,uptodate
repo: https://github.com/codespell-project/codespell
rev: v2.1.0
- hooks:
- 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
@@ -32,7 +44,7 @@ repos:
- types-tabulate
- types-toml
repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.902'
rev: 'v0.910-1'
- repo: local
hooks:
- id: pylint
@@ -47,7 +59,7 @@ repos:
id: beautysh
language_version: python3
repo: https://github.com/lovesegfault/beautysh
rev: v6.1.0
rev: v6.2.1
- hooks:
- args:
- git-hook
-615
View File
@@ -1,615 +0,0 @@
[MASTER]
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-whitelist=pygit2
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
ignore-patterns=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
init-hook="import sys;
from pathlib import Path;
from pylint.config import find_pylintrc;
tests_folder = Path(find_pylintrc()).parent / 'tests'
sys.path.append(str(tests_folder));
"
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use.
jobs=1
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=pylint_pytest,pylint_plugin_disable
# Pickle collected data for later comparisons.
persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
confidence=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=format,
# ^ most of them are covered by flake8,
# and are conventions rather than warnings or errors
refactoring, # will be noisy if it starts giving suggestions
spelling,
# design is too-few-* and too-many-* checks, too-much-* for us right now
design,
# following were generated by default
# most of them are redundant Py3 checks for Py2+3 codebase
print-statement,
parameter-unpacking,
unpacking-in-except,
old-raise-syntax,
backtick,
long-suffix,
old-ne-operator,
old-octal-literal,
import-star-module-level,
non-ascii-bytes-literal,
raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
apply-builtin,
basestring-builtin,
buffer-builtin,
cmp-builtin,
coerce-builtin,
execfile-builtin,
file-builtin,
long-builtin,
raw_input-builtin,
reduce-builtin,
standarderror-builtin,
unicode-builtin,
xrange-builtin,
coerce-method,
delslice-method,
getslice-method,
setslice-method,
no-absolute-import,
old-division,
dict-iter-method,
dict-view-method,
next-method-called,
metaclass-assignment,
indexing-exception,
raising-string,
reload-builtin,
oct-method,
hex-method,
nonzero-method,
cmp-method,
input-builtin,
round-builtin,
intern-builtin,
unichr-builtin,
map-builtin-not-iterating,
zip-builtin-not-iterating,
range-builtin-not-iterating,
filter-builtin-not-iterating,
using-cmp-argument,
eq-without-hash,
div-method,
idiv-method,
rdiv-method,
exception-message-attribute,
invalid-str-codec,
sys-max-int,
bad-python3-import,
deprecated-string-function,
deprecated-str-translate-call,
deprecated-itertools-function,
deprecated-types-field,
next-method-defined,
dict-items-not-iterating,
dict-keys-not-iterating,
dict-values-not-iterating,
deprecated-operator-function,
deprecated-urllib-function,
xreadlines-attribute,
deprecated-sys-function,
exception-escape,
comprehension-escape,
# custom disables start here
no-self-use,
invalid-name,
# already a check enabled for `wildcard-import`
unused-wildcard-import,
misplaced-comparison-constant,
# probably soon: https://github.com/iterative/dvc/issues/1843
logging-format-interpolation,
logging-fstring-interpolation,
# we don't have proper docstrings yet
# even if we do, we'll be doing it gradually
missing-function-docstring,
missing-module-docstring,
missing-class-docstring,
duplicate-code,
# too noisy, and detects issues even when import is inside a function
# see: https://github.com/PyCQA/pylint/issues/850
cyclic-import,
# isort should cover these:
wrong-import-order,
wrong-import-position,
ungrouped-imports,
multiple-imports,
# we use it to speedup/optimize
import-outside-toplevel,
fixme,
# we use cause of the exception for error message output
# so some of the times we don't use `raise from` to not show the cause
# of the error
raise-missing-from,
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member,
no-else-return,
[REPORTS]
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
#msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
output-format=text
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[REFACTORING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit
[LOGGING]
# Format style used to check logging format string. `old` means using %
# formatting, while `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging
[SPELLING]
# Limits count of emitted suggestions for spelling mistakes.
max-spelling-suggestions=4
# Spelling dictionary name. Available dictionaries: none. To make it working
# install python-enchant package..
spelling-dict=
# List of comma separated words that should not be checked.
spelling-ignore-words=
# A path to a file that contains private dictionary; one word per line.
spelling-private-dict-file=
# Tells whether to store unknown words to indicated private dictionary in
# --spelling-private-dict-file option instead of raising a message.
spelling-store-unknown-words=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=hdfs.*,pytest.lazy_fixture,logging.TRACE,logger.trace,sys.getwindowsversion
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,Dvcfile
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=azure
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
signature-mutators=funcy.decorators.decorator,
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored. Default to name
# with leading underscore.
ignored-argument-names=_.*|^ignored_|^unused_|args|kwargs
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=79
# Maximum number of lines in a module.
max-module-lines=1000
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
dict-separator
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style.
#class-attribute-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_,
s3,
gs,
x, y,
x0, y0,
x1, y1,
dx, dy
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style.
#variable-rgx=
[SIMILARITIES]
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=no
# Minimum lines number of a similarity.
min-similarity-lines=4
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
[DESIGN]
# Maximum number of arguments for function / method.
max-args=5
# Maximum number of attributes for a class (see R0902).
max-attributes=7
# Maximum number of boolean expressions in an if statement.
max-bool-expr=5
# Maximum number of branch for function / method body.
max-branches=12
# Maximum number of locals for function / method body.
max-locals=15
# Maximum number of parents for a class (see R0901).
max-parents=7
# Maximum number of public methods for a class (see R0904).
max-public-methods=20
# Maximum number of return / yield for function / method body.
max-returns=6
# Maximum number of statements in function / method body.
max-statements=50
# Minimum number of public methods for a class (see R0903).
min-public-methods=2
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp
# List of member names, which should be excluded from the protected access
# warning.
exclude-protected=_asdict,
_fields,
_replace,
_source,
_make
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[IMPORTS]
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=optparse,tkinter.tix
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled).
ext-import-graph=
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled).
import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception".
overgeneral-exceptions=Exception
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Copyright 2017-2021 Iterative, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+2 -2
View File
@@ -1,3 +1,3 @@
include fastentrypoints.py
include LICENSE
include test_requirements.txt
recursive-include requirements *.txt
recursive-include dvc/repo/plots/templates *.json
+53 -43
View File
@@ -8,7 +8,7 @@
• `Tutorial <https://dvc.org/doc/get-started>`_
• `Mailing List <https://sweedom.us10.list-manage.com/subscribe/post?u=a08bf93caae4063c4e6a351f6&id=24c0ecc49a>`_
|Release| |CI| |Maintainability| |Coverage| |Donate| |DOI|
|CI| |Maintainability| |Coverage| |Donate| |DOI|
|PyPI| |Packages| |Brew| |Conda| |Choco| |Snap|
@@ -20,8 +20,9 @@ learning projects. Key features:
#. Simple **command line** Git-like experience. Does not require installing and maintaining
any databases. Does not depend on any proprietary online services.
#. Management and versioning of **datasets** and **machine learning models**. Data is saved in
S3, Google cloud, Azure, Alibaba cloud, SSH server, HDFS, or even local HDD RAID.
#. Management and versioning of **datasets** and **machine learning
models**. Data can be saved in S3, Google cloud, Azure, Alibaba
cloud, SSH server, HDFS, or even local HDD RAID.
#. Makes projects **reproducible** and **shareable**; helping to answer questions about how
a model was built.
@@ -29,9 +30,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
@@ -65,25 +66,25 @@ Quick start
Please read `Get Started <https://dvc.org/doc/get-started>`_ guide for a full version. Common workflow commands include:
+-----------------------------------+-------------------------------------------------------------------+
| Step | Command |
+===================================+===================================================================+
| Track data | | ``$ git add train.py`` |
| | | ``$ dvc add images.zip`` |
+-----------------------------------+-------------------------------------------------------------------+
| Connect code and data by commands | | ``$ dvc run -d images.zip -o images/ unzip -q images.zip`` |
| | | ``$ dvc run -d images/ -d train.py -o model.p python train.py`` |
+-----------------------------------+-------------------------------------------------------------------+
| Make changes and reproduce | | ``$ vi train.py`` |
| | | ``$ dvc repro model.p.dvc`` |
+-----------------------------------+-------------------------------------------------------------------+
| Share code | | ``$ git add .`` |
| | | ``$ git commit -m 'The baseline model'`` |
| | | ``$ git push`` |
+-----------------------------------+-------------------------------------------------------------------+
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
| | | ``$ dvc push`` |
+-----------------------------------+-------------------------------------------------------------------+
+-----------------------------------+----------------------------------------------------------------------------+
| Step | Command |
+===================================+============================================================================+
| Track data | | ``$ git add train.py`` |
| | | ``$ dvc add images.zip`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Connect code and data by commands | | ``$ dvc run -n prepare -d images.zip -o images/ unzip -q images.zip`` |
| | | ``$ dvc run -n train -d images/ -d train.py -o model.p python train.py`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Make changes and reproduce | | ``$ vi train.py`` |
| | | ``$ dvc repro model.p.dvc`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Share code | | ``$ git add .`` |
| | | ``$ git commit -m 'The baseline model'`` |
| | | ``$ git push`` |
+-----------------------------------+----------------------------------------------------------------------------+
| Share data and ML models | | ``$ dvc remote add myremote -d s3://mybucket/image_cnn`` |
| | | ``$ dvc push`` |
+-----------------------------------+----------------------------------------------------------------------------+
Installation
============
@@ -182,30 +183,37 @@ Fedora / CentOS (rpm)
Comparison to related technologies
==================================
#. *Data Engineering* tools such as `AirFlow <https://airflow.apache.org/>`_, `Luigi <https://github.com/spotify/luigi>`_ and
others - in DVC data, model and ML pipelines represent a single ML project and focuses on data scientist' experience while
data engineering pipeline orchestrates multiple data projects and focuses on efficent execution. DVC project can be used
from data pipeline as a single execution step. `AirFlow DVC <https://github.com/covid-genomics/airflow-dvc>`_ is an example
of such integration.
#. Data Engineering tools such as `AirFlow <https://airflow.apache.org/>`,
`Luigi <https://github.com/spotify/luigi>`, and others - in DVC data,
model and ML pipelines represent a single ML project focused on data
scientists' experience. Data engineering tools orchestrate multiple data
projects and focus on efficient execution. A DVC project can be used from
existing data pipelines as a single execution step.
#. `Git-annex <https://git-annex.branchable.com/>`_ - DVC uses the idea of storing the content of large files (which should
not be in a Git repository) in a local key-value store, and uses file hardlinks/symlinks instead of
copying/duplicating files.
#. `Git-LFS <https://git-lfs.github.com/>`_ - DVC is compatible with any remote storage (S3, Google Cloud, Azure, SSH,
etc). DVC also uses reflinks or hardlinks to avoid copy operations on checkouts; thus handling large data files
much more efficiently.
#. `Git-LFS <https://git-lfs.github.com/>`_ - DVC is compatible with many
remote storage services (S3, Google Cloud, Azure, SSH, etc). DVC also
uses reflinks or hardlinks to avoid copy operations on checkouts; thus
handling large data files much more efficiently.
#. *Makefile* (and analogues including ad-hoc scripts) - DVC tracks dependencies (in a directed acyclic graph).
#. Makefile (and analogues including ad-hoc scripts) - DVC tracks
dependencies (in a directed acyclic graph).
#. `Workflow Management Systems <https://en.wikipedia.org/wiki/Workflow_management_system>`_ - DVC is a workflow
management system designed specifically to manage machine learning experiments. DVC is built on top of Git.
#. `DAGsHub <https://dagshub.com/>`_ - online service of Git+DVC repositories with pipeline and metrics visualization and
DVC-specific cloud storage.
#. `DAGsHub <https://dagshub.com/>`_ - online service to host DVC
projects. It provides a useful UI around DVC repositories and integrates
other tools.
#. `DVC Studio <https://studio.iterative.ai/>`_ - online service of DVC repositories visualisation from DVC team. Also,
integrated with `CML (CI/CD for ML) <https://cml.dev/>`_ for training models in clouds and Kubernetes.
#. `DVC Studio <https://studio.iterative.ai/>`_ - official online
platform for DVC projects. It can be used to manage data and models, run
and track experiments, and visualize and share results. Also, it
integrates with `CML (CI/CD for ML) <https://cml.dev/>` for training
models in the cloud or Kubernetes.
Contributing
@@ -214,7 +222,9 @@ Contributing
|Maintainability| |Donate|
Contributions are welcome! Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more
details.
details. Thanks to all our contributors!
|Contribs|
Mailing List
============
@@ -244,10 +254,6 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
:target: https://dvc.org
:alt: DVC logo
.. |Release| image:: https://img.shields.io/badge/release-ok-brightgreen
:target: https://travis-ci.com/iterative/dvc/branches
:alt: Release
.. |CI| image:: https://github.com/iterative/dvc/workflows/Tests/badge.svg?branch=master
:target: https://github.com/iterative/dvc/actions
:alt: GHA Tests
@@ -295,3 +301,7 @@ Barrak, A., Eghan, E.E. and Adams, B. `On the Co-evolution of ML Pipelines and S
.. |Flowchart| image:: https://dvc.org/img/flow.gif
:target: https://dvc.org/img/flow.gif
:alt: how_dvc_works
.. |Contribs| image:: https://contrib.rocks/image?repo=iterative/dvc
:target: https://github.com/iterative/dvc/graphs/contributors
:alt: Contributors
+40 -1
View File
@@ -1,10 +1,39 @@
from contextlib import ExitStack, contextmanager
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable, Union
if TYPE_CHECKING:
from argparse import Namespace
@contextmanager
def yappi_profile(
path: Union[Callable[[], str], str] = None, wall_clock: bool = True
):
try:
import yappi # pylint: disable=import-error
except ImportError:
print("Failed to run profiler, yappi is not installed")
yield
return
yappi.set_clock_type("wall" if wall_clock else "cpu")
yappi.start()
yield
yappi.stop()
# pylint:disable=no-member
if path:
stats = yappi.get_func_stats()
fpath = path() if callable(path) else path
stats.save(fpath, "callgrind")
else:
yappi.get_func_stats().print_all()
yappi.get_thread_stats().print_all()
yappi.clear_stats()
@contextmanager
def instrument(html_output=False):
"""Run a statistical profiler"""
@@ -70,6 +99,13 @@ def debugtools(args: "Namespace" = None, **kwargs):
stack.enter_context(profile(kw.get("cprofile_dump")))
if kw.get("instrument") or kw.get("instrument_open"):
stack.enter_context(instrument(kw.get("instrument_open", False)))
if kw.get("yappi"):
from datetime import datetime
output = "callgrind.dvc-{0:%Y%m%d}_{0:%H%M%S}.out"
stack.enter_context(
yappi_profile(path=lambda: output.format(datetime.now()))
)
yield
@@ -79,6 +115,9 @@ def add_debugging_flags(parser):
parser.add_argument(
"--cprofile", action="store_true", default=False, help=SUPPRESS
)
parser.add_argument(
"--yappi", action="store_true", default=False, help=SUPPRESS
)
parser.add_argument("--cprofile-dump", help=SUPPRESS)
parser.add_argument(
"--pdb", action="store_true", default=False, help=SUPPRESS
+12 -7
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
@@ -63,7 +68,7 @@ def send(path):
url = "https://analytics.dvc.org"
headers = {"content-type": "application/json"}
with open(path) as fobj:
with open(path, encoding="utf-8") as fobj:
report = json.load(fobj)
report.update(_runtime_info())
@@ -168,13 +173,13 @@ def _find_or_create_user_id():
try:
with Lock(lockfile):
try:
with open(fname) as fobj:
with open(fname, encoding="utf-8") as fobj:
user_id = json.load(fobj)["user_id"]
except (FileNotFoundError, ValueError, KeyError):
user_id = str(uuid.uuid4())
with open(fname, "w") as fobj:
with open(fname, "w", encoding="utf-8") as fobj:
json.dump({"user_id": user_id}, fobj)
return user_id
+1 -1
View File
@@ -113,7 +113,7 @@ def make_checkpoint():
root_dir, Repo.DVC_DIR, "tmp", CheckpointTask.SIGNAL_FILE
)
with builtins.open(signal_file, "w") as fobj:
with builtins.open(signal_file, "w", encoding="utf-8") as fobj:
# NOTE: force flushing/writing empty file to disk, otherwise when
# run in certain contexts (pytest) file may not actually be written
fobj.write("")
+2
View File
@@ -30,6 +30,7 @@ from .command import (
install,
live,
ls,
machine,
metrics,
move,
params,
@@ -87,6 +88,7 @@ COMMANDS = [
experiments,
check_ignore,
live,
machine,
]
-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:
+17 -4
View File
@@ -66,7 +66,8 @@ class CmdConfig(CmdBaseNoRepo):
)
return 1
levels = [self.args.level] if self.args.level else self.config.LEVELS
levels = self._get_appropriate_levels(self.args.level)
for level in levels:
conf = self.config.read(level)
prefix = self._config_file_prefix(
@@ -81,9 +82,7 @@ class CmdConfig(CmdBaseNoRepo):
def _get(self, remote, section, opt):
from dvc.config import ConfigError
levels = (
[self.args.level] if self.args.level else self.config.LEVELS[::-1]
)
levels = self._get_appropriate_levels(self.args.level)[::-1]
for level in levels:
conf = self.config.read(level)
@@ -136,6 +135,20 @@ class CmdConfig(CmdBaseNoRepo):
f"option '{opt}' doesn't exist in {name} '{section}'"
)
def _get_appropriate_levels(self, levels):
if levels:
self._validate_level_for_non_repo_operation(levels)
return [levels]
if self.config.dvc_dir is None:
return self.config.SYSTEM_LEVELS
return self.config.LEVELS
def _validate_level_for_non_repo_operation(self, level):
from dvc.config import ConfigError
if self.config.dvc_dir is None and level in self.config.REPO_LEVELS:
raise ConfigError("Not inside a DVC repo")
@staticmethod
def _format_config(config, prefix=""):
from dvc.utils.flatten import flatten
+8 -7
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)
@@ -101,9 +103,8 @@ class CmdDAG(CmdBase):
if self.args.dot:
ui.write(_show_dot(G))
else:
from dvc.utils.pager import pager
pager(_show_ascii(G))
with ui.pager():
ui.write(_show_ascii(G))
return 0
+1
View File
@@ -393,6 +393,7 @@ def add_parser(subparsers, _parent_parser):
help="Show status of all stages in the specified directory.",
)
status_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
+13 -13
View File
@@ -1,5 +1,4 @@
import argparse
import json
import logging
import os
@@ -16,7 +15,7 @@ def _digest(checksum):
return "{}..{}".format(checksum["old"][0:8], checksum["new"][0:8])
def _show_md(diff, show_hash=False, hide_missing=False):
def _show_markdown(diff, show_hash=False, hide_missing=False):
headers = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
rows = []
statuses = ["added", "deleted", "renamed", "modified"]
@@ -111,14 +110,12 @@ class CmdDiff(CmdBase):
if not sum(summary.values()):
return None
fmt = (
"files summary: {added} added, {deleted} deleted,"
" {renamed} renamed, {modified} modified"
states_summary = ", ".join(
f"{summary[state]} {state}"
for state in states
if summary[state] > 0
)
if not hide_missing:
fmt += ", {not in cache} not in cache"
ui.write(fmt.format_map(summary))
ui.write("files summary:", states_summary)
def run(self):
from dvc.exceptions import DvcException
@@ -144,10 +141,10 @@ class CmdDiff(CmdBase):
del entry["hash"]
diff[key] = entries
if self.args.show_json:
ui.write(json.dumps(diff))
elif self.args.show_md:
_show_md(diff, show_hash, hide_missing)
if self.args.json:
ui.write_json(diff)
elif self.args.markdown:
_show_markdown(diff, show_hash, hide_missing)
elif diff:
self._show_diff(diff, hide_missing)
@@ -190,6 +187,7 @@ def add_parser(subparsers, parent_parser):
nargs="?",
)
diff_parser.add_argument(
"--json",
"--show-json",
help="Format the output into a JSON",
action="store_true",
@@ -202,9 +200,11 @@ def add_parser(subparsers, parent_parser):
default=False,
)
diff_parser.add_argument(
"--md",
"--show-md",
help="Show tabulated output in the Markdown format (GFM).",
action="store_true",
dest="markdown",
default=False,
)
diff_parser.add_argument(
+338 -72
View File
@@ -5,9 +5,8 @@ from datetime import date, datetime
from fnmatch import fnmatch
from typing import TYPE_CHECKING, Dict, Iterable, Optional
from funcy import lmap
from funcy import compact, lmap
from dvc import prompt
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.command.metrics import DEFAULT_PRECISION
@@ -16,11 +15,11 @@ 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
from dvc.compare import TabularData
from dvc.ui import RichText
logger = logging.getLogger(__name__)
@@ -28,6 +27,7 @@ logger = logging.getLogger(__name__)
SHOW_MAX_WIDTH = 1024
FILL_VALUE = "-"
FILL_VALUE_ERRORED = "!"
def _filter_name(names, label, filter_strs):
@@ -90,6 +90,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 +101,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(
@@ -137,6 +139,8 @@ def _collect_rows(
precision=DEFAULT_PRECISION,
sort_by=None,
sort_order=None,
fill_value=FILL_VALUE,
iso=False,
):
from dvc.scm.git import Git
@@ -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")),
_format_time(exp.get("timestamp"), fill_value, iso),
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
@@ -235,11 +258,12 @@ def _sort_column(sort_by, metric_names, param_names):
def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
def _sort(item):
rev, exp = item
tip = exp.get("checkpoint_tip")
exp_data = exp.get("data", {})
tip = exp_data.get("checkpoint_tip")
if tip and tip != rev:
# Sort checkpoint experiments by tip commit
return _sort((tip, experiments[tip]))
data = exp.get(typ, {}).get(sort_path, {})
data = exp_data.get(typ, {}).get(sort_path, {}).get("data", {})
val = flatten(data).get(sort_name)
return val is None, val
@@ -251,33 +275,39 @@ def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
return ret
def _format_time(timestamp):
if timestamp is None:
return FILL_VALUE
if timestamp.date() == date.today():
def _format_time(datetime_obj, fill_value=FILL_VALUE, iso=False):
if datetime_obj is None:
return fill_value
if iso:
return datetime_obj.isoformat()
if datetime_obj.date() == date.today():
fmt = "%I:%M %p"
else:
fmt = "%b %d, %Y"
return timestamp.strftime(fmt)
return datetime_obj.strftime(fmt)
def _extend_row(row, names, items, precision):
from rich.text import Text
def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
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)
# wrap field data in rich.Text, otherwise rich may
value = with_value(
item.get(name),
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
)
# wrap field data in ui.rich_text, otherwise rich may
# interpret unescaped braces from list/dict types as rich
# markup tags
row.append(Text(str(_format_field(value, precision))))
row.append(ui.rich_text(str(_format_field(value, precision))))
def _parse_filter_list(param_list):
@@ -293,6 +323,7 @@ def _parse_filter_list(param_list):
def experiments_table(
all_experiments,
headers,
metric_headers,
metric_names,
param_headers,
@@ -300,14 +331,15 @@ def experiments_table(
sort_by=None,
sort_order=None,
precision=DEFAULT_PRECISION,
fill_value=FILL_VALUE,
iso=False,
) -> "TabularData":
from funcy import lconcat
from dvc.compare import TabularData
headers = ["Experiment", "rev", "queued", "typ", "Created", "parent"]
td = TabularData(
lconcat(headers, metric_headers, param_headers), fill_value=FILL_VALUE
lconcat(headers, metric_headers, param_headers), fill_value=fill_value
)
for base_rev, experiments in all_experiments.items():
rows = _collect_rows(
@@ -318,32 +350,31 @@ def experiments_table(
sort_by=sort_by,
sort_order=sort_order,
precision=precision,
fill_value=fill_value,
iso=iso,
)
td.extend(rows)
return td
def prepare_exp_id(kwargs) -> "Text":
from rich.text import Text
def prepare_exp_id(kwargs) -> "RichText":
exp_name = kwargs["Experiment"]
rev = kwargs["rev"]
typ = kwargs.get("typ", "baseline")
if typ == "baseline" or not exp_name:
text = Text(exp_name or rev)
text = ui.rich_text(exp_name or rev)
else:
text = Text.assemble(rev, " [", (exp_name, "bold"), "]")
text = ui.rich_text.assemble(rev, " [", (exp_name, "bold"), "]")
parent = kwargs.get("parent")
suff = f" ({parent})" if parent else ""
text.append(suff)
tree = experiment_types[typ]
queued = "*" if kwargs.get("queued") else ""
pref = (f"{tree} " if tree else "") + queued
return Text(pref) + text
pref = f"{tree} " if tree else ""
return ui.rich_text(pref) + text
def baseline_styler(typ):
@@ -351,8 +382,15 @@ def baseline_styler(typ):
def show_experiments(
all_experiments, pager=True, no_timestamp=False, **kwargs
all_experiments,
pager=True,
no_timestamp=False,
csv=False,
markdown=False,
**kwargs,
):
from funcy.seqs import flatten as flatten_list
include_metrics = _parse_filter_list(kwargs.pop("include_metrics", []))
exclude_metrics = _parse_filter_list(kwargs.pop("exclude_metrics", []))
include_params = _parse_filter_list(kwargs.pop("include_params", []))
@@ -365,11 +403,26 @@ def show_experiments(
include_params=include_params,
exclude_params=exclude_params,
)
metric_headers = _normalize_headers(metric_names)
param_headers = _normalize_headers(param_names)
headers = [
"Experiment",
"rev",
"typ",
"Created",
"parent",
"State",
"Executor",
]
names = {**metric_names, **param_names}
counter = Counter(flatten_list([list(a.keys()) for a in names.values()]))
counter.update(headers)
metric_headers = _normalize_headers(metric_names, counter)
param_headers = _normalize_headers(param_names, counter)
td = experiments_table(
all_experiments,
headers,
metric_headers,
metric_names,
param_headers,
@@ -377,27 +430,38 @@ def show_experiments(
kwargs.get("sort_by"),
kwargs.get("sort_order"),
kwargs.get("precision"),
kwargs.get("fill_value"),
kwargs.get("iso"),
)
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"]
td.column("Experiment")[:] = map(prepare_exp_id, td.as_dict(merge_headers))
td.drop(*merge_headers[1:])
if not csv:
merge_headers = ["Experiment", "rev", "typ", "parent"]
td.column("Experiment")[:] = map(
prepare_exp_id, td.as_dict(merge_headers)
)
td.drop(*merge_headers[1:])
headers = {"metrics": metric_headers, "params": param_headers}
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(
{
header: {
"justify": "left" if typ == "metrics" else "params",
"justify": "right" if typ == "metrics" else "left",
"header_style": f"black on {header_bg_colors[typ]}",
"collapse": idx != 0,
"no_wrap": typ == "metrics",
@@ -413,13 +477,12 @@ def show_experiments(
rich_table=True,
header_styles=styles,
row_styles=row_styles,
csv=csv,
markdown=markdown,
)
def _normalize_headers(names):
count = Counter(
name for path in names for name in names[path] for path in names
)
def _normalize_headers(names, count):
return [
name if count[name] == 1 else f"{path}:{name}"
for path in names
@@ -430,7 +493,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):
@@ -448,11 +511,17 @@ class CmdExperimentsShow(CmdBase):
logger.exception("failed to show experiments")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(all_experiments, default=_format_json))
if self.args.json:
ui.write_json(all_experiments, default=_format_json)
else:
precision = (
self.args.precision or None
if self.args.csv
else DEFAULT_PRECISION
)
fill_value = "" if self.args.csv else FILL_VALUE
iso = True if self.args.csv else False
show_experiments(
all_experiments,
include_metrics=self.args.include_metrics,
@@ -462,8 +531,12 @@ class CmdExperimentsShow(CmdBase):
no_timestamp=self.args.no_timestamp,
sort_by=self.args.sort_by,
sort_order=self.args.sort_order,
precision=self.args.precision or DEFAULT_PRECISION,
precision=precision,
fill_value=fill_value,
iso=iso,
pager=not self.args.no_pager,
csv=self.args.csv,
markdown=self.args.markdown,
)
return 0
@@ -492,10 +565,8 @@ class CmdExperimentsDiff(CmdBase):
logger.exception("failed to show experiments diff")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(diff))
if self.args.json:
ui.write_json(diff)
else:
from dvc.compare import show_diff
@@ -510,11 +581,13 @@ class CmdExperimentsDiff(CmdBase):
show_diff(
diff[key],
title=title,
markdown=self.args.show_md,
markdown=self.args.markdown,
no_path=self.args.no_path,
old=self.args.old,
on_empty_diff="diff not supported",
precision=precision if key == "metrics" else None,
a_rev=self.args.a_rev,
b_rev=self.args.b_rev,
)
return 0
@@ -589,13 +662,13 @@ 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)
msg = "Are you sure you want to proceed?"
if not self.args.force and not prompt.confirm(msg):
if not self.args.force and not ui.confirm(msg):
return 1
removed = self.repo.experiments.gc(
@@ -707,12 +780,86 @@ 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,
remote=self.args.git_remote,
)
return 0
class CmdExperimentsInit(CmdBase):
CODE = "src"
DATA = "data"
MODELS = "models"
DEFAULT_METRICS = "metrics.json"
DEFAULT_PARAMS = "params.yaml"
PLOTS = "plots"
DVCLIVE = "dvclive"
DEFAULTS = {
"code": CODE,
"data": DATA,
"models": MODELS,
"metrics": DEFAULT_METRICS,
"params": DEFAULT_PARAMS,
"plots": PLOTS,
"live": DVCLIVE,
}
def run(self):
from dvc.command.stage import parse_cmd
cmd = parse_cmd(self.args.cmd)
if not self.args.interactive and not cmd:
raise InvalidArgumentError("command is not specified")
from dvc.repo.experiments.init import init
defaults = {}
if not self.args.explicit:
config = self.repo.config["exp"]
defaults.update({**self.DEFAULTS, **config})
cli_args = compact(
{
"cmd": cmd,
"code": self.args.code,
"data": self.args.data,
"models": self.args.models,
"metrics": self.args.metrics,
"params": self.args.params,
"plots": self.args.plots,
"live": self.args.live,
}
)
initialized_stage = init(
self.repo,
name=self.args.name,
type=self.args.type,
defaults=defaults,
overrides=cli_args,
interactive=self.args.interactive,
force=self.args.force,
)
if self.args.run:
return self.repo.experiments.run(
targets=[initialized_stage.addressing]
)
return 0
class RawDefaultsHelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
):
def _get_help_string(self, action: argparse.Action) -> Optional[str]:
if action.default:
return super()._get_help_string(action)
return action.help
def add_parser(subparsers, parent_parser):
EXPERIMENTS_HELP = "Commands to run and compare experiments."
@@ -818,7 +965,10 @@ def add_parser(subparsers, parent_parser):
)
experiments_show_parser.add_argument(
"--sort-order",
help="Sort order to use with --sort-by.",
help=(
"Sort order to use with --sort-by."
" Defaults to ascending ('asc')."
),
choices=("asc", "desc"),
default="asc",
)
@@ -835,11 +985,27 @@ def add_parser(subparsers, parent_parser):
help="Always show git commit SHAs instead of branch/tag names.",
)
experiments_show_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
help="Print output in JSON format instead of a human-readable table.",
)
experiments_show_parser.add_argument(
"--csv",
"--show-csv",
action="store_true",
default=False,
help="Print output in csv format instead of a human-readable table.",
)
experiments_show_parser.add_argument(
"--md",
"--show-md",
action="store_true",
default=False,
dest="markdown",
help="Show tabulated output in the Markdown format (GFM).",
)
experiments_show_parser.add_argument(
"--precision",
type=int,
@@ -903,22 +1069,25 @@ def add_parser(subparsers, parent_parser):
help="Show only params that are stage dependencies.",
)
experiments_diff_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
help="Show output in JSON format.",
)
experiments_diff_parser.add_argument(
"--md",
"--show-md",
action="store_true",
default=False,
dest="markdown",
help="Show tabulated output in the Markdown format (GFM).",
)
experiments_diff_parser.add_argument(
"--old",
action="store_true",
default=False,
help="Show old metric/param value.",
help="Show old ('a_rev') metric/param value.",
)
experiments_diff_parser.add_argument(
"--no-path",
@@ -937,9 +1106,7 @@ def add_parser(subparsers, parent_parser):
)
experiments_diff_parser.set_defaults(func=CmdExperimentsDiff)
EXPERIMENTS_RUN_HELP = (
"Reproduce complete or partial experiment pipelines."
)
EXPERIMENTS_RUN_HELP = "Run or resume an experiment."
experiments_run_parser = experiments_subparsers.add_parser(
"run",
parents=[parent_parser],
@@ -1071,9 +1238,9 @@ def add_parser(subparsers, parent_parser):
nargs="?",
default=None,
help=(
"Optional Git remote name or Git URL. If provided, experiments "
"from the specified Git repository will be listed instead of "
"local experiments."
"Optional Git remote name or Git URL. "
"If provided, experiments from the specified Git repository "
" will be listed instead of local ones."
),
metavar="[<git_remote>]",
)
@@ -1191,7 +1358,7 @@ def add_parser(subparsers, parent_parser):
)
experiments_pull_parser.set_defaults(func=CmdExperimentsPull)
EXPERIMENTS_REMOVE_HELP = "Remove local experiments."
EXPERIMENTS_REMOVE_HELP = "Remove experiments."
experiments_remove_parser = experiments_subparsers.add_parser(
"remove",
parents=[parent_parser],
@@ -1199,9 +1366,22 @@ 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.",
)
remove_group.add_argument(
"-g",
"--git-remote",
metavar="<git_remote>",
help="Name or URL of the Git remote to remove the experiment from",
)
experiments_remove_parser.add_argument(
"experiment",
nargs="*",
@@ -1210,6 +1390,92 @@ def add_parser(subparsers, parent_parser):
)
experiments_remove_parser.set_defaults(func=CmdExperimentsRemove)
EXPERIMENTS_INIT_HELP = "Initialize experiments."
experiments_init_parser = experiments_subparsers.add_parser(
"init",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_INIT_HELP, "exp/init"),
formatter_class=RawDefaultsHelpFormatter,
)
experiments_init_parser.add_argument(
"cmd",
nargs=argparse.REMAINDER,
help="Command to execute.",
metavar="command",
)
experiments_init_parser.add_argument(
"--run",
action="store_true",
help="Run the experiment after initializing it",
)
experiments_init_parser.add_argument(
"--interactive",
"-i",
action="store_true",
help="Prompt for values that are not provided",
)
experiments_init_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite existing stage",
)
experiments_init_parser.add_argument(
"--explicit",
action="store_true",
default=False,
help="Only use the path values explicitly provided",
)
experiments_init_parser.add_argument(
"--name", "-n", help="Name of the stage to create"
)
experiments_init_parser.add_argument(
"--code",
default=CmdExperimentsInit.CODE,
help="Path to the source file or directory "
"which your experiments depend",
)
experiments_init_parser.add_argument(
"--data",
default=CmdExperimentsInit.DATA,
help="Path to the data file or directory "
"which your experiments depend",
)
experiments_init_parser.add_argument(
"--models",
default=CmdExperimentsInit.MODELS,
help="Path to the model file or directory for your experiments",
)
experiments_init_parser.add_argument(
"--params",
default=CmdExperimentsInit.DEFAULT_PARAMS,
help="Path to the parameters file for your experiments",
)
experiments_init_parser.add_argument(
"--metrics",
default=CmdExperimentsInit.DEFAULT_METRICS,
help="Path to the metrics file for your experiments",
)
experiments_init_parser.add_argument(
"--plots",
default=CmdExperimentsInit.PLOTS,
help="Path to the plots file or directory for your experiments",
)
experiments_init_parser.add_argument(
"--live",
help="Path to log dvclive outputs for your experiments "
f"(default: {CmdExperimentsInit.DVCLIVE})",
)
experiments_init_parser.add_argument(
"--type",
choices=["default", "live"],
default="default",
help="Select type of stage to create",
)
experiments_init_parser.set_defaults(func=CmdExperimentsInit)
def _add_run_common(parser):
"""Add common args for 'exp run' and 'exp resume'."""
+8 -6
View File
@@ -1,5 +1,5 @@
import argparse
import os
from pathlib import Path
from dvc.command import completion
from dvc.command.base import CmdBase, fix_subparsers
@@ -12,12 +12,14 @@ 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 = Path.cwd() / (self.args.target + "_html")
from dvc.render.utils import render
ui.write("\nfile://", os.path.abspath(html_path), sep="")
return 0
index_path = render(self.repo, plots, metrics, html_path)
ui.write(index_path.as_uri())
return 0
return 1
class CmdLiveShow(CmdLive):
+7 -5
View File
@@ -34,10 +34,8 @@ class CmdList(CmdBaseNoRepo):
recursive=self.args.recursive,
dvc_only=self.args.dvc_only,
)
if self.args.show_json:
import json
ui.write(json.dumps(entries))
if self.args.json:
ui.write_json(entries)
elif entries:
entries = _prettify(entries, with_color=True)
ui.write("\n".join(entries))
@@ -54,6 +52,7 @@ def add_parser(subparsers, parent_parser):
)
list_parser = subparsers.add_parser(
"list",
aliases=["ls"],
parents=[parent_parser],
description=append_doc_link(LIST_HELP, "list"),
help=LIST_HELP,
@@ -70,7 +69,10 @@ def add_parser(subparsers, parent_parser):
"--dvc-only", action="store_true", help="Show only DVC outputs."
)
list_parser.add_argument(
"--show-json", action="store_true", help="Show output in JSON format."
"--json",
"--show-json",
action="store_true",
help="Show output in JSON format.",
)
list_parser.add_argument(
"--rev",
+423
View File
@@ -0,0 +1,423 @@
import argparse
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.command.config import CmdConfig
from dvc.config import ConfigError
from dvc.exceptions import DvcException
from dvc.ui import ui
from dvc.utils import format_link
class MachineDisabledError(ConfigError):
def __init__(self):
super().__init__("Machine feature is disabled")
class CmdMachineConfig(CmdConfig):
def __init__(self, args):
super().__init__(args)
if not self.config["feature"].get("machine", False):
raise MachineDisabledError
if getattr(self.args, "name", None):
self.args.name = self.args.name.lower()
def _check_exists(self, conf):
if self.args.name not in conf["machine"]:
raise ConfigError(f"machine '{self.args.name}' doesn't exist.")
class CmdMachineAdd(CmdMachineConfig):
def run(self):
from dvc.machine import validate_name
validate_name(self.args.name)
if self.args.default:
ui.write(f"Setting '{self.args.name}' as a default machine.")
with self.config.edit(self.args.level) as conf:
if self.args.name in conf["machine"] and not self.args.force:
raise ConfigError(
"machine '{}' already exists. Use `-f|--force` to "
"overwrite it.".format(self.args.name)
)
conf["machine"][self.args.name] = {"cloud": self.args.cloud}
if self.args.default:
conf["core"]["machine"] = self.args.name
return 0
class CmdMachineRemove(CmdMachineConfig):
def run(self):
with self.config.edit(self.args.level) as conf:
self._check_exists(conf)
del conf["machine"][self.args.name]
up_to_level = self.args.level or "repo"
# Remove core.machine refs to this machine in any shadowing configs
for level in reversed(self.config.LEVELS):
with self.config.edit(level) as conf:
if conf["core"].get("machine") == self.args.name:
del conf["core"]["machine"]
if level == up_to_level:
break
return 0
class CmdMachineList(CmdMachineConfig):
def run(self):
levels = [self.args.level] if self.args.level else self.config.LEVELS
for level in levels:
conf = self.config.read(level)["machine"]
if self.args.name:
conf = conf.get(self.args.name, {})
prefix = self._config_file_prefix(
self.args.show_origin, self.config, level
)
configs = list(self._format_config(conf, prefix))
if configs:
ui.write("\n".join(configs))
return 0
class CmdMachineModify(CmdMachineConfig):
def run(self):
from dvc.config import merge
with self.config.edit(self.args.level) as conf:
merged = self.config.load_config_to_level(self.args.level)
merge(merged, conf)
self._check_exists(merged)
if self.args.name not in conf["machine"]:
conf["machine"][self.args.name] = {}
section = conf["machine"][self.args.name]
if self.args.unset:
section.pop(self.args.option, None)
else:
section[self.args.option] = self.args.value
return 0
class CmdMachineRename(CmdBase):
def _check_exists(self, conf):
if self.args.name not in conf["machine"]:
raise ConfigError(f"machine '{self.args.name}' doesn't exist.")
def _rename_default(self, conf):
if conf["core"].get("machine") == self.args.name:
conf["core"]["machine"] = self.args.new
def _check_before_rename(self):
from dvc.machine import validate_name
validate_name(self.args.new)
all_config = self.config.load_config_to_level(None)
if self.args.new in all_config.get("machine", {}):
raise ConfigError(
"Rename failed. Machine '{}' already exists.".format(
self.args.new
)
)
ui.write(f"Rename machine '{self.args.name}' to '{self.args.new}'.")
def run(self):
self._check_before_rename()
with self.config.edit(self.args.level) as conf:
self._check_exists(conf)
conf["machine"][self.args.new] = conf["machine"][self.args.name]
try:
self.repo.machine.rename(self.args.name, self.args.new)
except DvcException as error:
del conf["machine"][self.args.new]
raise ConfigError("terraform rename failed") from error
del conf["machine"][self.args.name]
self._rename_default(conf)
up_to_level = self.args.level or "repo"
for level in reversed(self.config.LEVELS):
if level == up_to_level:
break
with self.config.edit(level) as level_conf:
self._rename_default(level_conf)
return 0
class CmdMachineDefault(CmdMachineConfig):
def run(self):
if self.args.name is None and not self.args.unset:
conf = self.config.read(self.args.level)
try:
print(conf["core"]["machine"])
except KeyError:
ui.write("No default machine set")
return 1
else:
with self.config.edit(self.args.level) as conf:
if self.args.unset:
conf["core"].pop("machine", None)
else:
merged_conf = self.config.load_config_to_level(
self.args.level
)
if (
self.args.name in conf["machine"]
or self.args.name in merged_conf["machine"]
):
conf["core"]["machine"] = self.args.name
else:
raise ConfigError(
"default machine must be present in machine "
"list."
)
return 0
class CmdMachineCreate(CmdBase):
def run(self):
if self.repo.machine is None:
raise MachineDisabledError
self.repo.machine.create(self.args.name)
return 0
class CmdMachineStatus(CmdBase):
SHOWN_FIELD = ["name", "cloud", "instance_hdd_size", "instance_ip"]
def run(self):
if self.repo.machine is None:
raise MachineDisabledError
all_status = self.repo.machine.status(self.args.name)
for i, status in enumerate(all_status, start=1):
ui.write(f"instance_num_{i}:")
for field in self.SHOWN_FIELD:
value = status.get(field, None)
ui.write(f"\t{field:20}: {value}")
return 0
class CmdMachineDestroy(CmdBase):
def run(self):
if self.repo.machine is None:
raise MachineDisabledError
self.repo.machine.destroy(self.args.name)
return 0
class CmdMachineSsh(CmdBase):
def run(self):
if self.repo.machine is None:
raise MachineDisabledError
self.repo.machine.run_shell(self.args.name)
return 0
def add_parser(subparsers, parent_parser):
from dvc.command.config import parent_config_parser
machine_HELP = "Set up and manage cloud machines."
machine_parser = subparsers.add_parser(
"machine",
parents=[parent_parser],
description=append_doc_link(machine_HELP, "machine"),
# NOTE: suppress help during development to hide command
# help=machine_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_subparsers = machine_parser.add_subparsers(
dest="cmd",
help="Use `dvc machine CMD --help` for " "command-specific help.",
)
fix_subparsers(machine_subparsers)
machine_ADD_HELP = "Add a new data machine."
machine_add_parser = machine_subparsers.add_parser(
"add",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_ADD_HELP, "machine/add"),
help=machine_ADD_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_add_parser.add_argument("name", help="Name of the machine")
machine_add_parser.add_argument(
"cloud",
help="Machine cloud. See full list of supported clouds at {}".format(
format_link(
"https://github.com/iterative/"
"terraform-provider-iterative#machine"
)
),
)
machine_add_parser.add_argument(
"-d",
"--default",
action="store_true",
default=False,
help="Set as default machine.",
)
machine_add_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Force overwriting existing configs",
)
machine_add_parser.set_defaults(func=CmdMachineAdd)
machine_DEFAULT_HELP = "Set/unset the default machine."
machine_default_parser = machine_subparsers.add_parser(
"default",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_DEFAULT_HELP, "machine/default"),
help=machine_DEFAULT_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_default_parser.add_argument(
"name", nargs="?", help="Name of the machine"
)
machine_default_parser.add_argument(
"-u",
"--unset",
action="store_true",
default=False,
help="Unset default machine.",
)
machine_default_parser.set_defaults(func=CmdMachineDefault)
machine_LIST_HELP = "List the configuration of one/all machines."
machine_list_parser = machine_subparsers.add_parser(
"list",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_LIST_HELP, "machine/list"),
help=machine_LIST_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_list_parser.add_argument(
"--show-origin",
default=False,
action="store_true",
help="Show the source file containing each config value.",
)
machine_list_parser.add_argument(
"name",
nargs="?",
type=str,
help="name of machine to specify",
)
machine_list_parser.set_defaults(func=CmdMachineList)
machine_MODIFY_HELP = "Modify the configuration of an machine."
machine_modify_parser = machine_subparsers.add_parser(
"modify",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_MODIFY_HELP, "machine/modify"),
help=machine_MODIFY_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_modify_parser.add_argument("name", help="Name of the machine")
machine_modify_parser.add_argument(
"option", help="Name of the option to modify."
)
machine_modify_parser.add_argument(
"value", nargs="?", help="(optional) Value of the option."
)
machine_modify_parser.add_argument(
"-u",
"--unset",
default=False,
action="store_true",
help="Unset option.",
)
machine_modify_parser.set_defaults(func=CmdMachineModify)
machine_RENAME_HELP = "Rename a machine "
machine_rename_parser = machine_subparsers.add_parser(
"rename",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_RENAME_HELP, "remote/rename"),
help=machine_RENAME_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_rename_parser.add_argument("name", help="Machine to be renamed")
machine_rename_parser.add_argument("new", help="New name of the machine")
machine_rename_parser.set_defaults(func=CmdMachineRename)
machine_REMOVE_HELP = "Remove an machine."
machine_remove_parser = machine_subparsers.add_parser(
"remove",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_REMOVE_HELP, "machine/remove"),
help=machine_REMOVE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_remove_parser.add_argument(
"name", help="Name of the machine to remove."
)
machine_remove_parser.set_defaults(func=CmdMachineRemove)
machine_CREATE_HELP = "Create and start a machine instance."
machine_create_parser = machine_subparsers.add_parser(
"create",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_CREATE_HELP, "machine/create"),
help=machine_CREATE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_create_parser.add_argument(
"name", help="Name of the machine to create."
)
machine_create_parser.set_defaults(func=CmdMachineCreate)
machine_STATUS_HELP = "List the status of a running machine."
machine_status_parser = machine_subparsers.add_parser(
"status",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_STATUS_HELP, "machine/status"),
help=machine_STATUS_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_status_parser.add_argument(
"name", help="Name of the running machine."
)
machine_status_parser.set_defaults(func=CmdMachineStatus)
machine_DESTROY_HELP = "Destroy an machine instance."
machine_destroy_parser = machine_subparsers.add_parser(
"destroy",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_DESTROY_HELP, "machine/destroy"),
help=machine_DESTROY_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_destroy_parser.add_argument(
"name", help="Name of the machine instance to destroy."
)
machine_destroy_parser.set_defaults(func=CmdMachineDestroy)
machine_SSH_HELP = "Connect to a machine via SSH."
machine_ssh_parser = machine_subparsers.add_parser(
"ssh",
parents=[parent_config_parser, parent_parser],
description=append_doc_link(machine_SSH_HELP, "machine/ssh"),
help=machine_SSH_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
machine_ssh_parser.add_argument(
"name", help="Name of the machine instance to connect to."
)
machine_ssh_parser.set_defaults(func=CmdMachineSsh)
+16 -18
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__)
@@ -30,23 +31,14 @@ class CmdMetricsShow(CmdMetricsBase):
logger.exception("")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(metrics))
if self.args.json:
ui.write_json(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,
markdown=self.args.markdown,
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
@@ -71,20 +63,20 @@ class CmdMetricsDiff(CmdMetricsBase):
logger.exception("failed to show metrics diff")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(diff))
if self.args.json:
ui.write_json(diff)
else:
from dvc.compare import show_diff
show_diff(
diff,
title="Metric",
markdown=self.args.show_md,
markdown=self.args.markdown,
no_path=self.args.no_path,
precision=self.args.precision or DEFAULT_PRECISION,
round_digits=True,
a_rev=self.args.a_rev,
b_rev=self.args.b_rev,
)
return 0
@@ -146,15 +138,18 @@ def add_parser(subparsers, parent_parser):
help="Show metrics for all commits.",
)
metrics_show_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
help="Show output in JSON format.",
)
metrics_show_parser.add_argument(
"--md",
"--show-md",
action="store_true",
default=False,
dest="markdown",
help="Show tabulated output in the Markdown format (GFM).",
)
metrics_show_parser.add_argument(
@@ -226,15 +221,18 @@ def add_parser(subparsers, parent_parser):
help="Show unchanged metrics as well.",
)
metrics_diff_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
help="Show output in JSON format.",
)
metrics_diff_parser.add_argument(
"--md",
"--show-md",
action="store_true",
default=False,
dest="markdown",
help="Show tabulated output in the Markdown format (GFM).",
)
metrics_diff_parser.add_argument(
+8 -5
View File
@@ -25,19 +25,19 @@ class CmdParamsDiff(CmdBase):
logger.exception("failed to show params diff")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(diff))
if self.args.json:
ui.write_json(diff)
else:
from dvc.compare import show_diff
show_diff(
diff,
title="Param",
markdown=self.args.show_md,
markdown=self.args.markdown,
no_path=self.args.no_path,
show_changes=False,
a_rev=self.args.a_rev,
b_rev=self.args.b_rev,
)
return 0
@@ -103,15 +103,18 @@ def add_parser(subparsers, parent_parser):
help="Show only params that are stage dependencies.",
)
params_diff_parser.add_argument(
"--json",
"--show-json",
action="store_true",
default=False,
help="Show output in JSON format.",
)
params_diff_parser.add_argument(
"--md",
"--show-md",
action="store_true",
default=False,
dest="markdown",
help="Show tabulated output in the Markdown format (GFM).",
)
params_diff_parser.add_argument(
+39 -22
View File
@@ -4,6 +4,7 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.exceptions import DvcException
from dvc.render.utils import find_vega, render
from dvc.ui import ui
from dvc.utils import format_link
@@ -35,38 +36,51 @@ class CmdPlots(CmdBase):
return 1
try:
plots = self._func(targets=self.args.targets, props=self._props())
plots_data = self._func(
targets=self.args.targets, props=self._props()
)
if not plots_data:
ui.error_write(
"No plots were loaded, "
"visualization file will not be created."
)
if self.args.show_vega:
target = self.args.targets[0]
ui.write(plots[target])
plot_json = find_vega(self.repo, plots_data, target)
if plot_json:
ui.write(plot_json)
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
rel: str = self.args.out or "dvc_plots"
path: Path = (Path.cwd() / rel).resolve()
index_path = render(
self.repo,
plots_data,
path=path,
html_template_path=self.args.html_template,
)
assert index_path.is_absolute()
url = index_path.as_uri()
ui.write(url)
if self.args.open:
import webbrowser
opened = webbrowser.open(index_path)
if not opened:
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
return 0
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
opened = webbrowser.open(rel)
if not opened:
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
return 0
class CmdPlotsShow(CmdPlots):
UNINITIALIZED = True
@@ -169,7 +183,10 @@ def add_parser(subparsers, parent_parser):
_add_output_arguments(plots_diff_parser)
plots_diff_parser.set_defaults(func=CmdPlotsDiff)
PLOTS_MODIFY_HELP = "Modify display properties of plot metrics files."
PLOTS_MODIFY_HELP = (
"Modify display properties of data-series plots "
"(has no effect on image-type plots)."
)
plots_modify_parser = plots_subparsers.add_parser(
"modify",
parents=[parent_parser],
+1 -1
View File
@@ -86,7 +86,7 @@ class CmdRemoteDefault(CmdRemote):
if self.args.name is None and not self.args.unset:
conf = self.config.read(self.args.level)
try:
print(conf["core"]["remote"])
ui.write(conf["core"]["remote"])
except KeyError:
ui.write("No default remote set")
return 1
+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)
)
+4 -6
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)
@@ -69,10 +69,8 @@ class CmdDataStatus(CmdDataBase):
if self.args.quiet:
return bool(st)
if self.args.show_json:
import json
ui.write(json.dumps(st))
if self.args.json:
ui.write_json(st)
return 0
if st:
@@ -80,7 +78,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
+21 -5
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
@@ -164,7 +168,10 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
def render(self, **kwargs: Any):
from dvc.ui import ui
ui.table(self, headers=self.keys(), **kwargs)
if kwargs.pop("csv", False):
ui.write(self.to_csv(), end="")
else:
ui.table(self, headers=self.keys(), **kwargs)
def as_dict(
self, cols: Iterable[str] = None
@@ -204,8 +211,12 @@ def diff_table(
precision: int = None,
round_digits: bool = False,
on_empty_diff: str = None,
a_rev: str = None,
b_rev: str = None,
) -> TabularData:
headers: List[str] = ["Path", title, "Old", "New", "Change"]
a_rev = a_rev or "HEAD"
b_rev = b_rev or "workspace"
headers: List[str] = ["Path", title, a_rev, b_rev, "Change"]
fill_value = "-"
td = TabularData(headers, fill_value=fill_value)
@@ -233,8 +244,8 @@ def diff_table(
td.drop("Change")
if not old:
td.drop("Old")
td.rename("New", "Value")
td.drop(a_rev)
td.rename(b_rev, "Value")
return td
@@ -249,6 +260,8 @@ def show_diff(
round_digits: bool = False,
on_empty_diff: str = None,
markdown: bool = False,
a_rev: str = None,
b_rev: str = None,
) -> None:
td = diff_table(
diff,
@@ -259,6 +272,8 @@ def show_diff(
precision=precision,
round_digits=round_digits,
on_empty_diff=on_empty_diff,
a_rev=a_rev,
b_rev=b_rev,
)
td.render(markdown=markdown)
@@ -277,8 +292,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)
+40 -15
View File
@@ -20,7 +20,27 @@ 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
class MachineConfigError(ConfigError):
pass
class NoMachineError(MachineConfigError):
pass
class MachineNotFoundError(MachineConfigError):
pass
@@ -56,8 +76,10 @@ class Config(dict):
APPNAME = "dvc"
APPAUTHOR = "iterative"
SYSTEM_LEVELS = ("system", "global")
REPO_LEVELS = ("repo", "local")
# In the order they shadow each other
LEVELS = ("system", "global", "repo", "local")
LEVELS = SYSTEM_LEVELS + REPO_LEVELS
CONFIG = "config"
CONFIG_LOCAL = "config.local"
@@ -119,7 +141,7 @@ class Config(dict):
dvc.config.Config: config object.
"""
config_file = os.path.join(dvc_dir, Config.CONFIG)
open(config_file, "w+").close()
open(config_file, "w+", encoding="utf-8").close()
return Config(dvc_dir)
def load(self, validate=True, config=None):
@@ -159,7 +181,7 @@ class Config(dict):
conf_obj = ConfigObj(fobj)
else:
conf_obj = ConfigObj()
return _parse_remotes(_lower_keys(conf_obj.dict()))
return _parse_named(_lower_keys(conf_obj.dict()))
def _save_config(self, level, conf_dict):
from configobj import ConfigObj
@@ -171,7 +193,7 @@ class Config(dict):
fs.makedirs(os.path.dirname(filename))
config = ConfigObj(_pack_remotes(conf_dict))
config = ConfigObj(_pack_named(conf_dict))
with fs.open(filename, "wb") as fobj:
config.write(fobj)
config.filename = filename
@@ -243,6 +265,7 @@ class Config(dict):
"key_path": func,
}
},
"machine": {str: {"startup_script": func}},
}
return Schema(dirs_schema, extra=ALLOW_EXTRA)(conf)
@@ -265,7 +288,7 @@ class Config(dict):
def edit(self, level=None, validate=True):
# NOTE: we write to repo config by default, same as git config
level = level or "repo"
if level in {"repo", "local"} and self.dvc_dir is None:
if self.dvc_dir is None and level in self.REPO_LEVELS:
raise ConfigError("Not inside a DVC repo")
conf = self.load_one(level)
@@ -292,27 +315,29 @@ class Config(dict):
raise ConfigError(str(exc)) from None
def _parse_remotes(conf):
result = {"remote": {}}
def _parse_named(conf):
result = {"remote": {}, "machine": {}}
for section, val in conf.items():
name = re_find(r'^\s*remote\s*"(.*)"\s*$', section)
if name:
result["remote"][name] = val
match = re_find(r'^\s*(remote|machine)\s*"(.*)"\s*$', section)
if match:
key, name = match
result[key][name] = val
else:
result[section] = val
return result
def _pack_remotes(conf):
def _pack_named(conf):
# Drop empty sections
result = compact(conf)
# Transform remote.name -> 'remote "name"'
for name, val in conf["remote"].items():
result[f'remote "{name}"'] = val
result.pop("remote", None)
for key in ("remote", "machine"):
for name, val in conf[key].items():
result[f'{key} "{name}"'] = val
result.pop(key, None)
return result
+41 -2
View File
@@ -118,6 +118,7 @@ SCHEMA = {
Optional("autostage", default=False): Bool,
Optional("experiments"): Bool, # obsoleted
Optional("check_update", default=True): Bool,
"machine": Lower,
},
"cache": {
"local": str,
@@ -154,6 +155,9 @@ SCHEMA = {
"grant_read_acp": str,
"grant_write_acp": str,
"grant_full_control": str,
"cache_regions": bool,
"read_timeout": Coerce(int),
"connect_timeout": Coerce(int),
**REMOTE_COMMON,
},
"gs": {
@@ -189,7 +193,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": {
@@ -217,13 +225,44 @@ SCHEMA = {
)
},
"state": {
"dir": str,
"row_limit": All(Coerce(int), Range(1)), # obsoleted
"row_cleanup_quota": All(Coerce(int), Range(0, 100)), # obsoleted
},
"index": {
"dir": str,
},
"machine": {
str: {
"cloud": All(Lower, Choices("aws", "azure")),
"region": All(
Lower, Choices("us-west", "us-east", "eu-west", "eu-north")
),
"image": str,
"name": str,
"spot": Bool,
"spot_price": Coerce(float),
"instance_hdd_size": Coerce(int),
"instance_type": Lower,
"instance_gpu": Lower,
"ssh_private": str,
"startup_script": str,
},
},
# section for experimental features
"feature": {
Optional("machine", default=False): Bool,
# enabled by default. It's of no use, kept for backward compatibility.
Optional("parametrization", default=True): Bool
Optional("parametrization", default=True): Bool,
},
"plots": {"html_template": str},
"exp": {
"code": str,
"data": str,
"models": str,
"metrics": str,
"params": str,
"plots": str,
"live": str,
},
}
+17 -13
View File
@@ -12,29 +12,33 @@ from dvc.utils import fix_env, is_binary
logger = logging.getLogger(__name__)
CREATE_NEW_PROCESS_GROUP = 0x00000200
DETACHED_PROCESS = 0x00000008
def _popen(cmd, **kwargs):
prefix = [sys.executable]
if not is_binary():
prefix += [sys.argv[0]]
main_entrypoint = os.path.join(
os.path.abspath(os.path.dirname(__file__)), "__main__.py"
)
prefix += [main_entrypoint]
return Popen(prefix + cmd, close_fds=True, shell=False, **kwargs)
def _spawn_windows(cmd, env):
from subprocess import STARTF_USESHOWWINDOW, STARTUPINFO
from subprocess import (
CREATE_NEW_PROCESS_GROUP,
CREATE_NO_WINDOW,
STARTF_USESHOWWINDOW,
STARTUPINFO,
)
creationflags = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS
# https://stackoverflow.com/a/7006424
# https://bugs.python.org/issue41619
creationflags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
startupinfo = STARTUPINFO()
startupinfo.dwFlags |= STARTF_USESHOWWINDOW
_popen(
cmd, env=env, creationflags=creationflags, startupinfo=startupinfo
).communicate()
_popen(cmd, env=env, creationflags=creationflags, startupinfo=startupinfo)
def _spawn_posix(cmd, env):
@@ -44,17 +48,17 @@ def _spawn_posix(cmd, env):
# with PyInstaller has trouble with SystemExit exception and throws
# errors such as "[26338] Failed to execute script __main__"
try:
pid = os.fork()
pid = os.fork() # pylint: disable=no-member
if pid > 0:
return
except OSError:
logger.exception("failed at first fork")
os._exit(1) # pylint: disable=protected-access
os.setsid()
os.setsid() # pylint: disable=no-member
try:
pid = os.fork()
pid = os.fork() # pylint: disable=no-member
if pid > 0:
os._exit(0) # pylint: disable=protected-access
except OSError:
+10 -10
View File
@@ -163,7 +163,7 @@ class AsciiCanvas:
self.point(x0 + width, y0 + height, "+")
def _build_sugiyama_layout(vertexes, edges):
def _build_sugiyama_layout(vertices, edges):
#
# Just a reminder about naming conventions:
# +------------X
@@ -174,17 +174,17 @@ def _build_sugiyama_layout(vertexes, edges):
# Y
#
vertexes = {v: Vertex(f" {v} ") for v in vertexes}
vertices = {v: Vertex(f" {v} ") for v in vertices}
# NOTE: reverting edges to correctly orientate the graph
edges = [Edge(vertexes[e], vertexes[s]) for s, e in edges]
vertexes = vertexes.values()
graph = Graph(vertexes, edges)
edges = [Edge(vertices[e], vertices[s]) for s, e in edges]
vertices = vertices.values()
graph = Graph(vertices, edges)
for vertex in vertexes:
for vertex in vertices:
vertex.view = VertexViewer(vertex.data)
# NOTE: determine min box length to create the best layout
minw = min(v.view.w for v in vertexes)
minw = min(v.view.w for v in vertices)
for edge in edges:
edge.view = EdgeViewer()
@@ -204,11 +204,11 @@ def _build_sugiyama_layout(vertexes, edges):
return sug
def draw(vertexes, edges):
def draw(vertices, edges):
"""Build a DAG and draw it in ASCII.
Args:
vertexes (list): list of graph vertexes.
vertices (list): list of graph vertices.
edges (list): list of graph edges.
"""
# pylint: disable=too-many-locals
@@ -217,7 +217,7 @@ def draw(vertexes, edges):
Xs = [] # pylint: disable=invalid-name
Ys = [] # pylint: disable=invalid-name
sug = _build_sugiyama_layout(vertexes, edges)
sug = _build_sugiyama_layout(vertices, edges)
for vertex in sug.g.sV:
# NOTE: moving boxes w/2 to the left
+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.index_db_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))
+21 -1
View File
@@ -1,3 +1,4 @@
import logging
import os
from collections import defaultdict
@@ -10,6 +11,8 @@ from dvc.utils.serialize import LOADERS, ParseError
from .base import Dependency
logger = logging.getLogger(__name__)
class MissingParamsError(DvcException):
pass
@@ -98,7 +101,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 = {}
@@ -133,3 +136,20 @@ class ParamsDependency(Dependency):
)
return HashInfo(self.PARAM_PARAMS, info)
def save(self):
if not self.exists:
raise self.DoesNotExistError(self)
if not self.isfile and not self.isdir:
raise self.IsNotFileOrDirError(self)
if self.is_empty:
logger.warning(f"'{self}' is empty.")
self.ignore()
if self.metric or self.plot:
self.verify_metric()
self.hash_info = self.get_hash()
+85 -60
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
@@ -61,17 +63,12 @@ class RepoDependency(Dependency):
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
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
from dvc.objects.checkout import checkout
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,81 +92,109 @@ 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
from dvc.objects.db.git import GitObjectDB
from dvc.exceptions import NoOutputOrStageError, PathMissingError
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)
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:
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
staging, _, staged_obj = stage(
local_odb,
path_info,
repo.repo_fs,
local_odb.fs.PARAM_CHECKSUM,
)
except FileNotFoundError as exc:
raise PathMissingError(
self.def_path, self.def_repo[self.PARAM_URL]
) from exc
staging = copy(staging)
staging.read_only = True
staged_obj = stage(
local_odb,
path_info,
repo.repo_fs,
local_odb.fs.PARAM_CHECKSUM,
)
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(oid for _, _, oid 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(oid) for _, _, oid 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
+35 -55
View File
@@ -1,10 +1,7 @@
import collections
import contextlib
import logging
import os
from typing import TYPE_CHECKING, Any, Union
from voluptuous import MultipleInvalid
from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union
from dvc.exceptions import DvcException
from dvc.parsing.versions import LOCKFILE_VERSION, SCHEMA_KWD
@@ -12,23 +9,18 @@ from dvc.stage import serialize
from dvc.stage.exceptions import (
StageFileBadNameError,
StageFileDoesNotExistError,
StageFileFormatError,
StageFileIsNotDvcFileError,
)
from dvc.types import AnyPath
from dvc.utils import relpath
from dvc.utils.collections import apply_diff
from dvc.utils.serialize import (
dump_yaml,
modify_yaml,
parse_yaml,
parse_yaml_for_update,
)
from dvc.utils.serialize import dump_yaml, modify_yaml
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
DVC_FILE = "Dvcfile"
DVC_FILE_SUFFIX = ".dvc"
@@ -45,10 +37,6 @@ class FileIsGitIgnored(DvcException):
)
class LockfileCorruptedError(DvcException):
pass
class ParametrizedDumpError(DvcException):
pass
@@ -96,7 +84,7 @@ def check_dvcfile_path(repo, path):
class FileMixin:
SCHEMA = None
SCHEMA: Callable[[_T], _T]
def __init__(self, repo, path, verify=True, **kwargs):
self.repo = repo
@@ -138,7 +126,11 @@ class FileMixin:
if self._is_git_ignored():
raise FileIsGitIgnored(self.path)
def _load(self):
def load(self, **kwargs: Any) -> Any:
d, _ = self._load(**kwargs)
return d
def _load(self, **kwargs: Any) -> Tuple[Any, str]:
# it raises the proper exceptions by priority:
# 1. when the file doesn't exists
# 2. filename is not a DVC file
@@ -155,18 +147,23 @@ class FileMixin:
raise StageFileIsNotDvcFileError(self.path)
self._check_gitignored()
with self.repo.fs.open(self.path, encoding="utf-8") as fd:
stage_text = fd.read()
d = parse_yaml(stage_text, self.path)
return self.validate(d, self.relpath), stage_text
return self._load_yaml(**kwargs)
@classmethod
def validate(cls, d, fname=None):
assert isinstance(cls.SCHEMA, collections.abc.Callable)
try:
return cls.SCHEMA(d) # pylint: disable=not-callable
except MultipleInvalid as exc:
raise StageFileFormatError(f"'{fname}' format error: {exc}")
def validate(cls, d: _T, fname: str = None) -> _T:
from dvc.utils.strictyaml import validate
return validate(d, cls.SCHEMA, path=fname) # type: ignore[arg-type]
def _load_yaml(self, **kwargs: Any) -> Tuple[Any, str]:
from dvc.utils import strictyaml
return strictyaml.load(
self.path,
self.SCHEMA, # type: ignore[arg-type]
self.repo.fs,
**kwargs,
)
def remove(self, force=False): # pylint: disable=unused-argument
with contextlib.suppress(FileNotFoundError):
@@ -200,9 +197,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,10 +295,7 @@ class PipelineFile(FileMixin):
if not self.exists():
return
with open(self.path, "r", encoding="utf-8") as f:
d = parse_yaml_for_update(f.read(), self.path)
self.validate(d, self.path)
d, _ = self._load_yaml(round_trip=True)
if stage.name not in d.get("stages", {}):
return
@@ -345,34 +337,25 @@ def migrate_lock_v1_to_v2(d, version_info):
d["stages"] = stages
def lockfile_schema(data: _T) -> _T:
schema = get_lockfile_schema(data)
return schema(data)
class Lockfile(FileMixin):
@classmethod
def validate(cls, d, fname=None):
schema = get_lockfile_schema(d)
try:
return schema(d)
except MultipleInvalid as exc:
raise StageFileFormatError(f"'{fname}' format error: {exc}")
SCHEMA = staticmethod(lockfile_schema) # type: ignore[assignment]
def _verify_filename(self):
pass # lockfile path is hardcoded, so no need to verify here
def _load(self):
def _load(self, **kwargs: Any):
try:
return super()._load()
return super()._load(**kwargs)
except StageFileDoesNotExistError:
# we still need to account for git-ignored dvc.lock file
# even though it may not exist or have been .dvcignored
self._check_gitignored()
return {}, ""
except StageFileFormatError as exc:
raise LockfileCorruptedError(
f"Lockfile '{self.relpath}' is corrupted."
) from exc
def load(self):
d, _ = self._load()
return d
@property
def latest_version_info(self):
@@ -411,10 +394,7 @@ class Lockfile(FileMixin):
if not self.exists():
return
with open(self.path, encoding="utf-8") as f:
d = parse_yaml_for_update(f.read(), self.path)
self.validate(d, self.path)
d, _ = self._load_yaml(round_trip=True)
version = LOCKFILE_VERSION.from_dict(d)
data = d if version == LOCKFILE_VERSION.V1 else d.get("stages", {})
if stage.name not in data:
+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"
+14 -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):
@@ -392,3 +364,8 @@ class CircularImportError(DvcException):
f"'{dep}' contains invalid circular import. "
f"DVC repo '{a}' already imports from '{b}'."
)
class PrettyDvcException(DvcException):
def __pretty_exc__(self, **kwargs):
"""Print prettier exception message."""
+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] = {}
+10 -3
View File
@@ -2,6 +2,7 @@ import posixpath
from urllib.parse import urlparse
from ..scheme import Schemes
from . import utils # noqa: F401
from .azure import AzureFileSystem
from .gdrive import GDriveFileSystem
from .gs import GSFileSystem
@@ -32,15 +33,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)
+20 -59
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
@@ -13,7 +11,7 @@ from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from dvc.utils import format_link
from .fsspec_wrapper import ObjectFSWrapper
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
logger = logging.getLogger(__name__)
_DEFAULT_CREDS_STEPS = (
@@ -23,47 +21,6 @@ _DEFAULT_CREDS_STEPS = (
)
@contextmanager
def _temp_event_loop():
"""When trying to initalize 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
@@ -82,7 +39,7 @@ def _az_config():
# pylint:disable=abstract-method
class AzureFileSystem(ObjectFSWrapper):
class AzureFileSystem(CallbackMixin, ObjectFSWrapper):
scheme = Schemes.AZURE
PATH_CLS = CloudURLInfo
PARAM_CHECKSUM = "etag"
@@ -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)
+131 -93
View File
@@ -1,15 +1,19 @@
import contextlib
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial, partialmethod
from functools import partialmethod
from multiprocessing import cpu_count
from typing import Any, ClassVar, Dict, FrozenSet, Optional
from tqdm.utils import CallbackIOWrapper
from dvc.exceptions import DvcException
from dvc.path_info import URLInfo
from dvc.progress import Tqdm
from dvc.progress import DEFAULT_CALLBACK, FsspecCallback
from dvc.ui import ui
from dvc.utils import tmp_fname
from dvc.utils.fs import makedirs, move
from dvc.utils.http import open_url
logger = logging.getLogger(__name__)
@@ -33,6 +37,8 @@ class RemoteMissingDepsError(DvcException):
class BaseFileSystem:
sep = "/"
scheme = "base"
REQUIRES: ClassVar[Dict[str, str]] = {}
PATH_CLS = URLInfo # type: Any
@@ -57,6 +63,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):
@@ -118,13 +129,10 @@ class BaseFileSystem:
f"dependencies: {missing}. {hint}"
)
def open(self, path_info, mode: str = "r", encoding: str = None, **kwargs):
if hasattr(self, "_generate_download_url"):
# pylint:disable=no-member
func = self._generate_download_url # type: ignore[attr-defined]
get_url = partial(func, path_info)
return open_url(get_url, mode=mode, encoding=encoding, **kwargs)
def checksum(self, path_info) -> str:
raise NotImplementedError
def open(self, path_info, mode: str = "r", encoding: str = None, **kwargs):
raise RemoteActionNotImplemented("open", self.scheme)
def exists(self, path_info) -> bool:
@@ -167,12 +175,6 @@ class BaseFileSystem:
"""
raise NotImplementedError
def ls(self, path_info, detail=False):
raise RemoteActionNotImplemented("ls", self.scheme)
def find(self, path_info, detail=False, prefix=None):
raise RemoteActionNotImplemented("find", self.scheme)
def is_empty(self, path_info):
return False
@@ -185,7 +187,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
"""
@@ -214,43 +216,58 @@ class BaseFileSystem:
return False
return hash_.endswith(cls.CHECKSUM_DIR_SUFFIX)
def upload(self, from_info, to_info, name=None, no_progress_bar=False):
if not hasattr(self, "_upload"):
raise RemoteActionNotImplemented("upload", self.scheme)
def upload(
self,
from_info,
to_info,
total=None,
desc=None,
callback=None,
no_progress_bar=False,
**pbar_args,
):
is_file_obj = hasattr(from_info, "read")
method = "upload_fobj" if is_file_obj else "put_file"
if not hasattr(self, method):
raise RemoteActionNotImplemented(method, self.scheme)
if to_info.scheme != self.scheme:
raise NotImplementedError
if from_info.scheme != "local":
raise NotImplementedError
if not is_file_obj:
desc = desc or from_info.name
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
stack = contextlib.ExitStack()
if not callback:
pbar = ui.progress(
desc=desc,
disable=no_progress_bar,
bytes=True,
total=total or -1,
**pbar_args,
)
stack.enter_context(pbar)
callback = pbar.as_callback()
if total:
callback.set_size(total)
name = name or from_info.name
with stack:
if is_file_obj:
wrapped = CallbackIOWrapper(
callback.relative_update, from_info
)
# `size` is used to provide hints to the WebdavFileSystem
# for legacy servers.
# pylint: disable=no-member
return self.upload_fobj(wrapped, to_info, size=total)
self._upload( # noqa, pylint: disable=no-member
from_info.fspath,
to_info,
name=name,
no_progress_bar=no_progress_bar,
)
if from_info.scheme != "local":
raise NotImplementedError
def upload_fobj(
self, fobj, to_info, no_progress_bar=False, size=None, **pbar_args
):
if not hasattr(self, "_upload_fobj"):
raise RemoteActionNotImplemented("upload_fobj", self.scheme)
with Tqdm.wrapattr(
fobj,
"read",
disable=no_progress_bar,
bytes=True,
total=size,
**pbar_args,
) as wrapped:
self._upload_fobj( # pylint: disable=no-member
wrapped, to_info, size=size
logger.debug("Uploading '%s' to '%s'", from_info, to_info)
# pylint: disable=no-member
return self.put_file(
os.fspath(from_info), to_info, callback=callback
)
def download(
@@ -258,84 +275,105 @@ class BaseFileSystem:
from_info,
to_info,
name=None,
callback=None,
no_progress_bar=False,
jobs=None,
_only_file=False,
**kwargs,
):
if not hasattr(self, "_download"):
raise RemoteActionNotImplemented("download", self.scheme)
if not hasattr(self, "get_file"):
raise RemoteActionNotImplemented("get_file", self.scheme)
if from_info.scheme != self.scheme:
raise NotImplementedError
if to_info.scheme == self.scheme != "local":
self.copy(from_info, to_info)
return 0
if to_info.scheme != "local":
raise NotImplementedError
if not _only_file and self.isdir(from_info):
return self._download_dir(
from_info, to_info, name, no_progress_bar, jobs, **kwargs
download_dir = not _only_file and self.isdir(from_info)
desc = name or to_info.name
stack = contextlib.ExitStack()
if not callback:
pbar_kwargs = {"unit": "Files"} if download_dir else {}
pbar = ui.progress(
total=-1,
desc="Downloading directory" if download_dir else desc,
bytes=not download_dir,
disable=no_progress_bar,
**pbar_kwargs,
)
return self._download_file(from_info, to_info, name, no_progress_bar)
stack.enter_context(pbar)
callback = pbar.as_callback()
with stack:
if download_dir:
return self._download_dir(
from_info, to_info, callback=callback, jobs=jobs, **kwargs
)
return self._download_file(from_info, to_info, callback=callback)
download_file = partialmethod(download, _only_file=True)
def _download_dir(
self, from_info, to_info, name, no_progress_bar, jobs, **kwargs
self,
from_info,
to_info,
callback=DEFAULT_CALLBACK,
jobs=None,
**kwargs,
):
from_infos = list(self.walk_files(from_info, **kwargs))
if not from_infos:
makedirs(to_info, exist_ok=True)
return None
to_infos = (
to_info / info.relative_to(from_info) for info in from_infos
)
callback.set_size(len(from_infos))
with Tqdm(
total=len(from_infos),
desc="Downloading directory",
unit="Files",
disable=no_progress_bar,
) as pbar:
download_files = pbar.wrap_fn(
partial(self._download_file, name=name, no_progress_bar=True)
)
max_workers = jobs or self.jobs
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(download_files, from_info, to_info)
for from_info, to_info in zip(from_infos, to_infos)
]
download_files = FsspecCallback.wrap_fn(callback, self._download_file)
max_workers = jobs or self.jobs
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(download_files, from_info, to_info)
for from_info, to_info in zip(from_infos, to_infos)
]
# NOTE: unlike pulling/fetching cache, where we need to
# download everything we can, not raising an error here might
# turn very ugly, as the user might think that he has
# downloaded a complete directory, while having a partial one,
# which might cause unexpected results in his pipeline.
for future in as_completed(futures):
# NOTE: executor won't let us raise until all futures that
# it has are finished, so we need to cancel them ourselves
# before re-raising.
exc = future.exception()
if exc:
for entry in futures:
entry.cancel()
raise exc
# NOTE: unlike pulling/fetching cache, where we need to
# download everything we can, not raising an error here might
# turn very ugly, as the user might think that he has
# downloaded a complete directory, while having a partial one,
# which might cause unexpected results in his pipeline.
for future in as_completed(futures):
# NOTE: executor won't let us raise until all futures that
# it has are finished, so we need to cancel them ourselves
# before re-raising.
exc = future.exception()
if exc:
for entry in futures:
entry.cancel()
raise exc
def _download_file(self, from_info, to_info, name, no_progress_bar):
def _download_file(
self,
from_info,
to_info,
callback=DEFAULT_CALLBACK,
):
makedirs(to_info.parent, exist_ok=True)
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
name = name or to_info.name
tmp_file = tmp_fname(to_info)
self._download( # noqa, pylint: disable=no-member
from_info, tmp_file, name=name, no_progress_bar=no_progress_bar
)
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
try:
# noqa, pylint: disable=no-member
self.get_file(from_info, tmp_file, callback=callback)
except Exception: # pylint: disable=broad-except
# do we need to rollback makedirs for previously not-existing
# directories?
with contextlib.suppress(FileNotFoundError):
os.unlink(tmp_file)
raise
move(tmp_file, to_info)
+61 -32
View File
@@ -6,6 +6,7 @@ from dvc.exceptions import OutputNotFoundError
from dvc.path_info import PathInfo
from dvc.utils import relpath
from ..progress import DEFAULT_CALLBACK
from ._metadata import Metadata
from .base import BaseFileSystem
@@ -23,6 +24,8 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
repo: DVC repo.
"""
sep = os.sep
scheme = "local"
PARAM_CHECKSUM = "md5"
@@ -30,6 +33,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)
@@ -49,14 +56,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)
obj = out.obj.trie.get(key)
if obj:
return obj.hash_info
if out.obj is None:
raise FileNotFoundError
(_, oid) = out.obj.trie.get(key) or (None, None)
if oid:
return oid
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:
@@ -76,33 +83,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
@@ -125,7 +136,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
@@ -143,7 +154,7 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
self._fetch_dir(out, **kwargs)
base = out.path_info.parts
for key, _ in out.obj: # noqa: B301
for key, _, _ in out.obj: # noqa: B301
trie[base + key] = None
def _walk(self, root, trie, topdown=True, **kwargs):
@@ -155,16 +166,19 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
self._add_dir(trie, out, **kwargs)
root_len = len(root.parts)
for key, out in trie.iteritems(prefix=root.parts): # noqa: B301
if key == root.parts:
continue
try:
for key, out in trie.iteritems(prefix=root.parts): # noqa: B301
if key == root.parts:
continue
name = key[root_len]
if len(key) > root_len + 1 or (out and out.is_dir_checksum):
dirs.add(name)
continue
name = key[root_len]
if len(key) > root_len + 1 or (out and out.is_dir_checksum):
dirs.add(name)
continue
files.append(name)
files.append(name)
except KeyError:
pass
assert topdown
dirs = list(dirs)
@@ -231,17 +245,32 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def info(self, path_info):
meta = self.metadata(path_info)
ret = {"type": "dir" if meta.isdir else "file"}
ret = {"type": "directory" if meta.isdir else "file"}
if meta.is_output and len(meta.outs) == 1 and meta.outs[0].hash_info:
hash_info = meta.outs[0].hash_info
ret["size"] = hash_info.size
ret[hash_info.name] = hash_info.value
out = meta.outs[0]
ret["size"] = out.meta.size
ret[out.hash_info.name] = out.hash_info.value
elif meta.part_of_output:
(out,) = meta.outs
key = path_info.relative_to(out.path_info).parts
obj = out.obj.trie.get(key)
if obj:
ret["size"] = obj.size
ret[obj.hash_info.name] = obj.hash_info.value
(obj_meta, oid) = out.obj.trie.get(key) or (None, None)
if oid:
ret["size"] = obj_meta.size if obj_meta else 0
ret[oid.name] = oid.value
return ret
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
fs, path = self._get_fs_path(from_info)
fs.get_file( # pylint: disable=protected-access
path, to_file, callback=callback, **kwargs
)
def checksum(self, path_info):
info = self.info(path_info)
md5 = info.get("md5")
if md5:
return md5
raise NotImplementedError
+223 -65
View File
@@ -1,13 +1,38 @@
import os
import shutil
from functools import lru_cache
from typing import (
IO,
TYPE_CHECKING,
Any,
Dict,
Iterator,
Optional,
Union,
overload,
)
from funcy import cached_property
from tqdm.utils import CallbackIOWrapper
from dvc.progress import Tqdm
from dvc.path_info import URLInfo
from dvc.progress import DEFAULT_CALLBACK
from .base import BaseFileSystem
FSPath = URLInfo
AnyFSPath = Union[FSPath, str]
if TYPE_CHECKING:
from typing import Iterable
from typing_extensions import Literal
# An info() entry, might evolve to a TypedDict
# in the future (e.g for properly type 'size' etc).
Entry = Dict[str, Any]
# pylint: disable=no-member
class FSSpecWrapper(BaseFileSystem):
@@ -18,25 +43,36 @@ class FSSpecWrapper(BaseFileSystem):
self.fs_args = {"skip_instance_cache": True}
self.fs_args.update(self._prepare_credentials(**kwargs))
@staticmethod
def _get_kwargs_from_urls(urlpath: str) -> "Dict[str, Any]":
from fsspec.utils import infer_storage_options
options = infer_storage_options(urlpath)
options.pop("path", None)
options.pop("protocol", None)
return options
@cached_property
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
def _with_bucket(self, path: AnyFSPath) -> str:
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
def _strip_bucket(self, entry: str) -> str:
return entry
return path or bucket
@overload
def _strip_buckets(
self, entries: "Iterable[Entry]", detail: "Literal[True]"
) -> "Iterator[Entry]":
...
@overload
def _strip_buckets(
self, entries: "Iterable[str]", detail: "Literal[False]" = False
) -> "Iterator[str]":
...
def _strip_buckets(self, entries, detail=False):
for entry in entries:
@@ -47,55 +83,91 @@ class FSSpecWrapper(BaseFileSystem):
entry = self._strip_bucket(entry)
yield entry
def _entry_hook(self, entry):
"""Simple hook method to be overriden when wanted to process
def _entry_hook(self, entry: "Entry") -> "Entry":
"""Simple hook method to be overridden when wanted to process
entries within info() and ls(detail=True) calls"""
return entry
def _prepare_credentials(
self, **config
): # pylint: disable=unused-argument
self, **config: Dict[str, Any] # pylint: disable=unused-argument
) -> Dict[str, Any]:
"""Prepare the arguments for authentication to the
host filesystem"""
return {}
def _isdir(self, path_info):
def _isdir(self, path_info: FSPath) -> bool:
return self.fs.isdir(self._with_bucket(path_info))
def isdir(self, path_info):
def isdir(self, path_info: FSPath) -> bool:
try:
return self._isdir(path_info)
except FileNotFoundError:
return False
def isfile(self, path_info):
def isfile(self, path_info: FSPath) -> bool:
try:
return not self._isdir(path_info)
except FileNotFoundError:
return False
def is_empty(self, path_info):
def is_empty(self, path_info: FSPath) -> bool:
entry = self.info(path_info)
if entry["type"] == "directory":
return not self.fs.ls(self._with_bucket(path_info))
return entry["size"] == 0
def open(
self, path_info, mode="r", **kwargs
): # pylint: disable=arguments-differ
self,
path_info: FSPath,
mode: str = "r",
encoding: Optional[str] = None,
**kwargs,
) -> "IO": # pylint: disable=arguments-differ
return self.fs.open(self._with_bucket(path_info), mode=mode)
def copy(self, from_info, to_info):
def checksum(self, path_info: FSPath) -> str:
return self.fs.checksum(self._with_bucket(path_info))
def copy(self, from_info: FSPath, to_info: FSPath) -> None:
self.makedirs(to_info.parent)
self.fs.copy(self._with_bucket(from_info), self._with_bucket(to_info))
def exists(self, path_info) -> bool:
def exists(self, path_info: FSPath) -> bool:
return self.fs.exists(self._with_bucket(path_info))
@overload
def ls(
self, path_info: FSPath, detail: "Literal[True]"
) -> "Iterator[Entry]":
...
@overload
def ls(self, path_info: FSPath, detail: "Literal[False]") -> Iterator[str]:
...
def ls(self, path_info, detail=False):
path = self._with_bucket(path_info)
files = self.fs.ls(path, detail=detail)
yield from self._strip_buckets(files, detail=detail)
@overload
def find(
self,
path_info: FSPath,
detail: "Literal[True]",
prefix: Optional[bool],
) -> "Iterator[Entry]":
...
@overload
def find(
self,
path_info: FSPath,
detail: "Literal[False]",
prefix: Optional[bool],
) -> Iterator[str]:
...
# pylint: disable=unused-argument
def find(self, path_info, detail=False, prefix=None):
path = self._with_bucket(path_info)
@@ -105,60 +177,86 @@ class FSSpecWrapper(BaseFileSystem):
yield from self._strip_buckets(files, detail=detail)
def walk_files(self, path_info, **kwargs):
def walk_files(self, path_info: FSPath, **kwargs) -> Iterator[FSPath]:
for file in self.find(path_info, **kwargs):
yield path_info.replace(path=file)
def remove(self, path_info):
self.fs.rm(self._with_bucket(path_info))
def move(self, from_info: FSPath, to_info: FSPath) -> None:
self.fs.move(self._with_bucket(from_info), self._with_bucket(to_info))
def info(self, path_info):
def remove(self, path_info: FSPath) -> None:
self.fs.rm_file(self._with_bucket(path_info))
def info(self, path_info: FSPath) -> "Entry":
info = self.fs.info(self._with_bucket(path_info)).copy()
info = self._entry_hook(info)
info["name"] = self._strip_bucket(info["name"])
return info
def _upload_fobj(self, fobj, to_info, size=None):
with self.open(to_info, "wb") as fdest:
shutil.copyfileobj(fobj, fdest, length=fdest.blocksize)
def makedirs(self, path_info: FSPath, **kwargs) -> None:
self.fs.makedirs(
self._with_bucket(path_info), exist_ok=kwargs.pop("exist_ok", True)
)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **kwargs
):
size = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
self._with_bucket(to_info),
size=size,
desc=name,
no_progress_bar=no_progress_bar,
)
def put_file(
self,
from_file: FSPath,
to_info: FSPath,
callback: Any = DEFAULT_CALLBACK,
**kwargs,
) -> None:
self.fs.put_file(
from_file, self._with_bucket(to_info), callback=callback, **kwargs
)
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
):
total = self.getsize(self._with_bucket(from_info))
with self.open(from_info, "rb") as fobj:
with Tqdm.wrapattr(
fobj,
"read",
desc=name,
disable=no_progress_bar,
bytes=True,
total=total,
**pbar_args,
) as wrapped:
with open(to_file, "wb") as fdest:
shutil.copyfileobj(wrapped, fdest, length=fobj.blocksize)
def get_file(
self,
from_info: FSPath,
to_info: FSPath,
callback: Any = DEFAULT_CALLBACK,
**kwargs,
) -> None:
self.fs.get_file(
self._with_bucket(from_info), to_info, callback=callback, **kwargs
)
def upload_fobj(self, fobj: IO, to_info: FSPath, **kwargs) -> None:
self.makedirs(to_info.parent)
with self.open(to_info, "wb") as fdest:
shutil.copyfileobj(
fobj, fdest, length=getattr(fdest, "blocksize", None)
)
# pylint: disable=abstract-method
class ObjectFSWrapper(FSSpecWrapper):
TRAVERSE_PREFIX_LEN = 3
def _isdir(self, path_info):
@lru_cache(512)
def _with_bucket(self, path: AnyFSPath) -> str:
if isinstance(path, self.PATH_CLS):
return f"{path.bucket}/{path.path}"
return str(path)
def _strip_bucket(self, entry: str) -> str:
try:
bucket, path = entry.split("/", 1)
except ValueError:
# If there is no path attached, only returns
# the bucket (top-level).
bucket, path = entry, ""
return path or bucket
def makedirs(self, path_info: FSPath, **kwargs) -> None:
# For object storages make this method a no-op. The original
# fs.makedirs() method will only check if the bucket exists
# and create if it doesn't though we don't want to support
# that behavior, and the check will cost some time so we'll
# simply ignore all mkdir()/makedirs() calls.
return None
def _isdir(self, path_info: FSPath) -> bool:
# Directory in object storages are interpreted differently
# among different fsspec providers, so this logic is a temporary
# measure for us to adapt as of now. It checks whether it is a
@@ -173,7 +271,7 @@ class ObjectFSWrapper(FSSpecWrapper):
)
def find(self, path_info, detail=False, prefix=None):
if prefix is not None:
if prefix:
path = self._with_bucket(path_info.parent)
files = self.fs.find(
path, detail=detail, prefix=path_info.parts[-1]
@@ -183,7 +281,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
@@ -193,4 +291,64 @@ class ObjectFSWrapper(FSSpecWrapper):
if len(files) == 1 and files[0] == path and self.isdir(path_info):
return None
yield from self._strip_buckets(files, detail=detail)
yield from self._strip_buckets( # pylint: disable=not-an-iterable
files, detail=detail
)
# pylint: disable=arguments-differ
class NoDirectoriesMixin:
def isdir(self, *args, **kwargs):
return False
def isfile(self, *args, **kwargs):
return True
def find(self, *args, **kwargs):
raise NotImplementedError
def walk(self, *args, **kwargs):
raise NotImplementedError
def walk_files(self, *args, **kwargs):
raise NotImplementedError
def ls(self, *args, **kwargs):
raise NotImplementedError
class CallbackMixin:
"""Provides callback support for the filesystem that don't support yet."""
def put_file(
self,
from_file,
to_info,
callback=DEFAULT_CALLBACK,
**kwargs,
):
"""Add compatibility support for Callback."""
# pylint: disable=protected-access
self.makedirs(to_info.parent)
size = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
callback.set_size(size)
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
self.upload_fobj(wrapped, to_info)
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
def get_file(
self,
from_info,
to_info,
callback=DEFAULT_CALLBACK,
**kwargs,
):
# pylint: disable=protected-access
total: int = self.getsize(from_info)
if total:
callback.set_size(total)
with self.open(from_info, "rb") as fobj, open(to_info, "wb") as fdest:
wrapped = CallbackIOWrapper(callback.relative_update, fobj)
shutil.copyfileobj(wrapped, fdest, length=fobj.blocksize)
+27 -392
View File
@@ -1,24 +1,18 @@
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, wrap_prop
from dvc.exceptions import DvcException, FileMissingError
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 FSSpecWrapper
logger = logging.getLogger(__name__)
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
@@ -40,36 +34,6 @@ class GDriveAuthError(DvcException):
super().__init__(message)
def _gdrive_retry(func):
def should_retry(exc):
from pydrive2.files import ApiRequestError
if not isinstance(exc, ApiRequestError):
return False
error_code = exc.error.get("code", 0)
result = False
if 500 <= error_code < 600:
result = True
if error_code == 403:
result = exc.GetField("reason") in [
"userRateLimitExceeded",
"rateLimitExceeded",
]
if result:
logger.debug(f"Retrying GDrive API call, error: {exc}.")
return result
# 16 tries, start at 0.5s, multiply by golden ratio, cap at 20s
return retry(
16,
timeout=lambda a: min(0.5 * 1.618 ** a, 20),
filter_errors=should_retry,
)(func)
class GDriveURLInfo(CloudURLInfo):
def __init__(self, url):
super().__init__(url)
@@ -85,7 +49,7 @@ class GDriveURLInfo(CloudURLInfo):
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
class GDriveFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
scheme = Schemes.GDRIVE
PATH_CLS = GDriveURLInfo
PARAM_CHECKSUM = "checksum"
@@ -213,9 +177,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(
@@ -225,7 +189,7 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
temporary_save_path = self._gdrive_service_credentials_path
if is_credentials_temp:
with open(temporary_save_path, "w") as cred_file:
with open(temporary_save_path, "w", encoding="utf-8") as cred_file:
cred_file.write(
os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
)
@@ -290,357 +254,28 @@ class GDriveFileSystem(BaseFileSystem): # pylint:disable=abstract-method
if is_credentials_temp:
os.remove(temporary_save_path)
return GoogleDrive(gauth)
return _GDriveFileSystem(
self._with_bucket(self.path_info),
gauth,
trash_only=self._trash_only,
)
@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.",
),
}
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return posixpath.join(path.bucket, path.path)
self._cache_path_id(self._path, cache["root_id"], cache=cache)
return super()._with_bucket(path)
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.
def _strip_bucket(self, entry):
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
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
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 []
)
def _get_item_id(self, path_info, create=False, use_cache=True, hint=None):
assert path_info.bucket == self._bucket
item_ids = self._path_to_item_ids(path_info.path, create, use_cache)
if item_ids:
return min(item_ids)
assert not create
raise FileMissingError(path_info, hint)
def exists(self, path_info) -> bool:
try:
self._get_item_id(path_info)
except FileMissingError:
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")}
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, **kwargs):
rpath = self._with_bucket(to_info)
self.makedirs(os.path.dirname(rpath))
return self.fs.upload_fobj(fobj, rpath, **kwargs)
+27 -13
View File
@@ -1,14 +1,19 @@
import errno
import os
from tqdm.utils import CallbackIOWrapper
from dvc.utils import is_exec, relpath
from ..progress import DEFAULT_CALLBACK
from .base import BaseFileSystem
class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""Proxies the repo file access methods to Git objects"""
sep = os.sep
scheme = "local"
def __init__(self, root_dir, trie):
@@ -33,8 +38,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"
@@ -93,30 +98,39 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
if not self.exists(path_info):
return False
mode = self.stat(path_info).st_mode
mode = self.info(path_info)["mode"]
return is_exec(mode)
def info(self, path_info):
key = self._get_key(path_info)
try:
st = self.trie.stat(key)
return self.trie.info(key)
except KeyError:
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path_info
)
return {"size": st.st_size}
def stat(self, path):
key = self._get_key(path)
try:
return self.trie.stat(key)
except KeyError:
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path
)
def checksum(self, path_info):
return self.info(path_info)["sha"]
def walk_files(self, path_info, **kwargs):
for root, _, files in self.walk(path_info, **kwargs):
for file in files:
# NOTE: os.path.join is ~5.5 times slower
yield f"{root}{os.sep}{file}"
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
import shutil
total = self.getsize(from_info)
if total:
callback.set_size(total)
with self.open(from_info, "rb", **kwargs) as from_fobj:
with open(to_file, "wb+") as to_fobj:
wrapped = CallbackIOWrapper(
callback.relative_update, from_fobj
)
shutil.copyfileobj(wrapped, to_fobj)
+4 -4
View File
@@ -6,11 +6,11 @@ from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from .fsspec_wrapper import ObjectFSWrapper
# pylint:disable=abstract-method
class GSFileSystem(ObjectFSWrapper):
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
class GSFileSystem(CallbackMixin, ObjectFSWrapper):
scheme = Schemes.GS
PATH_CLS = CloudURLInfo
REQUIRES = {"gcsfs": "gcsfs"}
+64 -245
View File
@@ -1,271 +1,90 @@
import io
import logging
import os
import re
import shutil
import subprocess
from collections import deque
from contextlib import closing, contextmanager
import threading
from funcy import cached_property, wrap_prop
from dvc.hash_info import HashInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from dvc.utils import fix_env, tmp_fname
from dvc.utils import fix_env
from .base import BaseFileSystem, RemoteCmdError
from .pool import get_connection
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
logger = logging.getLogger(__name__)
CHECKSUM_REGEX = re.compile(r".*\t.*\t(?P<checksum>.*)")
def _hadoop_fs(cmd, user=None):
cmd = "hadoop fs -" + cmd
if user:
cmd = f"HADOOP_USER_NAME={user} " + cmd
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
# See https://github.com/iterative/dvc/issues/1197.
close_fds = os.name != "nt"
executable = os.getenv("SHELL") if os.name != "nt" else None
p = subprocess.Popen(
cmd,
shell=True,
close_fds=close_fds,
executable=executable,
env=fix_env(os.environ),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = p.communicate()
if p.returncode != 0:
raise RemoteCmdError("hdfs", cmd, p.returncode, err)
return out.decode("utf-8")
def _group(regex, s, gname):
match = re.match(regex, s)
assert match is not None
return match.group(gname)
def _hadoop_fs_checksum(path_info):
# NOTE: pyarrow doesn't support checksum, so we need to use hadoop
regex = r".*\t.*\t(?P<checksum>.*)"
stdout = _hadoop_fs(f"checksum {path_info.url}", user=path_info.user)
return _group(regex, stdout, "checksum")
class HDFSFileSystem(BaseFileSystem):
# pylint: disable=abstract-method
class HDFSFileSystem(CallbackMixin, FSSpecWrapper):
scheme = Schemes.HDFS
REQUIRES = {"pyarrow": "pyarrow"}
REGEX = r"^hdfs://((?P<user>.*)@)?.*$"
REQUIRES = {"fsspec": "fsspec", "pyarrow": "pyarrow"}
PARAM_CHECKSUM = "checksum"
TRAVERSE_PREFIX_LEN = 2
def __init__(self, **config):
super().__init__(**config)
self.host = config["host"]
self.user = config.get("user")
self.port = config.get("port")
self.kerb_ticket = config.get("kerb_ticket")
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return path.path
return super()._with_bucket(path)
@staticmethod
def _get_kwargs_from_urls(urlpath):
from fsspec.implementations.hdfs import PyArrowHDFS
from fsspec.implementations.arrow import HadoopFileSystem
# pylint:disable=protected-access
return PyArrowHDFS._get_kwargs_from_urls(urlpath)
return HadoopFileSystem._get_kwargs_from_urls(urlpath)
def hdfs(self, path_info):
import pyarrow.fs
def _prepare_credentials(self, **config):
return config
# NOTE: HadoopFileSystem is not meant to be closed by us and doesn't
# have close() method or alternative, so we add a noop one to satisfy
# our connection pool.
class HDFSConnection(pyarrow.fs.HadoopFileSystem):
def close(self):
pass
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from fsspec.implementations.arrow import HadoopFileSystem
return get_connection(
HDFSConnection,
path_info.host,
path_info.port,
user=path_info.user,
kerb_ticket=self.kerb_ticket,
)
@contextmanager
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb"}
try:
with self.hdfs(path_info) as hdfs, closing(
hdfs.open_input_stream(path_info.path)
) as fd:
if mode == "rb":
yield fd
else:
yield io.TextIOWrapper(fd, encoding=encoding)
except OSError as e:
# Empty .errno and not specific enough error class in pyarrow,
# see https://issues.apache.org/jira/browse/ARROW-6248
if "file does not exist" in str(e):
raise FileNotFoundError(*e.args)
raise
def exists(self, path_info) -> bool:
assert not isinstance(path_info, list)
assert path_info.scheme == "hdfs"
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
return file_info.type != pyarrow.fs.FileType.NotFound
def _walk(self, hdfs, root, topdown=True):
import posixpath
from pyarrow.fs import FileSelector, FileType
dirs = deque()
nondirs = deque()
selector = FileSelector(root)
for entry in hdfs.get_file_info(selector):
if entry.type == FileType.Directory:
dirs.append(entry.base_name)
else:
nondirs.append(entry.base_name)
if topdown:
yield root, dirs, nondirs
for dname in dirs:
# NOTE: posixpath.join() is slower
yield from self._walk(
hdfs, f"{root}{posixpath.sep}{dname}", topdown=topdown
)
if not topdown:
yield root, dirs, nondirs
def walk(self, top, topdown=True, onerror=None, **kwargs):
if not self.isdir(top):
return
with self.hdfs(top) as hdfs:
for root, dnames, fnames in self._walk(
hdfs, top.path, topdown=topdown
):
yield top.replace(path=root), dnames, fnames
def walk_files(self, path_info, **kwargs):
for root, _, fnames in self.walk(path_info):
yield from (root / fname for fname in fnames)
def remove(self, path_info):
if path_info.scheme != "hdfs":
raise NotImplementedError
if self.exists(path_info):
logger.debug(f"Removing {path_info.path}")
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
if file_info.type == pyarrow.fs.FileType.Directory:
hdfs.delete_dir(path_info.path)
else:
hdfs.delete_file(path_info.path)
def makedirs(self, path_info):
with self.hdfs(path_info) as hdfs:
# NOTE: fs.create_dir creates parents by default
hdfs.create_dir(path_info.path)
def copy(self, from_info, to_info, **_kwargs):
# NOTE: hdfs.copy_file is not supported yet in pyarrow
with self.hdfs(to_info) as hdfs:
# NOTE: this is how `hadoop fs -cp` works too: it copies through
# your local machine.
with closing(hdfs.open_input_stream(from_info.path)) as from_fobj:
tmp_info = to_info.parent / tmp_fname(to_info.name)
try:
with closing(
hdfs.open_output_stream(tmp_info.path)
) as tmp_fobj:
shutil.copyfileobj(from_fobj, tmp_fobj)
hdfs.move(tmp_info.path, to_info.path)
except Exception:
self.remove(tmp_info)
raise
def isfile(self, path_info):
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
return file_info.type == pyarrow.fs.FileType.File
def isdir(self, path_info):
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
return file_info.type == pyarrow.fs.FileType.Directory
def info(self, path_info):
with self.hdfs(path_info) as hdfs:
finfo = hdfs.get_file_info(path_info.path)
return {"size": finfo.size}
return HadoopFileSystem(**self.fs_args)
def checksum(self, path_info):
return HashInfo(
"checksum",
_hadoop_fs_checksum(path_info),
size=self.getsize(path_info),
return self._checksum(path_info)
def _checksum(self, path_info, **kwargs):
# PyArrow doesn't natively support retrieving the
# checksum, so we have to use hadoop fs
result = self._run_command(
f"checksum {path_info.url}",
env=fix_env(os.environ),
user=path_info.user,
)
if result is None:
return None
def _upload_fobj(self, fobj, to_info, **kwargs):
with self.hdfs(to_info) as hdfs:
with hdfs.open_output_stream(to_info.path) as fdest:
shutil.copyfileobj(fobj, fdest)
match = CHECKSUM_REGEX.match(result)
if match is None:
return None
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
with self.hdfs(to_info) as hdfs:
tmp_file = tmp_fname(to_info.path)
total = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
with Tqdm.wrapattr(
fobj,
"read",
desc=name,
total=total,
disable=no_progress_bar,
) as wrapped:
with hdfs.open_output_stream(tmp_file) as sobj:
sobj.write(wrapped.read())
hdfs.move(tmp_file, to_info.path)
return match.group("checksum")
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
with self.hdfs(from_info) as hdfs:
file_info = hdfs.get_file_info(from_info.path)
total = file_info.size
with open(to_file, "wb+") as fobj:
with Tqdm.wrapattr(
fobj,
"write",
desc=name,
total=total,
disable=no_progress_bar,
) as wrapped:
with hdfs.open_input_stream(from_info.path) as sobj:
wrapped.write(sobj.read())
def _run_command(self, cmd, env=None, user=None):
cmd = "hadoop fs -" + cmd
if user:
cmd = f"HADOOP_USER_NAME={user} " + cmd
# NOTE: close_fds doesn't work with redirected stdin/stdout/stderr.
# See https://github.com/iterative/dvc/issues/1197.
close_fds = os.name != "nt"
executable = os.getenv("SHELL") if os.name != "nt" else None
p = subprocess.Popen(
cmd,
shell=True,
close_fds=close_fds,
executable=executable,
env=env or os.environ,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = p.communicate()
if p.returncode != 0:
raise subprocess.CalledProcessError(p.returncode, cmd, out, err)
else:
return out.decode("utf-8")
+91 -154
View File
@@ -1,20 +1,12 @@
import logging
import os.path
import threading
from typing import Optional
from urllib.parse import urlparse
from funcy import cached_property, memoize, wrap_prop, wrap_with
from funcy import cached_property, memoize, wrap_with
from dvc import prompt
from dvc.exceptions import DvcException, HTTPError
from dvc.path_info import HTTPURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
from .fsspec_wrapper import FSSpecWrapper, NoDirectoriesMixin
@wrap_with(threading.Lock())
@@ -26,170 +18,115 @@ def ask_password(host, user):
)
class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
def make_context(ssl_verify):
if isinstance(ssl_verify, bool) or ssl_verify is None:
return ssl_verify
# If this is a path, then we will create an
# SSL context for it, and load the given certificate.
import ssl
context = ssl.create_default_context()
context.load_verify_locations(ssl_verify)
return context
# pylint: disable=abstract-method
class HTTPFileSystem(NoDirectoriesMixin, FSSpecWrapper):
scheme = Schemes.HTTP
PATH_CLS = HTTPURLInfo
PARAM_CHECKSUM = "etag"
PARAM_CHECKSUM = "checksum"
REQUIRES = {"aiohttp": "aiohttp", "aiohttp-retry": "aiohttp_retry"}
CAN_TRAVERSE = False
SESSION_RETRIES = 5
SESSION_BACKOFF_FACTOR = 0.1
REQUEST_TIMEOUT = 60
CHUNK_SIZE = 2 ** 16
def __init__(self, **config):
super().__init__(**config)
def _prepare_credentials(self, **config):
import aiohttp
from fsspec.asyn import fsspec_loop
self.user = config.get("user", None)
from dvc.config import ConfigError
self.auth = config.get("auth", None)
self.custom_auth_header = config.get("custom_auth_header", None)
self.password = config.get("password", None)
self.ask_password = config.get("ask_password", False)
self.headers = {}
self.ssl_verify = config.get("ssl_verify", True)
self.method = config.get("method", "POST")
credentials = {}
client_kwargs = credentials.setdefault("client_kwargs", {})
def _auth_method(self, url):
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
if config.get("auth"):
user = config.get("user")
password = config.get("password")
custom_auth_header = config.get("custom_auth_header")
if self.auth:
if self.ask_password and self.password is None:
self.password = ask_password(urlparse(url).hostname, self.user)
if self.auth == "basic":
return HTTPBasicAuth(self.user, self.password)
if self.auth == "digest":
return HTTPDigestAuth(self.user, self.password)
if self.auth == "custom" and self.custom_auth_header:
self.headers.update({self.custom_auth_header: self.password})
return None
if password is None and config.get("ask_password"):
password = ask_password(config.get("url"), user or "custom")
def _generate_download_url(self, path_info):
return path_info.url
auth_method = config["auth"]
if auth_method == "basic":
if user is None or password is None:
raise ConfigError(
"HTTP 'basic' authentication require both "
"'user' and 'password'"
)
@wrap_prop(threading.Lock())
@cached_property
def _session(self):
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
client_kwargs["auth"] = aiohttp.BasicAuth(user, password)
elif auth_method == "custom":
if custom_auth_header is None or password is None:
raise ConfigError(
"HTTP 'custom' authentication require both "
"'custom_auth_header' and 'password'"
)
credentials["headers"] = {custom_auth_header: password}
else:
raise NotImplementedError(
f"Auth method {auth_method!r} is not supported."
)
session = requests.Session()
if "ssl_verify" in config:
with fsspec_loop():
client_kwargs["connector"] = aiohttp.TCPConnector(
ssl=make_context(config["ssl_verify"])
)
session.verify = self.ssl_verify
# Allow reading proxy configurations from the environment.
client_kwargs["trust_env"] = True
retries = Retry(
total=self.SESSION_RETRIES,
backoff_factor=self.SESSION_BACKOFF_FACTOR,
credentials["get_client"] = self.get_client
self.upload_method = config.get("method", "POST")
return credentials
async def get_client(self, **kwargs):
import aiohttp
from aiohttp_retry import ExponentialRetry, RetryClient
kwargs["retry_options"] = ExponentialRetry(
attempts=self.SESSION_RETRIES,
factor=self.SESSION_BACKOFF_FACTOR,
max_timeout=self.REQUEST_TIMEOUT,
exceptions=[aiohttp.ClientError],
)
session.mount("http://", HTTPAdapter(max_retries=retries))
session.mount("https://", HTTPAdapter(max_retries=retries))
# The default timeout for the aiohttp is 300 seconds
# which is too low for DVC's interactions (especially
# on the read) when dealing with large data blobs. We
# unlimit the total time to read, and only limit the
# time that is spent when connecting to the remote server.
kwargs["timeout"] = aiohttp.ClientTimeout(
total=None,
connect=self.REQUEST_TIMEOUT,
sock_connect=self.REQUEST_TIMEOUT,
sock_read=None,
)
return session
return RetryClient(**kwargs)
def request(self, method, url, **kwargs):
import requests
@cached_property
def fs(self):
from fsspec.implementations.http import (
HTTPFileSystem as _HTTPFileSystem,
)
kwargs.setdefault("allow_redirects", True)
kwargs.setdefault("timeout", self.REQUEST_TIMEOUT)
return _HTTPFileSystem(**self.fs_args)
try:
res = self._session.request(
method,
url,
auth=self._auth_method(url),
headers=self.headers,
**kwargs,
)
redirect_no_location = (
kwargs["allow_redirects"]
and res.status_code in (301, 302)
and "location" not in res.headers
)
if redirect_no_location:
# AWS s3 doesn't like to add a location header to its redirects
# from https://s3.amazonaws.com/<bucket name>/* type URLs.
# This should be treated as an error
raise requests.exceptions.RequestException
return res
except requests.exceptions.RequestException:
raise DvcException(f"could not perform a {method} request")
def _head(self, url):
response = self.request("HEAD", url)
if response.ok:
return response
# Sometimes servers are configured to forbid HEAD requests
# Context: https://github.com/iterative/dvc/issues/4131
with self.request("GET", url, stream=True) as r:
if r.ok:
return r
return response
def exists(self, path_info) -> bool:
res = self._head(path_info.url)
if res.status_code == 404:
return False
if bool(res):
return True
raise HTTPError(res.status_code, res.reason)
def info(self, path_info):
resp = self._head(path_info.url)
etag = resp.headers.get("ETag") or resp.headers.get("Content-MD5")
size = self._content_length(resp)
return {"etag": etag, "size": size}
def _upload_fobj(self, fobj, to_info, **kwargs):
def chunks(fobj):
while True:
chunk = fobj.read(self.CHUNK_SIZE)
if not chunk:
break
yield chunk
response = self.request(self.method, to_info.url, data=chunks(fobj))
if response.status_code not in (200, 201):
raise HTTPError(response.status_code, response.reason)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
response = self.request("GET", from_info.url, stream=True)
if response.status_code != 200:
raise HTTPError(response.status_code, response.reason)
with open(to_file, "wb") as fd:
with Tqdm.wrapattr(
fd,
"write",
total=None
if no_progress_bar
else self._content_length(response),
leave=False,
desc=from_info.url if name is None else name,
disable=no_progress_bar,
) as fd_wrapped:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
fd_wrapped.write(chunk)
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=None if no_progress_bar else os.path.getsize(from_file),
no_progress_bar=no_progress_bar,
desc=name or to_info.url,
)
@staticmethod
def _content_length(response) -> Optional[int]:
res = response.headers.get("Content-Length")
return int(res) if res else None
def _entry_hook(self, entry):
entry["checksum"] = entry.get("ETag") or entry.get("Content-MD5")
return entry
+24 -29
View File
@@ -1,6 +1,5 @@
import logging
import os
import stat
from dvc.path_info import PathInfo
from dvc.scheme import Schemes
@@ -8,12 +7,15 @@ from dvc.system import System
from dvc.utils import is_exec, tmp_fname
from dvc.utils.fs import copy_fobj_to_file, copyfile, makedirs, move, remove
from ..progress import DEFAULT_CALLBACK
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
class LocalFileSystem(BaseFileSystem):
sep = os.sep
scheme = Schemes.LOCAL
PATH_CLS = PathInfo
PARAM_CHECKSUM = "md5"
@@ -32,7 +34,12 @@ class LocalFileSystem(BaseFileSystem):
def exists(self, path_info) -> bool:
assert isinstance(path_info, str) or path_info.scheme == "local"
return self.fs.exists(path_info)
# TODO: replace this with os.path.exists once the problem is fixed on
# the fsspec https://github.com/intake/filesystem_spec/issues/742
return os.path.lexists(path_info)
def checksum(self, path_info) -> str:
return self.fs.checksum(path_info)
def isfile(self, path_info) -> bool:
return os.path.isfile(path_info)
@@ -77,18 +84,17 @@ 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
mode = self.info(path_info)["mode"]
return is_exec(mode)
def stat(self, path):
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)
@@ -103,7 +109,7 @@ class LocalFileSystem(BaseFileSystem):
self.remove(tmp_info)
raise
def _upload_fobj(self, fobj, to_info, **kwargs):
def upload_fobj(self, fobj, to_info, **kwargs):
self.makedirs(to_info.parent)
tmp_info = to_info.parent / tmp_fname("")
try:
@@ -150,29 +156,18 @@ class LocalFileSystem(BaseFileSystem):
def reflink(self, from_info, to_info):
System.reflink(from_info, to_info)
@staticmethod
def info(path_info):
st = os.stat(path_info)
return {
"size": st.st_size,
"type": "dir" if stat.S_ISDIR(st.st_mode) else "file",
}
def info(self, path_info):
return self.fs.info(path_info)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
def put_file(
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
):
makedirs(to_info.parent, exist_ok=True)
tmp_file = tmp_fname(to_info)
copyfile(
from_file, tmp_file, name=name, no_progress_bar=no_progress_bar
)
copyfile(from_file, tmp_file, callback=callback)
os.replace(tmp_file, to_info)
@staticmethod
def _download(
from_info, to_file, name=None, no_progress_bar=False, **_kwargs
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
copyfile(
from_info, to_file, no_progress_bar=no_progress_bar, name=name
)
copyfile(from_info, to_file, callback=callback)
+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 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(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)
+122 -37
View File
@@ -1,6 +1,5 @@
import logging
import os
import stat
import threading
from contextlib import suppress
from itertools import takewhile
@@ -10,6 +9,7 @@ from funcy import lfilter, wrap_with
from dvc.path_info import PathInfo
from ..progress import DEFAULT_CALLBACK
from .base import BaseFileSystem
from .dvc import DvcFileSystem
@@ -31,16 +31,33 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
"""
sep = os.sep
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
@@ -70,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.
@@ -134,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
@@ -165,21 +243,21 @@ 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):
return False
try:
st = fs.stat(path)
return stat.S_ISDIR(st.st_mode)
info = fs.info(path)
return info["type"] == "directory"
except (OSError, ValueError):
# from CPython's os.path.isdir()
pass
@@ -192,25 +270,25 @@ 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):
return False
try:
st = fs.stat(path)
return stat.S_ISREG(st.st_mode)
info = fs.info(path)
return info["type"] == "file"
except (OSError, ValueError):
# from CPython's os.path.isfile()
pass
@@ -235,10 +313,6 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
return dvc_fs.isexec(path_info)
return fs.isexec(path_info)
def stat(self, path):
fs, _ = self._get_fs_pair(path)
return fs.stat(path)
def _dvc_walk(self, walk):
try:
root, dirs, files = next(walk)
@@ -375,19 +449,22 @@ 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
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
import shutil
fs, dvc_fs = self._get_fs_pair(from_info)
try:
fs.get_file( # pylint: disable=protected-access
from_info, to_file, callback=callback, **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.get_file( # pylint: disable=protected-access
from_info, to_file, callback=callback, **kwargs
)
def metadata(self, path):
abspath = os.path.abspath(path)
@@ -399,11 +476,11 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
with suppress(FileNotFoundError):
dvc_meta = dvc_fs.metadata(path_info)
stat_result = None
info_result = None
with suppress(FileNotFoundError):
stat_result = fs.stat(path_info)
info_result = fs.info(path_info)
if not stat_result and not dvc_meta:
if not info_result and not dvc_meta:
raise FileNotFoundError
from ._metadata import Metadata
@@ -413,13 +490,13 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
repo=self._get_repo(abspath) or self._main_repo,
)
isdir = bool(stat_result) and stat.S_ISDIR(stat_result.st_mode)
isdir = bool(info_result) and info_result["type"] == "directory"
meta.isdir = meta.isdir or isdir
if not dvc_meta:
from dvc.utils import is_exec
meta.is_exec = bool(stat_result) and is_exec(stat_result.st_mode)
meta.is_exec = bool(info_result) and is_exec(info_result["mode"])
return meta
def info(self, path_info):
@@ -429,3 +506,11 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
return fs.info(path_info)
except FileNotFoundError:
return dvc_fs.info(path_info)
def checksum(self, path_info):
fs, dvc_fs = self._get_fs_pair(path_info)
try:
return fs.checksum(path_info)
except FileNotFoundError:
return dvc_fs.checksum(path_info)
+31 -30
View File
@@ -6,7 +6,7 @@ from collections import defaultdict
from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.progress import DEFAULT_CALLBACK
from dvc.scheme import Schemes
from .fsspec_wrapper import ObjectFSWrapper
@@ -68,12 +68,12 @@ class BaseS3FileSystem(ObjectFSWrapper):
self._transfer_config = None
config_path = os.environ.get("AWS_CONFIG_FILE", _AWS_CONFIG_PATH)
if not os.path.exists(config_path):
return None
return {}
config = load_config(config_path)
profile_config = config["profiles"].get(profile or "default")
if not profile_config:
return None
return {}
s3_config = profile_config.get("s3", {})
return self._split_s3_config(s3_config)
@@ -99,6 +99,11 @@ class BaseS3FileSystem(ObjectFSWrapper):
client["endpoint_url"] = config.get("endpointurl")
client["verify"] = config.get("ssl_verify")
# timeout configuration
config_kwargs = login_info["config_kwargs"]
config_kwargs["read_timeout"] = config.get("read_timeout")
config_kwargs["connect_timeout"] = config.get("connect_timeout")
# encryptions
additional = login_info["s3_additional_kwargs"]
additional["ServerSideEncryption"] = config.get("sse")
@@ -123,6 +128,14 @@ class BaseS3FileSystem(ObjectFSWrapper):
if shared_creds:
os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", shared_creds)
if (
client["region_name"] is None
and session_config["s3"].get("region_name") is None
and os.getenv("AWS_REGION") is None
):
# Enable bucket region caching
login_info["cache_regions"] = config.get("cache_regions", True)
config_path = config.get("configpath")
if config_path:
os.environ.setdefault("AWS_CONFIG_FILE", config_path)
@@ -188,6 +201,7 @@ class S3FileSystem(BaseS3FileSystem): # pylint:disable=abstract-method
"s3",
endpoint_url=client_kwargs.get("endpoint_url"),
use_ssl=login_info["use_ssl"],
verify=client_kwargs.get("verify"),
)
def _get_obj(self, path_info):
@@ -195,36 +209,23 @@ class S3FileSystem(BaseS3FileSystem): # pylint:disable=abstract-method
return bucket.Object(path_info.path)
@_translate_exceptions
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **pbar_args
def put_file(
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
):
total = os.path.getsize(from_file)
with Tqdm(
disable=no_progress_bar,
total=total,
bytes=True,
desc=name,
**pbar_args,
) as pbar:
obj = self._get_obj(to_info)
obj.upload_file(
from_file,
Callback=pbar.update,
ExtraArgs=self.fs_args.get("s3_additional_kwargs"),
Config=self._transfer_config,
)
callback.set_size(os.path.getsize(from_file))
obj = self._get_obj(to_info)
obj.upload_file(
from_file,
Callback=callback.relative_update,
ExtraArgs=self.fs_args.get("s3_additional_kwargs"),
Config=self._transfer_config,
)
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
@_translate_exceptions
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **pbar_args
def get_file(
self, from_info, to_info, callback=DEFAULT_CALLBACK, **kwargs
):
obj = self._get_obj(from_info)
with Tqdm(
disable=no_progress_bar,
total=obj.content_length,
bytes=True,
desc=name,
**pbar_args,
) as pbar:
obj.download_file(to_file, Callback=pbar.update)
callback.set_size(obj.content_length)
obj.download_file(to_info, Callback=callback.relative_update)
+130
View File
@@ -0,0 +1,130 @@
import getpass
import os.path
import threading
from funcy import cached_property, memoize, silent, wrap_prop, wrap_with
from dvc import prompt
from dvc.scheme import Schemes
from dvc.utils.fs import as_atomic
from ..progress import DEFAULT_CALLBACK
from .fsspec_wrapper import 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(FSSpecWrapper):
scheme = Schemes.SSH
REQUIRES = {"sshfs": "sshfs"}
DEFAULT_PORT = 22
PARAM_CHECKSUM = "md5"
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return path.path
return super()._with_bucket(path)
def _prepare_credentials(self, **config):
self.CAN_TRAVERSE = True
from sshfs.config import parse_config
login_info = {}
try:
user_ssh_config = parse_config(host=config["host"])
except FileNotFoundError:
user_ssh_config = {}
login_info["host"] = user_ssh_config.get("Hostname", config["host"])
login_info["username"] = (
config.get("user")
or config.get("username")
or user_ssh_config.get("User")
or getpass.getuser()
)
login_info["port"] = (
config.get("port")
or silent(int)(user_ssh_config.get("Port"))
or self.DEFAULT_PORT
)
if config.get("ask_password") and config.get("password") is None:
config["password"] = ask_password(
login_info["host"], login_info["username"], login_info["port"]
)
login_info["password"] = config.get("password")
login_info["passphrase"] = config.get("password")
raw_keys = []
if config.get("keyfile"):
raw_keys.append(config.get("keyfile"))
elif user_ssh_config.get("IdentityFile"):
raw_keys.extend(user_ssh_config.get("IdentityFile"))
if raw_keys:
login_info["client_keys"] = [
os.path.expanduser(key) for key in raw_keys
]
login_info["timeout"] = config.get("timeout", _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")
# We are going to automatically add stuff to known_hosts
# something like paramiko's AutoAddPolicy()
login_info["known_hosts"] = None
return login_info
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from sshfs import SSHFileSystem as _SSHFileSystem
return _SSHFileSystem(**self.fs_args)
# Ensure that if an interrupt happens during the transfer, we don't
# pollute the cache.
def upload_fobj(self, fobj, to_info, **kwargs):
with as_atomic(self, to_info) as tmp_file:
super().upload_fobj(fobj, tmp_file, **kwargs)
def put_file(
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
):
with as_atomic(self, to_info) as tmp_file:
super().put_file(from_file, tmp_file, callback=callback, **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, path_info):
self.ensure_credentials()
from .connection import SSHConnection
return get_connection(
SSHConnection,
path_info.host,
username=path_info.user,
port=path_info.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(path_info) 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(path_info) as ssh:
return ssh.exists(path_info.path)
def isdir(self, path_info):
with self.ssh(path_info) as ssh:
return ssh.isdir(path_info.path)
def isfile(self, path_info):
with self.ssh(path_info) as ssh:
return ssh.isfile(path_info.path)
def walk_files(self, path_info, **kwargs):
with self.ssh(path_info) 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(path_info) as ssh:
ssh.remove(path_info.path)
def makedirs(self, path_info):
with self.ssh(path_info) 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(from_info) 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(from_info) 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(from_info) 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(to_info) 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(from_info) 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(from_info) as ssh:
ssh.reflink(from_info.path, to_info.path)
def md5(self, path_info):
with self.ssh(path_info) as ssh:
return HashInfo(
"md5",
ssh.md5(path_info.path),
size=ssh.getsize(path_info.path),
)
def info(self, path_info):
with self.ssh(path_info) 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(from_info) 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(to_info) 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": "dir" 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}")
+56
View File
@@ -0,0 +1,56 @@
import logging
import os
from typing import TYPE_CHECKING
from .local import LocalFileSystem
if TYPE_CHECKING:
from dvc.types import AnyPath, DvcPath
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
def transfer(
from_fs: "BaseFileSystem",
from_info: "AnyPath",
to_fs: "BaseFileSystem",
to_info: "DvcPath",
move: bool = False,
) -> None:
use_move = isinstance(from_fs, type(to_fs)) and move
try:
if use_move:
return to_fs.move(from_info, to_info)
if isinstance(from_fs, LocalFileSystem):
if not isinstance(from_info, from_fs.PATH_CLS):
from_info = from_fs.PATH_CLS(from_info)
return to_fs.upload(from_info, to_info)
if isinstance(to_fs, LocalFileSystem):
return from_fs.download_file(from_info, to_info)
with from_fs.open(from_info, mode="rb") as fobj:
size = from_fs.getsize(from_info)
return to_fs.upload(fobj, to_info, total=size)
except OSError as exc:
# If the target file already exists, we are going to simply
# ignore the exception (#4992).
#
# On Windows, it is not always guaranteed that you'll get
# FileExistsError (it might be PermissionError or a bare OSError)
# but all of those exceptions raised from the original
# FileExistsError so we have a separate check for that.
if isinstance(exc, FileExistsError) or (
os.name == "nt"
and exc.__context__
and isinstance(exc.__context__, FileExistsError)
):
logger.debug("'%s' file already exists, skipping", to_info)
if use_move:
from_fs.remove(from_info)
return None
raise
+4 -14
View File
@@ -1,5 +1,4 @@
import logging
import os
import threading
from functools import lru_cache
@@ -71,19 +70,13 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
return WebdavFileSystem(**self.fs_args)
def _upload_fobj(self, fobj, to_info, size: int = None):
def upload_fobj(self, fobj, to_info, **kwargs):
rpath = self.translate_path_info(to_info)
self.makedirs(os.path.dirname(rpath))
size = kwargs.get("size")
# using upload_fileobj to directly upload fileobj rather than buffering
# and using overwrite=True to avoid check for an extra exists call,
# as caller should ensure that the file does not exist beforehand.
return self.fs.client.upload_fileobj(
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)
return self.fs.upload_fileobj(fobj, rpath, overwrite=True, size=size)
@lru_cache(512)
def translate_path_info(self, path):
@@ -91,10 +84,7 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
return path.path[len(self.prefix) :].lstrip("/")
return path
_with_bucket = translate_path_info
def _strip_bucket(self, entry):
return entry
_with_bucket = translate_path_info # type: ignore
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
+20 -142
View File
@@ -1,51 +1,24 @@
import logging
import os
import posixpath
import shutil
import threading
from contextlib import contextmanager
from funcy import cached_property, wrap_prop
from dvc.hash_info import HashInfo
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
# pylint:disable=abstract-method
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
def update_pbar(pbar, total):
"""Update pbar to accept the two arguments passed by hdfs"""
def update(_, bytes_transfered):
if bytes_transfered == -1:
pbar.update_to(total)
return
pbar.update_to(bytes_transfered)
return update
class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
class WebHDFSFileSystem(CallbackMixin, FSSpecWrapper):
scheme = Schemes.WEBHDFS
PATH_CLS = CloudURLInfo
REQUIRES = {"hdfs": "hdfs"}
REQUIRES = {"fsspec": "fsspec"}
PARAM_CHECKSUM = "checksum"
TRAVERSE_PREFIX_LEN = 2
def __init__(self, **config):
super().__init__(**config)
self.host = config["host"]
self.user = config.get("user")
self.port = config.get("port")
self.hdfscli_config = config.get("hdfscli_config")
self.token = config.get("webhdfs_token")
self.alias = config.get("webhdfs_alias")
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return f"/{path.path.rstrip('/')}"
return path
@staticmethod
def _get_kwargs_from_urls(urlpath):
@@ -57,115 +30,20 @@ class WebHDFSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
)
)
def _prepare_credentials(self, **config):
if "webhdfs_token" in config:
config["token"] = config.pop("webhdfs_token")
return config
@wrap_prop(threading.Lock())
@cached_property
def hdfs_client(self):
import hdfs
def fs(self):
from fsspec.implementations.webhdfs import WebHDFS
logger.debug("HDFSConfig: %s", self.hdfscli_config)
try:
return hdfs.config.Config(self.hdfscli_config).get_client(
self.alias
)
except hdfs.util.HdfsError as exc:
exc_msg = str(exc)
errors = (
"No alias specified",
"Invalid configuration file",
f"Alias {self.alias} not found",
)
if not any(err in exc_msg for err in errors):
raise
http_url = f"http://{self.host}:{self.port}"
logger.debug("URL: %s", http_url)
if self.token is not None:
client = hdfs.TokenClient(http_url, token=self.token, root="/")
else:
client = hdfs.InsecureClient(
http_url, user=self.user, root="/"
)
return client
@contextmanager
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb"}
with self.hdfs_client.read(
path_info.path, encoding=encoding
) as reader:
yield reader
def walk_files(self, path_info, **kwargs):
if not self.exists(path_info):
return
root = path_info.path
for path, _, fnames in self.hdfs_client.walk(root):
for fname in fnames:
yield path_info.replace(path=posixpath.join(path, fname))
def remove(self, path_info):
if path_info.scheme != self.scheme:
raise NotImplementedError
self.hdfs_client.delete(path_info.path)
def exists(self, path_info) -> bool:
assert not isinstance(path_info, list)
assert path_info.scheme == "webhdfs"
status = self.hdfs_client.status(path_info.path, strict=False)
return status is not None
def info(self, path_info):
st = self.hdfs_client.status(path_info.path)
return {"size": st["length"]}
return WebHDFS(**self.fs_args)
def checksum(self, path_info):
return HashInfo(
"checksum",
self.hdfs_client.checksum(path_info.path)["bytes"],
size=self.hdfs_client.status(path_info.path)["length"],
)
def copy(self, from_info, to_info, **_kwargs):
with self.hdfs_client.read(from_info.path) as reader:
with self.hdfs_client.write(to_info.path) as writer:
shutil.copyfileobj(reader, writer)
def move(self, from_info, to_info):
self.hdfs_client.makedirs(to_info.parent.path)
self.hdfs_client.rename(from_info.path, to_info.path)
def _upload_fobj(self, fobj, to_info, **kwargs):
with self.hdfs_client.write(to_info.path) as fdest:
shutil.copyfileobj(fobj, fdest)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
total = os.path.getsize(from_file)
with Tqdm(
desc=name, total=total, disable=no_progress_bar, bytes=True
) as pbar:
self.hdfs_client.upload(
to_info.path,
from_file,
overwrite=True,
progress=update_pbar(pbar, total),
)
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
total = self.getsize(from_info)
with Tqdm(
desc=name, total=total, disable=no_progress_bar, bytes=True
) as pbar:
self.hdfs_client.download(
from_info.path, to_file, progress=update_pbar(pbar, total)
)
path = self._with_bucket(path_info)
ukey = self.fs.ukey(path)
return ukey["bytes"]
+4 -16
View File
@@ -7,13 +7,9 @@ HASH_DIR_SUFFIX = ".dir"
@dataclass
class HashInfo:
PARAM_SIZE = "size"
PARAM_NFILES = "nfiles"
name: Optional[str]
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)
@@ -26,15 +22,11 @@ class HashInfo:
@classmethod
def from_dict(cls, d):
_d = d.copy() if d else {}
size = _d.pop(cls.PARAM_SIZE, None)
nfiles = _d.pop(cls.PARAM_NFILES, None)
if not _d:
if not d:
return cls(None, None)
((name, value),) = _d.items()
return cls(name, value, size=size, nfiles=nfiles)
((name, value),) = d.items()
return cls(name, value)
def to_dict(self):
ret = OrderedDict()
@@ -42,10 +34,6 @@ class HashInfo:
return ret
ret[self.name] = self.value
if self.size is not None:
ret[self.PARAM_SIZE] = self.size
if self.nfiles is not None:
ret[self.PARAM_NFILES] = self.nfiles
return ret
@property
+4 -5
View File
@@ -11,7 +11,6 @@ from dvc.fs.base import BaseFileSystem
from dvc.path_info import PathInfo
from dvc.pathspec_math import PatternInfo, merge_patterns
from dvc.scheme import Schemes
from dvc.system import System
from dvc.types import AnyPath, List, Optional
from dvc.utils import relpath
from dvc.utils.collections import PathStringTrie
@@ -57,7 +56,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())
)
@@ -84,7 +83,7 @@ class DvcIgnorePatterns(DvcIgnore):
else:
return False
if not System.is_unix():
if os.name == "nt":
path = normalize_file(path)
return path
@@ -166,7 +165,7 @@ class DvcIgnoreFilter:
".hg/",
".git/",
".git",
"{}/".format(Repo.DVC_DIR),
f"{Repo.DVC_DIR}/",
]
self.fs = fs
@@ -394,7 +393,7 @@ def init(path):
if os.path.exists(dvcignore):
return dvcignore
with open(dvcignore, "w") as fobj:
with open(dvcignore, "w", encoding="utf-8") as fobj:
fobj.write(
"# Add patterns of files dvc should ignore, which could improve\n"
"# the performance. Learn more at\n"
+32 -24
View File
@@ -6,6 +6,7 @@ import uuid
import psutil
from dvc import __version__
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.fs import FS_MAP, get_fs_cls, get_fs_config
from dvc.repo import Repo
@@ -13,12 +14,14 @@ from dvc.scm.base import SCMError
from dvc.system import System
from dvc.utils import error_link
from dvc.utils.pkg import PKG
from dvc.version import __version__
if PKG is None:
package = ""
else:
package = f"({PKG})"
try:
import importlib.metadata as importlib_metadata
except ImportError: # < 3.8
import importlib_metadata # type: ignore[no-redef]
package = "" if PKG is None else f"({PKG})"
def get_dvc_info():
@@ -27,7 +30,7 @@ def get_dvc_info():
"---------------------------------",
f"Platform: Python {platform.python_version()} on "
f"{platform.platform()}",
f"Supports: {_get_supported_remotes()}",
f"Supports:{_get_supported_remotes()}",
]
try:
@@ -37,9 +40,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:
@@ -51,7 +52,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:
@@ -90,7 +91,7 @@ def _get_linktype_support_info(repo):
fname = "." + str(uuid.uuid4())
src = os.path.join(repo.odb.local.cache_dir, fname)
open(src, "w").close()
open(src, "w", encoding="utf-8").close()
dst = os.path.join(repo.root_dir, fname)
cache = []
@@ -113,27 +114,34 @@ def _get_linktype_support_info(repo):
def _get_supported_remotes():
supported_remotes = []
for scheme, fs_cls in FS_MAP.items():
if not fs_cls.get_missing_deps():
supported_remotes.append(scheme)
dependencies = []
for requirement in fs_cls.REQUIRES:
dependencies.append(
f"{requirement} = "
f"{importlib_metadata.version(requirement)}"
)
if len(supported_remotes) == len(FS_MAP):
return "All remotes"
remote_info = scheme
if dependencies:
remote_info += " (" + ", ".join(dependencies) + ")"
supported_remotes.append(remote_info)
if len(supported_remotes) == 1:
return supported_remotes
return ", ".join(supported_remotes)
assert len(supported_remotes) >= 1
return "\n\t" + ",\n\t".join(supported_remotes)
def get_fs_type(path):
partition = {
pathlib.Path(part.mountpoint): (part.fstype + " on " + part.device)
for part in psutil.disk_partitions(all=True)
}
partition = {}
for part in psutil.disk_partitions(all=True):
if part.fstype != "":
try:
mountpoint = pathlib.Path(part.mountpoint).resolve()
partition[mountpoint] = part.fstype + " on " + part.device
except PermissionError:
pass
# need to follow the symlink: https://github.com/iterative/dvc/issues/5065
path = pathlib.Path(path).resolve()
+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
+2 -13
View File
@@ -176,25 +176,14 @@ class HardlinkLock(flufl.lock.Lock, LockBase):
except flufl.lock.TimeOutError:
raise LockError(FAILED_TO_LOCK_MESSAGE)
def _set_claimfile(self, pid=None):
super()._set_claimfile(pid)
def _set_claimfile(self):
super()._set_claimfile()
if self._tmp_dir is not None:
# Under Windows file path length is limited so we hash it
filename = hashlib.md5(self._claimfile.encode()).hexdigest()
self._claimfile = os.path.join(self._tmp_dir, filename + ".lock")
# Fix for __del__ bug in flufl.lock [1] which is causing errors on
# Python shutdown [2].
# [1] https://gitlab.com/warsaw/flufl.lock/issues/7
# [2] https://github.com/iterative/dvc/issues/2573
def __del__(self):
try:
if self._owned:
self.finalize()
except ImportError:
pass
def make_lock(lockfile, tmp_dir=None, friendly=False, hardlink_lock=False):
cls = HardlinkLock if hardlink_lock else Lock
+14 -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)
@@ -142,9 +142,22 @@ class LoggerHandler(logging.StreamHandler):
super().handleError(record)
raise LoggingException(record)
def emit_pretty_exception(self, exc, verbose: bool = False):
return exc.__pretty_exc__(verbose=verbose)
def emit(self, record):
"""Write to Tqdm's stream so as to not break progress-bars"""
try:
if record.exc_info:
_, exc, *_ = record.exc_info
if hasattr(exc, "__pretty_exc__"):
try:
self.emit_pretty_exception(exc, verbose=_is_verbose())
if not _is_verbose():
return
except Exception: # noqa, pylint: disable=broad-except
pass
msg = self.format(record)
Tqdm.write(
msg, file=self.stream, end=getattr(self, "terminator", "\n")
+213
View File
@@ -0,0 +1,213 @@
import logging
import os
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Iterator,
Mapping,
Optional,
Tuple,
Type,
)
from dvc.exceptions import DvcException
from dvc.types import StrPath
if TYPE_CHECKING:
from dvc.repo import Repo
from .backend.base import BaseMachineBackend
BackendCls = Type[BaseMachineBackend]
logger = logging.getLogger(__name__)
RESERVED_NAMES = {"local", "localhost"}
DEFAULT_STARTUP_SCRIPT = """#!/bin/bash
sudo apt-get update
sudo apt-get install --yes python3 python3-pip
python3 -m pip install --upgrade pip
pushd /etc/apt/sources.list.d
sudo wget https://dvc.org/deb/dvc.list
sudo apt-get update
sudo apt-get install --yes dvc
popd
"""
def validate_name(name: str):
from dvc.exceptions import InvalidArgumentError
name = name.lower()
if name in RESERVED_NAMES:
raise InvalidArgumentError(
f"Machine name '{name}' is reserved for internal DVC use."
)
class MachineBackends(Mapping):
try:
from .backend.terraform import TerraformBackend
except ImportError:
TerraformBackend = None # type: ignore[assignment, misc]
DEFAULT: Dict[str, Optional["BackendCls"]] = {
"terraform": TerraformBackend,
}
def __getitem__(self, key: str) -> "BaseMachineBackend":
"""Lazily initialize backends and cache it afterwards"""
initialized = self.initialized.get(key)
if not initialized:
backend = self.backends[key]
initialized = backend(
os.path.join(self.tmp_dir, key), **self.kwargs
)
self.initialized[key] = initialized
return initialized
def __init__(
self,
selected: Optional[Iterable[str]],
tmp_dir: StrPath,
**kwargs,
) -> None:
selected = selected or list(self.DEFAULT)
self.backends: Dict[str, "BackendCls"] = {}
for key in selected:
cls = self.DEFAULT.get(key)
if cls is None:
raise DvcException(
f"'dvc machine' backend '{key}' is missing required "
"dependencies. Install them with:\n"
f"\tpip install dvc[{key}]"
)
self.backends[key] = cls
self.initialized: Dict[str, "BaseMachineBackend"] = {}
self.tmp_dir = tmp_dir
self.kwargs = kwargs
def __iter__(self):
return iter(self.backends)
def __len__(self) -> int:
return len(self.backends)
def close_initialized(self) -> None:
for backend in self.initialized.values():
backend.close()
class MachineManager:
"""Class that manages dvc cloud machines.
Args:
repo (dvc.repo.Repo): repo instance that belongs to the repo that
we are working on.
Raises:
config.ConfigError: thrown when config has invalid format.
"""
CLOUD_BACKENDS = {
"aws": "terraform",
"azure": "terraform",
}
def __init__(
self, repo: "Repo", backends: Optional[Iterable[str]] = None, **kwargs
):
self.repo = repo
tmp_dir = os.path.join(self.repo.tmp_dir, "machine")
self.backends = MachineBackends(backends, tmp_dir=tmp_dir, **kwargs)
def get_config_and_backend(
self,
name: Optional[str] = None,
) -> Tuple[dict, "BaseMachineBackend"]:
from dvc.config import NoMachineError
if not name:
name = self.repo.config["core"].get("machine")
if name:
config = self._get_config(name=name)
backend = self._get_backend(config["cloud"])
return config, backend
if bool(self.repo.config["machine"]):
error_msg = (
"no machine specified. Setup default machine with\n"
" dvc machine default <name>\n"
)
else:
error_msg = (
"no machine specified. Create a default machine with\n"
" dvc machine add -d <name> <cloud>"
)
raise NoMachineError(error_msg)
def _get_config(self, **kwargs):
config = self.repo.config
name = kwargs.get("name")
if name:
try:
conf = config["machine"][name.lower()]
conf["name"] = name
except KeyError:
from dvc.config import MachineNotFoundError
raise MachineNotFoundError(f"machine '{name}' doesn't exist")
else:
conf = kwargs
return conf
def _get_backend(self, cloud: str) -> "BaseMachineBackend":
from dvc.config import NoMachineError
try:
backend = self.CLOUD_BACKENDS[cloud]
return self.backends[backend]
except KeyError:
raise NoMachineError(f"Machine platform '{cloud}' unsupported")
def create(self, name: Optional[str]):
"""Create and start the specified machine instance."""
config, backend = self.get_config_and_backend(name)
if "startup_script" in config:
with open(config["startup_script"], encoding="utf-8") as fobj:
startup_script = fobj.read()
else:
startup_script = DEFAULT_STARTUP_SCRIPT
config["startup_script"] = startup_script
return backend.create(**config)
def destroy(self, name: Optional[str]):
"""Destroy the specified machine instance."""
config, backend = self.get_config_and_backend(name)
return backend.destroy(**config)
def get_sshfs(self, name: Optional[str]):
config, backend = self.get_config_and_backend(name)
return backend.get_sshfs(**config)
def run_shell(self, name: Optional[str]):
config, backend = self.get_config_and_backend(name)
return backend.run_shell(**config)
def status(self, name: str) -> Iterator[Dict[Any, Any]]:
config, backend = self.get_config_and_backend(name)
return backend.instances(**config)
def rename(self, name: str, new: str):
"""move configuration to a new location if the machine is running."""
config, backend = self.get_config_and_backend(name)
return backend.rename(new=new, **config)
+54
View File
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional
if TYPE_CHECKING:
from dvc.fs.ssh import SSHFileSystem
from dvc.repo.experiments.executor.base import BaseExecutor
from dvc.types import StrPath
class BaseMachineBackend(ABC):
def __init__(self, tmp_dir: "StrPath", **kwargs):
self.tmp_dir = tmp_dir
@abstractmethod
def create(self, name: Optional[str] = None, **config):
"""Create and start an instance of the specified machine."""
@abstractmethod
def destroy(self, name: Optional[str] = None, **config):
"""Stop and destroy all instances of the specified machine."""
@abstractmethod
def instances(
self, name: Optional[str] = None, **config
) -> Iterator[dict]:
"""Iterate over status of all instances of the specified machine."""
def close(self):
pass
@abstractmethod
def run_shell(self, name: Optional[str] = None, **config):
"""Spawn an interactive SSH shell for the specified machine."""
@abstractmethod
def get_executor(
self, name: Optional[str] = None, **config
) -> "BaseExecutor":
"""Return an executor instance which can be used for DVC
experiment/pipeline execution on the specified machine.
"""
@abstractmethod
@contextmanager
def get_sshfs(
self, name: Optional[str] = None, **config
) -> Iterator["SSHFileSystem"]:
"""Return an sshfs instance for the default directory on the
specified machine."""
@abstractmethod
def rename(self, name: str, new: str, **config):
"""Rename a machine instance."""
+93
View File
@@ -0,0 +1,93 @@
import os
from contextlib import contextmanager
from functools import partialmethod
from typing import TYPE_CHECKING, Iterator, Optional
from dvc.exceptions import DvcException
from dvc.fs.ssh import SSHFileSystem
from dvc.utils.fs import makedirs
from .base import BaseMachineBackend
if TYPE_CHECKING:
from dvc.repo.experiments.executor.base import BaseExecutor
from dvc.types import StrPath
class TerraformBackend(BaseMachineBackend):
def __init__(self, tmp_dir: "StrPath", **kwargs):
super().__init__(tmp_dir, **kwargs)
makedirs(tmp_dir, exist_ok=True)
@contextmanager
def make_tpi(self, name: str):
from tpi import TPIError
from tpi.terraform import TerraformBackend as TPIBackend
try:
working_dir = os.path.join(self.tmp_dir, name)
makedirs(working_dir, exist_ok=True)
yield TPIBackend(working_dir=working_dir)
except TPIError as exc:
raise DvcException("TPI operation failed") from exc
def _tpi_func(self, fname, name: Optional[str] = None, **config):
from tpi import TPIError
assert name
with self.make_tpi(name) as tpi:
func = getattr(tpi, fname)
try:
return func(name=name, **config)
except TPIError as exc:
raise DvcException(f"TPI {fname} failed") from exc
create = partialmethod(_tpi_func, "create") # type: ignore[assignment]
destroy = partialmethod(_tpi_func, "destroy") # type: ignore[assignment]
instances = partialmethod(
_tpi_func, "instances"
) # type: ignore[assignment]
run_shell = partialmethod(
_tpi_func, "run_shell"
) # type: ignore[assignment]
def get_executor(
self, name: Optional[str] = None, **config
) -> "BaseExecutor":
raise NotImplementedError
@contextmanager
def get_sshfs( # pylint: disable=unused-argument
self, name: Optional[str] = None, **config
) -> Iterator["SSHFileSystem"]:
from tpi import TerraformProviderIterative
assert name
with self.make_tpi(name) as tpi:
resource = tpi.default_resource(name)
with TerraformProviderIterative.pemfile(resource) as pem:
fs = SSHFileSystem(
host=resource["instance_ip"],
user="ubuntu",
keyfile=pem,
)
yield fs
def rename(self, name: str, new: str, **config):
"""rename a dvc machine instance to a new name"""
import shutil
mtype = "iterative_machine"
new_dir = os.path.join(self.tmp_dir, new)
old_dir = os.path.join(self.tmp_dir, name)
if os.path.exists(new_dir):
raise DvcException(f"rename failed: path {new_dir} already exists")
if not os.path.exists(old_dir):
return
with self.make_tpi(name) as tpi:
tpi.state_mv(f"{mtype}.{name}", f"{mtype}.{new}", **config)
shutil.move(old_dir, new_dir)
+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,
):
def check(odb: "ObjectDB", obj: "HashFile", **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()
for _, _, oid in obj:
odb.check(oid, **kwargs)
odb.add(obj.path_info, obj.fs, obj.hash_info, **kwargs)
odb.check(obj.hash_info, **kwargs)
def check(odb, obj):
odb.check(obj.hash_info)
if isinstance(obj, Tree):
for _, entry in obj:
odb.check(entry.hash_info)
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
+109 -158
View File
@@ -1,4 +1,5 @@
import logging
from itertools import chain
from shortuuid import uuid
@@ -10,48 +11,18 @@ from dvc.exceptions import (
DvcException,
)
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.diff import ROOT
from dvc.objects.diff import diff as odiff
from dvc.objects.stage import stage
from dvc.types import Optional
logger = logging.getLogger(__name__)
def _changed(path_info, fs, obj, cache):
logger.trace("checking if '%s'('%s') has changed.", path_info, obj)
try:
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
logger.debug(
"cache for '%s'('%s') has changed.", path_info, obj.hash_info
)
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
if obj.hash_info != actual:
logger.debug(
"hash value '%s' for '%s' has changed (actual '%s').",
obj.hash_info,
actual,
path_info,
)
return True
logger.trace("'%s' hasn't changed.", path_info)
return False
def _remove(path_info, fs, cache, force=False):
def _remove(path_info, fs, in_cache, force=False):
if not fs.exists(path_info):
return
@@ -59,11 +30,7 @@ def _remove(path_info, fs, cache, force=False):
fs.remove(path_info)
return
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM).hash_info
try:
obj = load(cache, current)
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
if not in_cache:
msg = (
f"file/directory '{path_info}' is going to be removed. "
"Are you sure you want to proceed?"
@@ -92,7 +59,7 @@ def _verify_link(cache, path_info, link_type):
def _do_link(cache, from_info, to_info, link_method):
if cache.fs.exists(to_info):
raise DvcException(f"Link '{to_info}' already exists!")
cache.fs.remove(to_info) # broken symlink
link_method(from_info, to_info)
@@ -120,9 +87,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([str(to_info)]) from exc
def _cache_is_copy(cache, path_info):
@@ -152,7 +121,7 @@ def _cache_is_copy(cache, path_info):
def _checkout_file(
path_info,
fs,
obj,
change,
cache,
force,
progress_callback=None,
@@ -161,24 +130,24 @@ def _checkout_file(
):
"""The file is changed we need to checkout a new copy"""
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):
modified = True
_remove(path_info, fs, cache, force=force)
_link(cache, cache_info, path_info)
else:
cache_info = cache.hash_to_path_info(change.new.oid.value)
if change.old.oid:
if relink:
if fs.iscopy(path_info) and _cache_is_copy(cache, path_info):
cache.unprotect(path_info)
else:
_remove(path_info, fs, cache, force=force)
_remove(path_info, fs, change.old.in_cache, force=force)
_link(cache, cache_info, path_info)
else:
modified = True
_remove(path_info, fs, change.old.in_cache, force=force)
_link(cache, cache_info, path_info)
else:
_link(cache, cache_info, path_info)
modified = True
if state:
state.save(path_info, fs, obj.hash_info)
state.save(path_info, fs, change.new.oid)
if progress_callback:
progress_callback(str(path_info))
@@ -186,102 +155,83 @@ def _checkout_file(
return modified
def _remove_redundant_files(
path_info, fs, obj, cache, force, dvcignore: Optional[DvcIgnoreFilter]
):
if dvcignore:
existing_files = set(dvcignore.walk_files(fs, path_info))
else:
existing_files = set(fs.walk_files(path_info))
needed_files = {path_info.joinpath(*key) for key, _ in obj}
redundant_files = existing_files - needed_files
for path in redundant_files:
_remove(path, fs, cache, force)
return bool(redundant_files)
def _checkout_dir(
def _diff(
path_info,
fs,
obj,
cache,
force,
progress_callback=None,
relink=False,
dvcignore: Optional[DvcIgnoreFilter] = None,
state=None,
):
modified = False
# Create dir separately so that dir is created
# even if there are no files in it
if not fs.exists(path_info):
modified = True
fs.makedirs(path_info)
logger.debug("Linking directory '%s'.", path_info)
for entry_key, entry_obj in obj:
entry_modified = _checkout_file(
path_info.joinpath(*entry_key),
fs,
entry_obj,
old = None
try:
_, _, old = stage(
cache,
force,
progress_callback,
relink,
state=None,
path_info,
fs,
obj.hash_info.name if obj else cache.fs.PARAM_CHECKSUM,
dry_run=True,
dvcignore=dvcignore,
)
if entry_modified:
modified = True
except FileNotFoundError:
pass
modified = (
_remove_redundant_files(
path_info, fs, obj, cache, force, dvcignore=dvcignore
)
or modified
)
diff = odiff(old, obj, cache)
if state:
state.save(path_info, fs, obj.hash_info)
if relink:
diff.modified.extend(diff.unchanged)
# relink is not modified, assume it as nochange
return modified and not relink
return diff
def _checkout(
diff,
path_info,
fs,
obj,
cache,
force=False,
progress_callback=None,
relink=False,
dvcignore: Optional[DvcIgnoreFilter] = None,
state=None,
):
if not obj.hash_info.isdir:
ret = _checkout_file(
path_info, fs, obj, cache, force, progress_callback, relink, state
)
else:
ret = _checkout_dir(
path_info,
fs,
obj,
cache,
force,
progress_callback,
relink,
dvcignore=dvcignore,
state=state,
)
if not diff:
return
if state:
state.save_link(path_info, fs)
for change in diff.deleted:
entry_path = (
path_info.joinpath(*change.old.key)
if change.old.key != ROOT
else path_info
)
_remove(entry_path, fs, change.old.in_cache, force=force)
return ret
failed = []
for change in chain(diff.added, diff.modified):
entry_path = (
path_info.joinpath(*change.new.key)
if change.new.key != ROOT
else path_info
)
if change.new.oid.isdir:
fs.makedirs(entry_path)
continue
try:
_checkout_file(
entry_path,
fs,
change,
cache,
force,
progress_callback,
relink,
state=state,
)
except CheckoutError as exc:
failed.extend(exc.target_infos)
if failed:
raise CheckoutError(failed)
def checkout(
@@ -296,53 +246,54 @@ def checkout(
dvcignore: Optional[DvcIgnoreFilter] = None,
state=None,
):
if path_info.scheme not in ["local", cache.fs.scheme]:
raise NotImplementedError
failed = None
skip = False
diff = _diff(
path_info,
fs,
obj,
cache,
relink=relink,
dvcignore=dvcignore,
)
failed = []
if not obj:
if not quiet:
logger.warning(
"No file hash info found for '%s'. It won't be created.",
path_info,
)
_remove(path_info, fs, cache, force=force)
failed = path_info
elif not relink and not _changed(path_info, fs, obj, cache):
failed.append(str(path_info))
elif not diff:
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:
try:
_checkout(
diff,
path_info,
fs,
cache,
force=force,
progress_callback=progress_callback,
relink=relink,
state=state,
)
except CheckoutError as exc:
failed.extend(exc.target_infos)
if diff and state:
state.save_link(path_info, fs)
if not failed:
state.save(path_info, fs, obj.hash_info)
if failed or not diff:
if progress_callback and obj:
progress_callback(str(path_info), len(obj))
if failed:
raise CheckoutError([failed])
raise CheckoutError(failed)
return
logger.debug("Checking out '%s' with cache '%s'.", path_info, obj)
return _checkout(
path_info,
fs,
obj,
cache,
force,
progress_callback,
relink,
dvcignore=dvcignore,
state=state,
)
return bool(diff) and not relink
+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 = [
+84 -58
View File
@@ -1,15 +1,19 @@
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.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 +23,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 +34,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 +44,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,50 +75,52 @@ class ObjectDB:
hash_info,
)
def add(self, path_info, fs, hash_info, move=True, **kwargs):
def _add_file(
self,
from_fs: "BaseFileSystem",
from_info: "AnyPath",
to_info: "DvcPath",
_hash_info: "HashInfo",
move: bool = False,
):
from dvc import fs
self.makedirs(to_info.parent)
return fs.utils.transfer(
from_fs, from_info, self.fs, to_info, move=move
)
def add(
self,
path_info: "AnyPath",
fs: "BaseFileSystem",
hash_info: "HashInfo",
move: bool = True,
verify: Optional[bool] = None,
):
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=self.verify)
self.check(hash_info, check_hash=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
self._add_file(fs, path_info, cache_info, hash_info, move=move)
try:
if use_move:
self.fs.move(path_info, cache_info)
else:
with fs.open(path_info, mode="rb") as fobj:
self.fs.upload_fobj(fobj, cache_info)
except OSError as exc:
# If the target file already exists, we are going to simply
# ignore the exception (#4992).
#
# On Windows, it is not always guaranteed that you'll get
# FileExistsError (it might be PermissionError or a bare OSError)
# but all of those exceptions raised from the original
# FileExistsError so we have a separate check for that.
if isinstance(exc, FileExistsError) or (
os.name == "nt"
and exc.__context__
and isinstance(exc.__context__, FileExistsError)
):
logger.debug("'%s' file already exists, skipping", path_info)
if use_move:
fs.remove(path_info)
else:
raise
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
self.protect(cache_info)
self.state.save(cache_info, self.fs, hash_info)
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 +140,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 +161,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 +171,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 +355,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 +394,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 +489,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(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(path_infos[0]) 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(self.path_info) as ssh:
if prefix and not ssh.exists(root):
return
# If we simply return an iterator then with above closes instantly
if progress_callback:
for path in ssh.walk_files(root):
progress_callback()
yield path
else:
yield from ssh.walk_files(root)
+134
View File
@@ -0,0 +1,134 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional, Tuple
if TYPE_CHECKING:
from dvc.hash_info import HashInfo
from .file import HashFile
ADD = "add"
MODIFY = "modify"
DELETE = "delete"
UNCHANGED = "unchanged"
@dataclass
class TreeEntry:
in_cache: bool
key: Tuple[str]
oid: Optional["HashInfo"] = field(default=None)
def __bool__(self):
return bool(self.oid)
def __eq__(self, other):
if not isinstance(other, TreeEntry):
return False
if self.key != other.key:
return False
return self.oid == other.oid
@dataclass
class Change:
old: TreeEntry
new: TreeEntry
@property
def typ(self):
if not self.old and not self.new:
return UNCHANGED
if self.old and not self.new:
return DELETE
if not self.old and self.new:
return ADD
if self.old != self.new:
return MODIFY
return UNCHANGED
def __bool__(self):
return self.typ != UNCHANGED
@dataclass
class DiffResult:
added: List[Change] = field(default_factory=list, compare=True)
modified: List[Change] = field(default_factory=list, compare=True)
deleted: List[Change] = field(default_factory=list, compare=True)
unchanged: List[Change] = field(default_factory=list, compare=True)
def __bool__(self):
return bool(self.added or self.modified or self.deleted)
ROOT = ("",)
def diff(
old: Optional["HashFile"], new: Optional["HashFile"], cache
) -> DiffResult:
from .tree import Tree
if old is None and new is None:
return DiffResult()
def _get_keys(obj):
if not obj:
return []
return [ROOT] + (
[key for key, _, _ in obj] if isinstance(obj, Tree) else []
)
old_keys = set(_get_keys(old))
new_keys = set(_get_keys(new))
def _get_oid(obj, key):
if not obj or key == ROOT:
return obj.hash_info if obj else None
entry_obj = obj.get(cache, key)
return entry_obj.hash_info if entry_obj else None
def _in_cache(oid, cache):
from .errors import ObjectFormatError
if not oid:
return False
try:
cache.check(oid)
return True
except (FileNotFoundError, ObjectFormatError):
return False
ret = DiffResult()
for key in old_keys | new_keys:
old_oid = _get_oid(old, key)
new_oid = _get_oid(new, key)
change = Change(
old=TreeEntry(_in_cache(old_oid, cache), key, old_oid),
new=TreeEntry(_in_cache(new_oid, cache), key, new_oid),
)
if change.typ == ADD:
ret.added.append(change)
elif change.typ == MODIFY:
ret.modified.append(change)
elif change.typ == DELETE:
ret.deleted.append(change)
else:
assert change.typ == UNCHANGED
if not change.new.in_cache and not (
change.new.oid and change.new.oid.isdir
):
ret.modified.append(change)
else:
ret.unchanged.append(change)
return ret
+8
View File
@@ -7,3 +7,11 @@ class ObjectError(DvcException):
class ObjectFormatError(ObjectError):
pass
class ObjectDBError(DvcException):
pass
class ObjectDBPermissionError(ObjectDBError):
pass
+12 -12
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,
@@ -26,12 +28,6 @@ class HashFile:
self.hash_info = hash_info
self.name = name
@property
def size(self):
if not (self.path_info and self.fs):
return None
return self.fs.getsize(self.path_info)
def __len__(self):
return 1
@@ -59,10 +55,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
@@ -70,7 +65,12 @@ class HashFile:
else:
return None
actual = get_file_hash(
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
)
+32
View File
@@ -0,0 +1,32 @@
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Meta:
PARAM_SIZE = "size"
PARAM_NFILES = "nfiles"
size: Optional[int] = field(default=None)
nfiles: Optional[int] = field(default=None)
@classmethod
def from_dict(cls, d):
if not d:
return cls()
size = d.pop(cls.PARAM_SIZE, None)
nfiles = d.pop(cls.PARAM_NFILES, None)
return cls(size=size, nfiles=nfiles)
def to_dict(self):
ret = OrderedDict()
if self.size is not None:
ret[self.PARAM_SIZE] = self.size
if self.nfiles is not None:
ret[self.PARAM_NFILES] = self.nfiles
return ret
+122
View File
@@ -0,0 +1,122 @@
import errno
import logging
import os
import pickle
from typing import TYPE_CHECKING, Optional
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_CHECKSUM = "checksum"
PARAM_FS_CONFIG = "fs_config"
def __init__(
self,
path_info: "AnyPath",
fs: "BaseFileSystem",
hash_info: "HashInfo",
checksum: Optional[str] = None,
**kwargs,
):
super().__init__(path_info, fs, hash_info, **kwargs)
self.checksum = checksum or fs.checksum(path_info)
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 self.checksum != self._get_checksum():
raise ObjectFormatError(f"{self} is changed")
if check_hash:
self._check_hash(odb)
def _get_checksum(self) -> str:
assert self.fs
return self.fs.checksum(self.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_CHECKSUM: self.checksum,
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
}
try:
return pickle.dumps(dict_)
except pickle.PickleError as exc:
raise ObjectFormatError(f"Could not pickle {self}") from exc
@classmethod
def from_bytes(cls, data: bytes, fs_cache: Optional[dict] = None):
from dvc.fs import get_fs_cls
from dvc.fs.repo import RepoFileSystem
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,
checksum=dict_.get(cls.PARAM_CHECKSUM),
)
@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]
)
),
)
+222 -121
View File
@@ -1,79 +1,107 @@
import errno
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
from .meta import Meta
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(
stream, tmp_info, desc=path_info.name, size=fs.getsize(path_info)
)
size = fs.getsize(path_info)
upload_odb.fs.upload(stream, tmp_info, desc=path_info.name, total=size)
obj = HashFile(tmp_info, odb.fs, stream.hash_info)
return path_info, obj
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
meta = Meta(size=size)
return path_info, meta, odb.get(stream.hash_info)
def _get_file_hash(path_info, fs, name):
info = fs.info(path_info)
if name in info:
assert not info[name].endswith(".dir")
return HashInfo(name, info[name], size=info["size"])
hash_value = info[name]
elif hasattr(fs, name):
func = getattr(fs, name)
hash_value = func(path_info)
elif name == "md5":
hash_value = file_md5(path_info, fs)
else:
raise NotImplementedError
func = getattr(fs, name, None)
if func:
return func(path_info)
if name == "md5":
return HashInfo(
name, file_md5(path_info, fs), size=fs.getsize(path_info)
)
raise NotImplementedError
meta = Meta(size=info["size"])
hash_info = HashInfo(name, hash_value)
return meta, hash_info
def get_file_hash(path_info, fs, name, state=None):
if state:
hash_info = state.get( # pylint: disable=assignment-from-none
meta, hash_info = state.get( # pylint: disable=assignment-from-none
path_info, fs
)
if hash_info:
return hash_info
return meta, hash_info
hash_info = _get_file_hash(path_info, fs, name)
meta, hash_info = _get_file_hash(path_info, fs, name)
if state:
assert ".dir" not in hash_info.value
state.save(path_info, fs, hash_info)
return hash_info
return meta, hash_info
def _get_file_obj(path_info, fs, name, odb=None, state=None, upload=False):
if upload:
def _stage_file(path_info, fs, name, odb=None, upload_odb=None, dry_run=False):
state = odb.state if odb else None
meta, 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)
)
return path_info, obj
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, meta, 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)
@@ -82,45 +110,32 @@ 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,
_stage_file,
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_meta = Meta(size=0, nfiles=0)
tree = Tree(None, None, None)
for file_info, obj in _iter_objects(
path_info, fs, name, odb, state, upload, **kwargs
):
for file_info, meta, obj in _iter_objects(path_info, fs, name, **kwargs):
if DvcIgnore.DVCIGNORE_FILE == file_info.name:
raise DvcIgnoreInCollectedDirError(file_info.parent)
@@ -132,90 +147,176 @@ 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.add(file_info.relative_to(path_info).parts, meta, obj.hash_info)
tree.digest()
tree_meta.size += meta.size
tree_meta.nfiles += 1
return tree
return tree_meta, tree
def _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs):
def _stage_tree(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)
return Meta(nfiles=len(tree)), tree
except FileNotFoundError:
pass
tree = _build_tree(path_info, fs, name, odb, state, upload, **kwargs)
meta, 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 meta, tree
_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
meta, 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):
meta.nfiles = len(obj)
assert obj.hash_info.name == name
return odb_, meta, obj
except (ObjectFormatError, FileNotFoundError):
pass
raise FileNotFoundError
def _stage_external_tree_info(odb, tree, name):
# NOTE: used only for external outputs. Initial reasoning was to be
# able to validate .dir files right in the workspace (e.g. check s3
# etag), but could be dropped for manual validation with regular md5,
# that would be universal for all clouds.
assert odb and name != "md5"
odb.add(tree.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)
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, path_info, fs, name, upload=False, **kwargs):
assert path_info and (
isinstance(path_info, str) or path_info.scheme == fs.scheme
)
def stage(
odb: "ObjectDB",
path_info: "DvcPath",
fs: "BaseFileSystem",
name: str,
upload: bool = False,
dry_run: bool = False,
**kwargs,
) -> Tuple["ObjectDB", "Meta", "HashFile"]:
"""Stage (prepare) objects from the given path for addition to an ODB.
if not fs.exists(path_info):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path_info
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)
staging = _get_staging(odb)
if odb:
try:
return _load_from_state(odb, staging, path_info, fs, name)
except FileNotFoundError:
pass
if details["type"] == "directory":
meta, obj = _stage_tree(
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:
_, meta, obj = _stage_file(
path_info,
fs,
name,
odb=staging,
upload_odb=odb if upload else None,
dry_run=dry_run,
)
state = odb.state
# pylint: disable=assignment-from-none
hash_info = state.get(path_info, fs)
if odb and odb.state and obj.hash_info:
odb.state.save(path_info, fs, obj.hash_info)
# 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
if fs.isdir(path_info):
obj = _get_tree_obj(path_info, fs, name, odb, state, upload, **kwargs)
else:
_, obj = _get_file_obj(path_info, fs, name, odb, state, upload)
if obj.hash_info and fs.exists(path_info):
state.save(path_info, fs, obj.hash_info)
return obj
return staging, meta, 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 = [oid.value for _, _, oid in tree]
if dir_hash not in index:
logger.debug(
"Indexing new .dir '%s' with '%s' nested files",
dir_hash,
len(file_hashes),
)
index.update([dir_hash], file_hashes)
yield from file_hashes
yield tree.hash_info.value
def status(
odb: "ObjectDB",
obj_ids: Iterable["HashInfo"],
name: Optional[str] = None,
index: Optional["ObjectDBIndexBase"] = None,
cache_odb: Optional["ObjectDB"] = None,
shallow: bool = True,
**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 _, _, oid in tree:
assert oid and oid.value
hash_infos[oid.value] = oid
if index:
dir_objs[hash_info.value] = tree
hash_infos[hash_info.value] = hash_info
if odb.fs.scheme == Schemes.MEMORY:
# assume memfs staged objects already exist
return StatusResult(set(hash_infos.values()), set())
hashes: Set[str] = set(hash_infos.keys())
exists: Set[str] = set()
logger.debug("Collecting status from '%s'", odb.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
+172
View File
@@ -0,0 +1,172 @@
import errno
import logging
from functools import partial, wraps
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
from funcy import split
from dvc.progress import Tqdm
from dvc.utils.threadpool import ThreadPoolExecutor
if TYPE_CHECKING:
from dvc.hash_info import HashInfo
from .db.base import ObjectDB
from .db.index import ObjectDBIndexBase
from .tree import Tree
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 find_tree_by_obj_id(
odbs: Iterable[Optional["ObjectDB"]], obj_id: "HashInfo"
) -> Optional["Tree"]:
from .errors import ObjectFormatError
from .tree import Tree
for odb in odbs:
if odb is not None:
try:
return Tree.load(odb, obj_id)
except (FileNotFoundError, ObjectFormatError):
pass
return None
def _do_transfer(
src: "ObjectDB",
dest: "ObjectDB",
obj_ids: Iterable["HashInfo"],
missing_ids: Iterable["HashInfo"],
processor: Callable,
src_index: Optional["ObjectDBIndexBase"] = None,
dest_index: Optional["ObjectDBIndexBase"] = None,
cache_odb: Optional["ObjectDB"] = None,
**kwargs: Any,
):
from dvc.exceptions import FileTransferError
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, obj_ids)
total_fails = 0
succeeded_dir_objs = []
all_file_ids = set(file_ids)
for dir_hash in dir_ids:
dir_obj = find_tree_by_obj_id([cache_odb, src], dir_hash)
assert dir_obj
entry_ids = {oid for _, _, oid in dir_obj}
bound_file_ids = all_file_ids & entry_ids
all_file_ids -= entry_ids
dir_fails = sum(processor(bound_file_ids))
if dir_fails:
logger.debug(
"failed to upload full contents of '%s', "
"aborting .dir file upload",
dir_obj.name,
)
logger.error(
"failed to upload '%s' to '%s'",
src.get(dir_obj.hash_info).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:
is_dir_failed = sum(processor([dir_obj.hash_info]))
total_fails += is_dir_failed
if not is_dir_failed:
succeeded_dir_objs.append(dir_obj)
# insert the rest
total_fails += sum(processor(all_file_ids))
if total_fails:
if src_index:
src_index.clear()
raise FileTransferError(total_fails)
# index successfully pushed dirs
if dest_index:
for dir_obj in succeeded_dir_objs:
file_hashes = {oid.value for _, _, oid in dir_obj}
logger.debug(
"Indexing pushed dir '%s' with '%s' nested files",
dir_obj.hash_info,
len(file_hashes),
)
assert dir_obj.hash_info and dir_obj.hash_info.value
dest_index.update([dir_obj.hash_info.value], file_hashes)
def transfer(
src: "ObjectDB",
dest: "ObjectDB",
obj_ids: Iterable["HashInfo"],
jobs: Optional[int] = None,
verify: bool = False,
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
def func(hash_info: "HashInfo") -> None:
obj = src.get(hash_info)
return dest.add(
obj.path_info, obj.fs, obj.hash_info, verify=verify, move=move
)
total = len(status.new)
jobs = jobs or dest.fs.jobs
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
with ThreadPoolExecutor(max_workers=jobs) as executor:
wrapped_func = pbar.wrap_fn(_log_exceptions(func))
processor = partial(executor.imap_unordered, wrapped_func)
_do_transfer(
src, dest, status.new, status.missing, processor, **kwargs
)
return total
+56 -63
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,9 @@ from .file import HashFile
from .stage import get_file_hash
if TYPE_CHECKING:
from .db.base import ObjectDB
from dvc.hash_info import HashInfo
from .meta import Meta
logger = logging.getLogger(__name__)
@@ -20,7 +22,7 @@ class Tree(HashFile):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._dict = {}
self._dict: Dict[Tuple[str], Tuple["Meta", "HashInfo"]] = {}
@cached_property
def trie(self):
@@ -28,38 +30,35 @@ class Tree(HashFile):
return Trie(self._dict)
@property
def size(self):
try:
return sum(obj.size for _, obj in self)
except TypeError:
return None
def add(self, key, obj):
def add(self, key: Tuple[str], meta: "Meta", oid: "HashInfo"):
self.__dict__.pop("trie", None)
self._dict[key] = obj
self._dict[key] = (meta, oid)
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"
self.hash_info.size = self.size
self.hash_info.nfiles = len(self)
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"
def __len__(self):
return len(self._dict)
def __iter__(self):
yield from self._dict.items()
yield from (
(key, value[0], value[1]) for key, value in self._dict.items()
)
def as_dict(self):
return self._dict.copy()
@@ -73,10 +72,10 @@ class Tree(HashFile):
{
# NOTE: not using hash_info.to_dict() because we don't want
# size/nfiles fields at this point.
obj.hash_info.name: obj.hash_info.value,
oid.name: oid.value,
self.PARAM_RELPATH: posixpath.sep.join(parts),
}
for parts, obj in self._dict.items() # noqa: B301
for parts, _, oid in self # noqa: B301
),
key=itemgetter(self.PARAM_RELPATH),
)
@@ -94,13 +93,11 @@ class Tree(HashFile):
relpath = entry.pop(cls.PARAM_RELPATH)
parts = tuple(relpath.split(posixpath.sep))
hash_info = HashInfo.from_dict(entry)
obj = HashFile(None, None, hash_info)
tree.add(parts, obj)
tree.add(parts, None, hash_info)
return tree
@classmethod
def load(cls, odb, hash_info):
obj = odb.get(hash_info)
try:
@@ -119,53 +116,52 @@ class Tree(HashFile):
tree = cls.from_list(raw)
tree.path_info = obj.path_info
tree.fs = obj.fs
for _, entry_obj in tree:
entry_obj.fs = obj.fs
tree.hash_info = hash_info
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.
"""
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(self.path_info, self.fs, self.hash_info)
try:
for key, obj in self.trie.items(prefix):
tree.add(key[depth:], obj)
for key, (meta, oid) in self.trie.items(prefix):
tree.add(key, meta, oid)
except KeyError:
pass
return tree
def get(self, odb, prefix: Tuple[str]) -> Optional[HashFile]:
"""Return object at the specified prefix in this tree.
Returns None if no object exists at the specified prefix.
"""
_, oid = self._dict.get(prefix) or (None, None)
if oid:
return odb.get(oid)
tree = Tree(None, None, None)
depth = len(prefix)
try:
for key, (meta, entry_oid) in self.trie.items(prefix):
tree.add(key[depth:], meta, entry_oid)
except KeyError:
return None
if not copy:
tree.digest()
odb.add(tree.path_info, tree.fs, tree.hash_info)
tree.digest()
return tree
def _get_dir_size(odb, tree):
def du(odb, tree):
try:
return sum(
odb.fs.getsize(odb.hash_to_path_info(obj.hash_info.value))
for _, obj in tree
odb.fs.getsize(odb.hash_to_path_info(oid.value))
for _, _, oid in tree
)
except FileNotFoundError:
return None
@@ -226,11 +222,8 @@ def merge(odb, ancestor_info, our_info, their_info):
merged_dict = _merge(ancestor.as_dict(), our.as_dict(), their.as_dict())
merged = Tree(None, None, None)
for key, hi in merged_dict.items():
merged.add(key, hi)
for key, (meta, oid) in merged_dict.items():
merged.add(key, meta, oid)
merged.digest()
odb.add(merged.path_info, merged.fs, merged.hash_info)
hash_info = merged.hash_info
hash_info.size = _get_dir_size(odb, merged)
return hash_info
return merged
+154 -63
View File
@@ -1,7 +1,6 @@
import logging
import os
from collections import defaultdict
from copy import copy
from typing import TYPE_CHECKING, Dict, Optional, Set, Type
from urllib.parse import urlparse
@@ -9,7 +8,6 @@ from funcy import collecting, project
from voluptuous import And, Any, Coerce, Length, Lower, Required, SetTo
from dvc import objects, prompt
from dvc.checkout import checkout
from dvc.exceptions import (
CheckoutError,
CollectCacheError,
@@ -17,6 +15,7 @@ from dvc.exceptions import (
MergeError,
RemoteCacheRequiredError,
)
from dvc.objects.checkout import checkout
from .fs import get_cloud_fs
from .fs.hdfs import HDFSFileSystem
@@ -25,16 +24,16 @@ 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.meta import Meta
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__)
@@ -82,6 +81,7 @@ def loadd_from(stage, d_list):
desc = d.pop(Output.PARAM_DESC, False)
isexec = d.pop(Output.PARAM_ISEXEC, False)
live = d.pop(Output.PARAM_LIVE, False)
remote = d.pop(Output.PARAM_REMOTE, None)
ret.append(
_get(
stage,
@@ -95,6 +95,7 @@ def loadd_from(stage, d_list):
desc=desc,
isexec=isexec,
live=live,
remote=remote,
)
)
return ret
@@ -110,6 +111,7 @@ def loads_from(
checkpoint=False,
isexec=False,
live=False,
remote=None,
):
return [
_get(
@@ -123,6 +125,7 @@ def loads_from(
checkpoint=checkpoint,
isexec=isexec,
live=live,
remote=remote,
)
for s in s_list
]
@@ -163,7 +166,6 @@ def load_from_pipeline(stage, data, typ="outs"):
metric = typ == stage.PARAM_METRICS
plot = typ == stage.PARAM_PLOTS
live = typ == stage.PARAM_LIVE
if live:
# `live` is single object
data = [data]
@@ -186,6 +188,7 @@ def load_from_pipeline(stage, data, typ="outs"):
Output.PARAM_CACHE,
Output.PARAM_PERSIST,
Output.PARAM_CHECKPOINT,
Output.PARAM_REMOTE,
],
)
@@ -256,6 +259,7 @@ class Output:
PARAM_LIVE = "live"
PARAM_LIVE_SUMMARY = "summary"
PARAM_LIVE_HTML = "html"
PARAM_REMOTE = "remote"
METRIC_SCHEMA = Any(
None,
@@ -271,8 +275,6 @@ class Output:
IsStageFileError = OutputIsStageFileError # type: Type[DvcException]
IsIgnoredError = OutputIsIgnoredError # type: Type[DvcException]
sep = "/"
def __init__(
self,
stage,
@@ -286,6 +288,7 @@ class Output:
live=False,
desc=None,
isexec=False,
remote=None,
):
self.repo = stage.repo if stage else None
@@ -313,6 +316,7 @@ class Output:
# By resolved path, which contains actual location,
# should be absolute and don't contain remote:// refs.
self.stage = stage
self.meta = Meta.from_dict(info)
self.hash_info = HashInfo.from_dict(info)
self.use_cache = False if self.IS_DEPENDENCY else cache
self.metric = False if self.IS_DEPENDENCY else metric
@@ -329,7 +333,7 @@ class Output:
self.obj = None
self.isexec = False if self.IS_DEPENDENCY else isexec
self.def_remote = None
self.remote = remote
def _parse_path(self, fs, path_info):
if fs.scheme != "local":
@@ -406,21 +410,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):
@@ -534,7 +538,15 @@ class Output:
self.verify_metric()
if not self.use_cache:
self.hash_info = self.get_hash()
_, self.meta, obj = ostage(
self.repo.odb.local,
self.path_info,
self.fs,
self.fs.PARAM_CHECKSUM,
dvcignore=self.dvcignore,
dry_run=True,
)
self.hash_info = obj.hash_info
if not self.IS_DEPENDENCY:
logger.debug(
"Output '%s' doesn't use cache. Skipping saving.", self
@@ -547,7 +559,7 @@ class Output:
logger.debug("Output '%s' didn't change. Skipping saving.", self)
return
self.obj = ostage(
_, self.meta, self.obj = ostage(
self.odb,
self.path_info,
self.fs,
@@ -568,14 +580,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,
@@ -587,8 +613,28 @@ 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(self.odb, prefix)
otransfer(
staging,
self.odb,
{save_obj.hash_info} | {oid for _, _, oid in save_obj},
shallow=True,
move=True,
)
return checkout_obj
def dumpd(self):
ret = copy(self.hash_info.to_dict())
ret = {**self.hash_info.to_dict(), **self.meta.to_dict()}
if self.is_in_repo:
path = self.path_info.relpath(self.stage.wdir).as_posix()
@@ -672,7 +718,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(self.odb, prefix)
return obj
@@ -766,7 +812,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, self.meta, obj = ostage(
odb,
from_info,
from_fs,
@@ -775,7 +821,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
@@ -788,7 +841,7 @@ class Output:
return 1
if not filter_info or filter_info == self.path_info:
return self.hash_info.nfiles or 0
return self.meta.nfiles or 0
obj = self.get_obj(filter_info=filter_info)
return len(obj) if obj else 0
@@ -804,8 +857,13 @@ 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:
if self.remote:
kwargs["remote"] = self.remote
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,10 +872,10 @@ class Output:
return self.obj
def collect_used_dir_cache(
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."""
) -> Optional["Tree"]:
"""Fetch dir cache and return used object IDs for this out."""
try:
self.get_dir_cache(jobs=jobs, remote=remote)
@@ -826,7 +884,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,16 +895,18 @@ class Output:
"unable to fully collect used cache"
" without cache for directory '{}'".format(self)
)
return {}
return None
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 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 {}
@@ -874,29 +934,40 @@ class Output:
return {}
if self.is_dir_checksum:
return self.collect_used_dir_cache(**kwargs)
obj = self._collect_used_dir_cache(**kwargs)
else:
obj = self.get_obj(filter_info=kwargs.get("filter_info"))
if not obj:
obj = self.odb.get(self.hash_info)
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 {}
return {None: {obj}}
if self.remote:
remote = self.repo.cloud.get_remote_odb(name=self.remote)
else:
remote = None
def _set_obj_names(self, obj):
obj.name = str(self)
return {remote: self._named_obj_ids(obj)}
def _named_obj_ids(self, obj):
name = str(self)
obj.hash_info.obj_name = name
oids = {obj.hash_info}
if isinstance(obj, Tree):
for key, entry_obj in obj:
entry_obj.name = os.path.join(str(self), *key)
for key, _, oid in obj:
oid.obj_name = self.fs.sep.join([name, *key])
oids.add(oid)
return oids
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
@@ -919,8 +990,8 @@ class Output:
ignored = [
self.fs.PARAM_CHECKSUM,
HashInfo.PARAM_SIZE,
HashInfo.PARAM_NFILES,
Meta.PARAM_SIZE,
Meta.PARAM_NFILES,
]
for opt in ignored:
@@ -938,7 +1009,7 @@ class Output:
)
def merge(self, ancestor, other):
from dvc.objects.tree import merge
from dvc.objects.tree import du, merge
assert other
@@ -951,14 +1022,33 @@ class Output:
self._check_can_merge(self)
self._check_can_merge(other)
self.hash_info = merge(
merged = merge(
self.odb, ancestor_info, self.hash_info, other.hash_info
)
self.odb.add(merged.path_info, merged.fs, merged.hash_info)
self.hash_info = merged.hash_info
self.meta = Meta(
size=du(self.odb, merged),
nfiles=len(merged),
)
@property
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,
@@ -966,8 +1056,8 @@ ARTIFACT_SCHEMA = {
Output.PARAM_PLOT: bool,
Output.PARAM_PERSIST: bool,
Output.PARAM_CHECKPOINT: bool,
HashInfo.PARAM_SIZE: int,
HashInfo.PARAM_NFILES: int,
Meta.PARAM_SIZE: int,
Meta.PARAM_NFILES: int,
Output.PARAM_ISEXEC: bool,
}
@@ -976,4 +1066,5 @@ SCHEMA = {
Output.PARAM_CACHE: bool,
Output.PARAM_METRIC: Output.METRIC_SCHEMA,
Output.PARAM_DESC: str,
Output.PARAM_REMOTE: str,
}
+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)):
+57 -5
View File
@@ -1,9 +1,9 @@
"""Manages progress bars for DVC repo."""
import logging
import sys
from threading import RLock
import fsspec
from tqdm import tqdm
from dvc.env import DVC_IGNORE_ISATTY
@@ -51,7 +51,7 @@ class Tqdm(tqdm):
file=None,
total=None,
postfix=None,
**kwargs
**kwargs,
):
"""
bytes : shortcut for
@@ -90,7 +90,7 @@ class Tqdm(tqdm):
bar_format="!",
lock_args=(False,),
total=total,
**kwargs
**kwargs,
)
self.postfix = postfix or {"info": ""}
if bar_format is None:
@@ -106,13 +106,16 @@ class Tqdm(tqdm):
self.bar_format = bar_format
self.refresh()
def update_msg(self, msg, n=1):
def update_msg(self, msg: str, n: int = 1) -> None:
"""
Sets `msg` as a postfix and calls `update(n)`.
"""
self.postfix["info"] = " %s |" % msg
self.set_msg(msg)
self.update(n)
def set_msg(self, msg: str) -> None:
self.postfix["info"] = f" {msg} |"
def update_to(self, current, total=None):
if total:
self.total = total
@@ -133,6 +136,9 @@ class Tqdm(tqdm):
return wrapped
def as_callback(self):
return FsspecCallback(self)
def close(self):
self.postfix["info"] = ""
# remove ETA (either unknown or zero); remove completed bar
@@ -158,3 +164,49 @@ class Tqdm(tqdm):
d["ncols_desc"] = d["ncols_info"] = 1
d["prefix"] = ""
return d
class FsspecCallback(fsspec.Callback):
def __init__(self, progress_bar):
self.progress_bar = progress_bar
super().__init__()
def set_size(self, size):
if size is not None:
self.progress_bar.total = size
self.progress_bar.refresh()
super().set_size(size)
def relative_update(self, inc=1):
self.progress_bar.update(inc)
super().relative_update(inc)
def absolute_update(self, value):
self.progress_bar.update_to(value)
super().absolute_update(value)
@staticmethod
def wrap_fn(cb, fn):
def wrapped(*args, **kwargs):
res = fn(*args, **kwargs)
cb.relative_update()
return res
return wrapped
def tdqm_or_callback_wrapped(
fobj, method, total, callback=None, **pbar_kwargs
):
if callback:
from funcy import nullcontext
from tqdm.utils import CallbackIOWrapper
callback.set_size(total)
wrapper = CallbackIOWrapper(callback.relative_update, fobj, method)
return nullcontext(wrapper)
return Tqdm.wrapattr(fobj, method, total=total, bytes=True, **pbar_kwargs)
DEFAULT_CALLBACK = fsspec.callbacks.NoOpCallback()
-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]

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