Compare commits

...
186 Commits
Author SHA1 Message Date
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
221 changed files with 6942 additions and 4166 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'"
+1
View File
@@ -5,6 +5,7 @@ on:
- master
release:
types: [released, prereleased]
workflow_dispatch:
jobs:
deb:
runs-on: ubuntu-18.04
+19 -20
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,8 +13,13 @@ env:
jobs:
lint:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- uses: actions/[email protected]
- name: Set up Python 3.8
uses: actions/[email protected]
@@ -27,7 +28,7 @@ jobs:
- name: Install requirements
run: |
pip install wheel
pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
pip install ".[all,tests,terraform]" Pygments collective.checkdocs pre-commit
- name: install temporary dependencies
run: |
pip install git+https://github.com/isidentical/gdrivefs@service-account
@@ -36,20 +37,17 @@ 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.6", "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]
- name: Set up Python
@@ -65,14 +63,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,6 +94,7 @@ 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 }}
@@ -108,7 +107,7 @@ jobs:
--cov-report=xml --cov-report=term
--all ${{ env.extra_test_args }}
- name: upload coverage report
uses: codecov/codecov-action@v2.0.2
uses: codecov/codecov-action@v2.1.0
with:
file: ./coverage.xml
fail_ci_if_error: false
+1 -1
View File
@@ -6,7 +6,7 @@ repos:
- id: black
language_version: python3
repo: https://github.com/ambv/black
rev: 21.7b0
rev: 21.9b0
- hooks:
- id: codespell
args:
+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.
-1
View File
@@ -1,3 +1,2 @@
include fastentrypoints.py
include LICENSE
include test_requirements.txt
+50 -40
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.
@@ -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 efficient 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
+8 -3
View File
@@ -2,6 +2,8 @@ import json
import logging
import os
from .env import DVC_NO_ANALYTICS
logger = logging.getLogger(__name__)
@@ -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
+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
+2 -3
View File
@@ -103,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,
+8 -6
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"]
@@ -142,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)
@@ -188,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",
@@ -200,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(
+270 -62
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
@@ -19,9 +18,8 @@ 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__)
@@ -141,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
@@ -161,8 +161,8 @@ def _collect_rows(
elif exp.get("queued"):
state = "Queued"
else:
state = FILL_VALUE
executor = exp.get("executor", FILL_VALUE)
state = fill_value
executor = exp.get("executor", fill_value)
is_baseline = rev == "baseline"
if is_baseline:
@@ -202,12 +202,12 @@ def _collect_rows(
exp_name,
name_rev,
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
fill_value = FILL_VALUE_ERRORED if results.get("error") else fill_value
_extend_row(
row,
metric_names,
@@ -258,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
@@ -274,19 +275,21 @@ 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, fill_value=FILL_VALUE):
from rich.text import Text
from dvc.compare import _format_field, with_value
if not items:
@@ -301,10 +304,10 @@ def _extend_row(row, names, items, precision, fill_value=FILL_VALUE):
item.get(name),
FILL_VALUE_ERRORED if data.get("error", None) else fill_value,
)
# wrap field data in rich.Text, otherwise rich may
# 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):
@@ -320,6 +323,7 @@ def _parse_filter_list(param_list):
def experiments_table(
all_experiments,
headers,
metric_headers,
metric_names,
param_headers,
@@ -327,22 +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",
"typ",
"Created",
"parent",
"State",
"Executor",
]
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(
@@ -353,23 +350,23 @@ 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 ""
@@ -377,7 +374,7 @@ def prepare_exp_id(kwargs) -> "Text":
tree = experiment_types[typ]
pref = f"{tree} " if tree else ""
return Text(pref) + text
return ui.rich_text(pref) + text
def baseline_styler(typ):
@@ -385,8 +382,10 @@ def baseline_styler(typ):
def show_experiments(
all_experiments, pager=True, no_timestamp=False, **kwargs
all_experiments, pager=True, no_timestamp=False, csv=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", []))
@@ -399,11 +398,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,
@@ -411,6 +425,8 @@ def show_experiments(
kwargs.get("sort_by"),
kwargs.get("sort_order"),
kwargs.get("precision"),
kwargs.get("fill_value"),
kwargs.get("iso"),
)
if no_timestamp:
@@ -422,9 +438,12 @@ def show_experiments(
row_styles = lmap(baseline_styler, td.column("typ"))
merge_headers = ["Experiment", "rev", "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 = {
@@ -453,13 +472,11 @@ def show_experiments(
rich_table=True,
header_styles=styles,
row_styles=row_styles,
csv=csv,
)
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
@@ -488,11 +505,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,
@@ -502,8 +525,11 @@ 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,
)
return 0
@@ -532,10 +558,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
@@ -550,7 +574,7 @@ 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",
@@ -635,7 +659,7 @@ class CmdExperimentsGC(CmdRepro):
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(
@@ -747,12 +771,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."
@@ -875,11 +973,19 @@ 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(
"--precision",
type=int,
@@ -943,15 +1049,18 @@ 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(
@@ -1109,9 +1218,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>]",
)
@@ -1229,7 +1338,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],
@@ -1237,9 +1346,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="*",
@@ -1248,6 +1370,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'."""
+5 -4
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
@@ -13,10 +13,11 @@ class CmdLive(CmdBase):
metrics, plots = self.repo.live.show(target=target, revs=revs)
if plots:
html_path = self.args.target + ".html"
self.repo.plots.write_html(html_path, plots, metrics)
html_path = Path.cwd() / (self.args.target + "_html")
from dvc.render.utils import render
ui.write("\nfile://", os.path.abspath(html_path), sep="")
index_path = render(self.repo, plots, metrics, html_path)
ui.write(index_path.as_uri())
return 0
return 1
+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",
+333
View File
@@ -0,0 +1,333 @@
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.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 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 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_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_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)
+12 -10
View File
@@ -31,16 +31,14 @@ class CmdMetricsShow(CmdMetricsBase):
logger.exception("")
return 1
if self.args.show_json:
import json
ui.write(json.dumps(metrics, default=encode_exception))
if self.args.json:
ui.write_json(metrics, default=encode_exception)
else:
from dvc.compare import show_metrics
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,
@@ -65,17 +63,15 @@ 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,
@@ -140,15 +136,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(
@@ -220,15 +219,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(
+6 -5
View File
@@ -25,17 +25,15 @@ 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,
)
@@ -103,15 +101,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(
+28 -20
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,43 +36,50 @@ 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
except DvcException:
logger.exception("")
return 1
if plots:
rel: str = self.args.out or "plots.html"
rel: str = self.args.out or "dvc_plots"
path: Path = (Path.cwd() / rel).resolve()
self.repo.plots.write_html(
path, plots=plots, html_template_path=self.args.html_template
index_path = render(
self.repo,
plots_data,
path=path,
html_template_path=self.args.html_template,
)
assert (
path.is_absolute()
) # as_uri throws ValueError if not absolute
url = path.as_uri()
assert index_path.is_absolute()
url = index_path.as_uri()
ui.write(url)
if self.args.open:
import webbrowser
opened = webbrowser.open(rel)
opened = webbrowser.open(index_path)
if not opened:
ui.error_write(
"Failed to open. Please try opening it manually."
)
return 1
else:
ui.error_write(
"No plots were loaded, visualization file will not be created."
)
return 0
return 0
except DvcException:
logger.exception("")
return 1
class CmdPlotsShow(CmdPlots):
+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
+2 -4
View File
@@ -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:
+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
+4 -1
View File
@@ -168,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
+30 -13
View File
@@ -32,6 +32,18 @@ class RemoteNotFoundError(RemoteConfigError):
pass
class MachineConfigError(ConfigError):
pass
class NoMachineError(MachineConfigError):
pass
class MachineNotFoundError(MachineConfigError):
pass
@memoize
def get_compiled_schema():
from voluptuous import Schema
@@ -64,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"
@@ -167,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
@@ -179,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
@@ -251,6 +265,7 @@ class Config(dict):
"key_path": func,
}
},
"machine": {str: {"startup_script": func}},
}
return Schema(dirs_schema, extra=ALLOW_EXTRA)(conf)
@@ -273,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)
@@ -300,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
+36 -1
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": {
@@ -221,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,
},
}
+14 -9
View File
@@ -12,29 +12,34 @@ from dvc.utils import fix_env, is_binary
logger = logging.getLogger(__name__)
CREATE_NEW_PROCESS_GROUP = 0x00000200
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000 # only available since Python 3.7 in subprocess.
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,
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):
+1 -1
View File
@@ -59,7 +59,7 @@ class DataCloud:
from dvc.objects.db import get_odb
cls, config, path_info = get_cloud_fs(self.repo, name=name)
config["tmp_dir"] = self.repo.tmp_dir
config["tmp_dir"] = self.repo.index_db_dir
return get_odb(cls(**config), path_info, **config)
def push(
+1 -1
View File
@@ -63,7 +63,7 @@ class RepoDependency(Dependency):
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
def download(self, to, jobs=None):
from dvc.checkout import checkout
from dvc.objects.checkout import checkout
for odb, objs in self.get_used_objs().items():
self.repo.cloud.pull(objs, jobs=jobs, odb=odb)
+34 -52
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):
@@ -298,10 +295,7 @@ class PipelineFile(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)
if stage.name not in d.get("stages", {}):
return
@@ -343,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):
@@ -409,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:
+1
View File
@@ -9,3 +9,4 @@ 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"
+5
View File
@@ -364,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."""
+1
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
+2 -2
View File
@@ -11,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 = (
@@ -39,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"
+123 -92
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__)
@@ -125,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:
@@ -174,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
@@ -221,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(
@@ -265,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)
+13 -3
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
@@ -256,8 +257,17 @@ class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
return ret
def _download(self, from_info, to_file, **kwargs):
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
fs, path = self._get_fs_path(from_info)
fs._download( # pylint: disable=protected-access
path, to_file, **kwargs
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
+186 -103
View File
@@ -1,13 +1,37 @@
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
from .local import LocalFileSystem
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
@@ -19,18 +43,8 @@ class FSSpecWrapper(BaseFileSystem):
self.fs_args = {"skip_instance_cache": True}
self.fs_args.update(self._prepare_credentials(**kwargs))
@cached_property
def fs(self):
raise NotImplementedError
def _with_bucket(self, path):
return str(path)
def _strip_bucket(self, entry):
return entry
@staticmethod
def _get_kwargs_from_urls(urlpath):
def _get_kwargs_from_urls(urlpath: str) -> "Dict[str, Any]":
from fsspec.utils import infer_storage_options
options = infer_storage_options(urlpath)
@@ -38,6 +52,28 @@ class FSSpecWrapper(BaseFileSystem):
options.pop("protocol", None)
return options
@cached_property
def fs(self):
raise NotImplementedError
def _with_bucket(self, path: AnyFSPath) -> str:
return str(path)
def _strip_bucket(self, entry: str) -> str:
return entry
@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:
if detail:
@@ -47,56 +83,91 @@ class FSSpecWrapper(BaseFileSystem):
entry = self._strip_bucket(entry)
yield entry
def _entry_hook(self, entry):
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)
@@ -106,63 +177,56 @@ 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 move(self, from_info, to_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 remove(self, path_info):
def remove(self, path_info: FSPath) -> None:
self.fs.rm_file(self._with_bucket(path_info))
def info(self, 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 makedirs(self, path_info, **kwargs):
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_fobj(self, fobj, to_info, size=None):
self.makedirs(to_info.parent)
with self.open(to_info, "wb") as fdest:
shutil.copyfileobj(fobj, fdest, length=fdest.blocksize)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **kwargs
):
self.makedirs(to_info.parent)
size = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
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
@@ -170,21 +234,21 @@ class ObjectFSWrapper(FSSpecWrapper):
TRAVERSE_PREFIX_LEN = 3
@lru_cache(512)
def _with_bucket(self, path):
def _with_bucket(self, path: AnyFSPath) -> str:
if isinstance(path, self.PATH_CLS):
return f"{path.bucket}/{path.path}"
return path
return str(path)
def _strip_bucket(self, entry):
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, None
bucket, path = entry, ""
return path or bucket
def makedirs(self, path_info, **kwargs):
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
@@ -192,7 +256,7 @@ class ObjectFSWrapper(FSSpecWrapper):
# simply ignore all mkdir()/makedirs() calls.
return None
def _isdir(self, path_info):
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
@@ -217,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
@@ -227,45 +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
)
_LOCAL_FS = LocalFileSystem()
# 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:
"""Use the native ``get_file()``/``put_file()`` APIs
if the target filesystem supports callbacks."""
"""Provides callback support for the filesystem that don't support yet."""
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,
):
with Tqdm(
desc=name,
disable=no_progress_bar,
bytes=True,
total=-1,
**pbar_args,
) as pbar:
self.fs.put_file(
os.fspath(from_file),
self._with_bucket(to_info),
callback=pbar.as_callback(_LOCAL_FS, from_file),
)
self.fs.invalidate_cache(self._with_bucket(to_info.parent))
"""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 _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,
):
with Tqdm(
desc=name,
disable=no_progress_bar,
bytes=True,
total=-1,
**pbar_args,
) as pbar:
self.fs.get_file(
self._with_bucket(from_info),
os.fspath(to_file),
callback=pbar.as_callback(self, from_info),
)
# 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)
+13 -6
View File
@@ -12,7 +12,7 @@ from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from dvc.utils import format_link, tmp_fname
from .fsspec_wrapper import CallbackMixin, FSSpecWrapper
from .fsspec_wrapper import FSSpecWrapper
logger = logging.getLogger(__name__)
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
@@ -79,9 +79,7 @@ class GDriveURLInfo(CloudURLInfo):
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
class GDriveFileSystem(
CallbackMixin, FSSpecWrapper
): # pylint:disable=abstract-method
class GDriveFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
scheme = Schemes.GDRIVE
PATH_CLS = GDriveURLInfo
PARAM_CHECKSUM = "checksum"
@@ -298,7 +296,16 @@ class GDriveFileSystem(
return super()._with_bucket(path)
def _upload_fobj(self, fobj, to_info, size: int = None):
def _strip_bucket(self, entry):
try:
bucket, path = entry.split("/", 1)
except ValueError:
# If there is no path attached, only returns
# the bucket (top-level).
bucket, path = entry, None
return path or bucket
def 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, size=size)
return self.fs.upload_fobj(fobj, rpath, **kwargs)
+18 -24
View File
@@ -1,9 +1,11 @@
import errno
import os
import stat
from tqdm.utils import CallbackIOWrapper
from dvc.utils import is_exec, relpath
from ..progress import DEFAULT_CALLBACK
from .base import BaseFileSystem
@@ -96,30 +98,20 @@ 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,
"type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
}
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):
@@ -127,16 +119,18 @@ class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
# NOTE: os.path.join is ~5.5 times slower
yield f"{root}{os.sep}{file}"
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
import shutil
from dvc.progress import Tqdm
total = self.getsize(from_info)
if total:
callback.set_size(total)
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)
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 -5
View File
@@ -6,17 +6,16 @@ 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"}
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
TRAVERSE_PREFIX_LEN = 2
def _prepare_credentials(self, **config):
login_info = {"consistency": None}
+64 -257
View File
@@ -1,283 +1,90 @@
import errno
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, **kwargs):
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:
from pyarrow.fs import FileType
file_info = hdfs.get_file_info(path_info.path)
if file_info.type is FileType.Directory:
kind = "directory"
elif file_info.type is FileType.File:
kind = "file"
else:
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path_info
)
return {"size": file_info.size, "type": kind}
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 -155
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,171 +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
REQUIRES = {"requests": "requests"}
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, "type": "file"}
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
+17 -26
View File
@@ -1,6 +1,5 @@
import logging
import os
import stat
from dvc.path_info import PathInfo
from dvc.scheme import Schemes
@@ -8,6 +7,7 @@ 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__)
@@ -34,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)
@@ -83,12 +88,9 @@ class LocalFileSystem(BaseFileSystem):
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 (
isinstance(from_info, PathInfo) and from_info.scheme != "local"
@@ -107,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:
@@ -154,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": "directory" 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)
+2 -2
View File
@@ -7,13 +7,13 @@ from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from .fsspec_wrapper import CallbackMixin, ObjectFSWrapper
from .fsspec_wrapper import ObjectFSWrapper
logger = logging.getLogger(__name__)
# pylint:disable=abstract-method
class OSSFileSystem(CallbackMixin, ObjectFSWrapper):
class OSSFileSystem(ObjectFSWrapper):
scheme = Schemes.OSS
PATH_CLS = CloudURLInfo
REQUIRES = {"ossfs": "ossfs"}
+25 -19
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
@@ -256,8 +256,8 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
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
@@ -287,8 +287,8 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
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
@@ -313,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)
@@ -453,19 +449,21 @@ class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
for fname in files:
yield PathInfo(root) / fname
def _download(self, from_info, to_file, **kwargs):
def get_file(
self, from_info, to_file, callback=DEFAULT_CALLBACK, **kwargs
):
fs, dvc_fs = self._get_fs_pair(from_info)
try:
fs._download( # pylint: disable=protected-access
from_info, to_file, **kwargs
fs.get_file( # pylint: disable=protected-access
from_info, to_file, callback=callback, **kwargs
)
return
except FileNotFoundError:
if not dvc_fs:
raise
dvc_fs._download( # pylint: disable=protected-access
from_info, to_file, **kwargs
dvc_fs.get_file( # pylint: disable=protected-access
from_info, to_file, callback=callback, **kwargs
)
def metadata(self, path):
@@ -478,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
@@ -492,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):
@@ -508,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)
+30 -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)
@@ -196,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)
+29 -24
View File
@@ -2,13 +2,14 @@ import getpass
import os.path
import threading
from funcy import cached_property, first, memoize, silent, wrap_prop, wrap_with
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 .fsspec_wrapper import CallbackMixin, FSSpecWrapper
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"))
@@ -26,7 +27,7 @@ def ask_password(host, user, port):
# pylint:disable=abstract-method
class SSHFileSystem(CallbackMixin, FSSpecWrapper):
class SSHFileSystem(FSSpecWrapper):
scheme = Schemes.SSH
REQUIRES = {"sshfs": "sshfs"}
@@ -35,10 +36,8 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
path_str = path.path
else:
path_str = super()._with_bucket(path)
return os.path.normpath(path_str)
return path.path
return super()._with_bucket(path)
def _prepare_credentials(self, **config):
self.CAN_TRAVERSE = True
@@ -66,16 +65,25 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
or self.DEFAULT_PORT
)
login_info["password"] = config.get("password")
if user_ssh_config.get("IdentityFile"):
config.setdefault(
"keyfile", first(user_ssh_config.get("IdentityFile"))
if config.get("ask_password") and config.get("password") is None:
config["password"] = ask_password(
login_info["host"], login_info["username"], login_info["port"]
)
keyfile = config.get("keyfile")
if keyfile:
login_info["client_keys"] = [os.path.expanduser(keyfile)]
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
@@ -96,11 +104,6 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
login_info["agent_forwarding"] = config.get("agent_forwarding", True)
login_info["proxy_command"] = user_ssh_config.get("ProxyCommand")
if config.get("ask_password") and login_info["password"] is None:
login_info["password"] = ask_password(
login_info["host"], login_info["username"], login_info["port"]
)
# We are going to automatically add stuff to known_hosts
# something like paramiko's AutoAddPolicy()
login_info["known_hosts"] = None
@@ -116,10 +119,12 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
# Ensure that if an interrupt happens during the transfer, we don't
# pollute the cache.
def _upload_fobj(self, fobj, to_info, *args, **kwargs):
def upload_fobj(self, fobj, to_info, **kwargs):
with as_atomic(self, to_info) as tmp_file:
super()._upload_fobj(fobj, tmp_file, *args, **kwargs)
super().upload_fobj(fobj, tmp_file, **kwargs)
def _upload(self, from_file, to_info, *args, **kwargs):
def put_file(
self, from_file, to_info, callback=DEFAULT_CALLBACK, **kwargs
):
with as_atomic(self, to_info) as tmp_file:
super()._upload(from_file, tmp_file, *args, **kwargs)
super().put_file(from_file, tmp_file, callback=callback, **kwargs)
+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 -7
View File
@@ -1,5 +1,4 @@
import logging
import os
import threading
from functools import lru_cache
@@ -71,15 +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
)
return self.fs.upload_fileobj(fobj, rpath, overwrite=True, size=size)
@lru_cache(512)
def translate_path_info(self, path):
@@ -87,7 +84,7 @@ class WebDAVFileSystem(FSSpecWrapper): # pylint:disable=abstract-method
return path.path[len(self.prefix) :].lstrip("/")
return path
_with_bucket = translate_path_info
_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"], "type": "file"}
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"]
+1 -2
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
@@ -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
+8 -5
View File
@@ -136,11 +136,14 @@ def _get_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()
+13
View File
@@ -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")
+202
View File
@@ -0,0 +1,202 @@
import logging
import os
from typing import (
TYPE_CHECKING,
Dict,
Iterable,
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"]) 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)
View File
+50
View File
@@ -0,0 +1,50 @@
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):
raise NotImplementedError
@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."""
+35
View File
@@ -0,0 +1,35 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING, Iterator, Optional
from tpi import TerraformProviderIterative, terraform
from dvc.fs.ssh import SSHFileSystem
from .base import BaseMachineBackend
if TYPE_CHECKING:
from dvc.repo.experiments.executor.base import BaseExecutor
from dvc.types import StrPath
class TerraformBackend(terraform.TerraformBackend, BaseMachineBackend):
def __init__(self, tmp_dir: "StrPath", **kwargs):
super().__init__(tmp_dir)
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"]:
resource = self._default_resource(name)
with TerraformProviderIterative.pemfile(resource) as pem:
fs = SSHFileSystem(
host=resource["instance_ip"],
user="ubuntu",
keyfile=pem,
)
yield fs
+106 -157
View File
@@ -1,4 +1,5 @@
import logging
from itertools import chain
from shortuuid import uuid
@@ -10,52 +11,19 @@ from dvc.exceptions import (
DvcException,
)
from dvc.ignore import DvcIgnoreFilter
from dvc.objects import check, load
from dvc.objects.db.slow_link_detection import ( # type: ignore[attr-defined]
slow_link_guard,
)
from dvc.objects.errors import ObjectFormatError
from dvc.objects.diff import ROOT
from dvc.objects.diff import diff as odiff
from dvc.objects.stage import stage
from dvc.objects.tree import Tree
from dvc.types import Optional
logger = logging.getLogger(__name__)
def _changed(path_info, fs, obj, cache, state=None):
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
actual = state.get(path_info, fs) if state else None
if actual is None:
try:
actual = stage(
cache, path_info, fs, obj.hash_info.name, dry_run=True
)[1].hash_info
except FileNotFoundError:
logger.debug("'%s' doesn't exist.", path_info)
return True
if obj.hash_info != actual:
logger.debug(
"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
@@ -63,13 +31,7 @@ def _remove(path_info, fs, cache, force=False):
fs.remove(path_info)
return
current = stage(cache, path_info, fs, fs.PARAM_CHECKSUM, dry_run=True)[
1
].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?"
@@ -98,7 +60,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)
@@ -130,7 +92,7 @@ def _link(cache, from_info, to_info):
try:
_try_links(cache, from_info, to_info, cache.cache_types)
except FileNotFoundError as exc:
raise CheckoutError([to_info]) from exc
raise CheckoutError([str(to_info)]) from exc
def _cache_is_copy(cache, path_info):
@@ -160,7 +122,7 @@ def _cache_is_copy(cache, path_info):
def _checkout_file(
path_info,
fs,
obj,
change,
cache,
force,
progress_callback=None,
@@ -169,24 +131,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, state=state):
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.obj.hash_info.value)
if change.old.obj:
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.obj.hash_info)
if progress_callback:
progress_callback(str(path_info))
@@ -194,109 +156,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)
failed = []
for entry_key, entry_obj in obj:
try:
entry_modified = _checkout_file(
path_info.joinpath(*entry_key),
fs,
entry_obj,
cache,
force,
progress_callback,
relink,
state=None,
)
if entry_modified:
modified = True
except CheckoutError as exc:
failed.extend(exc.target_infos)
modified = (
_remove_redundant_files(
path_info, fs, obj, cache, force, dvcignore=dvcignore
old = None
try:
_, old = stage(
cache,
path_info,
fs,
obj.hash_info.name if obj else cache.fs.PARAM_CHECKSUM,
dry_run=True,
dvcignore=dvcignore,
)
or modified
)
except FileNotFoundError:
pass
if failed:
raise CheckoutError(failed)
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 isinstance(change.new.obj, Tree):
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(
@@ -311,41 +247,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, state=state):
failed.append(str(path_info))
elif not diff:
logger.trace("Data '%s' didn't change.", path_info) # type: ignore
skip = True
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
+6 -39
View File
@@ -1,12 +1,10 @@
import itertools
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from contextlib import suppress
from copy import copy
from typing import TYPE_CHECKING, Optional
from dvc.fs.local import LocalFileSystem
from dvc.objects.errors import ObjectDBPermissionError, ObjectFormatError
from dvc.objects.file import HashFile
from dvc.progress import Tqdm
@@ -85,38 +83,12 @@ class ObjectDB:
_hash_info: "HashInfo",
move: bool = False,
):
from dvc import fs
self.makedirs(to_info.parent)
use_move = isinstance(from_fs, type(self.fs)) and move
try:
if use_move:
self.fs.move(from_info, to_info)
elif isinstance(from_fs, LocalFileSystem):
if not isinstance(from_info, from_fs.PATH_CLS):
from_info = from_fs.PATH_CLS(from_info)
self.fs.upload(from_info, to_info)
elif isinstance(self.fs, LocalFileSystem):
from_fs.download_file(from_info, to_info)
else:
with from_fs.open(from_info, mode="rb") as fobj:
self.fs.upload_fobj(fobj, to_info)
except OSError as exc:
# If the target file already exists, we are going to simply
# ignore the exception (#4992).
#
# 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)
else:
raise
return fs.utils.transfer(
from_fs, from_info, self.fs, to_info, move=move
)
def add(
self,
@@ -125,7 +97,6 @@ class ObjectDB:
hash_info: "HashInfo",
move: bool = True,
verify: Optional[bool] = None,
**kwargs,
):
if self.read_only:
raise ObjectDBPermissionError("Cannot add to read-only ODB")
@@ -139,7 +110,7 @@ class ObjectDB:
pass
cache_info = self.hash_to_path_info(hash_info.value)
self._add_file(fs, path_info, cache_info, hash_info, move)
self._add_file(fs, path_info, cache_info, hash_info, move=move)
try:
if verify:
@@ -149,10 +120,6 @@ class ObjectDB:
except (ObjectFormatError, FileNotFoundError):
pass
callback = kwargs.get("download_callback")
if callback:
callback(1)
def hash_to_path_info(self, hash_) -> "DvcPath":
return self.path_info / hash_[0:2] / hash_[2:]
+1 -1
View File
@@ -70,7 +70,7 @@ class ReferenceObjectDB(ObjectDB):
ref_fobj = io.BytesIO(ref_file.to_bytes())
ref_fobj.seek(0)
try:
self.fs.upload_fobj(ref_fobj, to_info)
self.fs.upload(ref_fobj, to_info)
except OSError as exc:
if isinstance(exc, FileExistsError) or (
os.name == "nt"
+135
View File
@@ -0,0 +1,135 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List, Optional, Tuple
if TYPE_CHECKING:
from .file import HashFile
ADD = "add"
MODIFY = "modify"
DELETE = "delete"
UNCHANGED = "unchanged"
@dataclass
class TreeEntry:
in_cache: bool
key: Tuple[str]
obj: Optional["HashFile"] = field(default=None)
def __bool__(self):
return bool(self.obj)
def __eq__(self, other):
if not isinstance(other, TreeEntry):
return False
if self.key != other.key or bool(self.obj) != bool(other.obj):
return False
if not self.obj:
return False
return self.obj.hash_info == other.obj.hash_info
@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_obj(obj, key):
if not obj or key == ROOT:
return obj
return obj.get(key)
def _in_cache(obj, cache):
from . import check
from .errors import ObjectFormatError
if not obj:
return False
try:
check(cache, obj)
return True
except (FileNotFoundError, ObjectFormatError):
return False
ret = DiffResult()
for key in old_keys | new_keys:
old_obj = _get_obj(old, key)
new_obj = _get_obj(new, key)
change = Change(
old=TreeEntry(_in_cache(old_obj, cache), key, old_obj),
new=TreeEntry(_in_cache(new_obj, cache), key, new_obj),
)
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 isinstance(
change.new.obj, Tree
):
ret.modified.append(change)
else:
ret.unchanged.append(change)
return ret
+9 -22
View File
@@ -2,7 +2,7 @@ import errno
import logging
import os
import pickle
from typing import TYPE_CHECKING, Optional, Tuple
from typing import TYPE_CHECKING, Optional
from .errors import ObjectFormatError
from .file import HashFile
@@ -20,8 +20,7 @@ logger = logging.getLogger(__name__)
class ReferenceHashFile(HashFile):
PARAM_PATH = "path"
PARAM_HASH = "hash"
PARAM_MTIME = "mtime"
PARAM_SIZE = "size"
PARAM_CHECKSUM = "checksum"
PARAM_FS_CONFIG = "fs_config"
def __init__(
@@ -29,14 +28,11 @@ class ReferenceHashFile(HashFile):
path_info: "AnyPath",
fs: "BaseFileSystem",
hash_info: "HashInfo",
mtime: Optional[str] = None,
size: Optional[int] = None,
checksum: Optional[str] = None,
**kwargs,
):
super().__init__(path_info, fs, hash_info, **kwargs)
cur_mtime, cur_size = self._get_mtime_and_size()
self.mtime = cur_mtime if mtime is None else mtime
self.hash_info.size = cur_size if size is None else size
self.checksum = checksum or fs.checksum(path_info)
def __str__(self):
return f"ref object {self.hash_info} -> {self.path_info}"
@@ -47,21 +43,14 @@ class ReferenceHashFile(HashFile):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), self.path_info
)
if not (self.mtime, self.size) == self._get_mtime_and_size():
if self.checksum != self._get_checksum():
raise ObjectFormatError(f"{self} is changed")
if check_hash:
self._check_hash(odb)
def _get_mtime_and_size(self) -> Tuple[Optional[str], int]:
from dvc.utils.fs import get_mtime_and_size
def _get_checksum(self) -> str:
assert self.fs
if hasattr(self.fs, "stat"):
try:
return get_mtime_and_size(self.path_info, self.fs)
except FileNotFoundError:
pass
return None, self.fs.getsize(self.path_info)
return self.fs.checksum(self.path_info)
def to_bytes(self):
from dvc.fs.repo import RepoFileSystem
@@ -79,8 +68,7 @@ class ReferenceHashFile(HashFile):
dict_ = {
self.PARAM_PATH: path_info,
self.PARAM_HASH: self.hash_info,
self.PARAM_MTIME: self.mtime,
self.PARAM_SIZE: self.size,
self.PARAM_CHECKSUM: self.checksum,
self.PARAM_FS_CONFIG: self.config_tuple(self.fs),
}
try:
@@ -118,8 +106,7 @@ class ReferenceHashFile(HashFile):
path_info,
fs,
hash_info,
mtime=dict_.get(cls.PARAM_MTIME),
size=dict_.get(cls.PARAM_SIZE),
checksum=dict_.get(cls.PARAM_CHECKSUM),
)
@staticmethod
+11 -14
View File
@@ -34,9 +34,8 @@ def _upload_file(path_info, fs, odb, upload_odb):
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)
upload_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)
odb.add(tmp_info, upload_odb.fs, stream.hash_info)
return path_info, odb.get(stream.hash_info)
@@ -46,18 +45,16 @@ 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
return HashInfo(name, hash_value, size=info["size"])
def get_file_hash(path_info, fs, name, state=None):
+40 -107
View File
@@ -1,20 +1,19 @@
import errno
import itertools
import logging
from concurrent import futures
from concurrent.futures import ThreadPoolExecutor
from functools import partial, wraps
from typing import TYPE_CHECKING, Callable, Iterable, Optional
from 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__)
@@ -38,109 +37,48 @@ def _log_exceptions(func):
return wrapper
def _transfer(
src, dest, dir_ids, file_ids, missing_ids, jobs, verify, move, **kwargs
):
func = _log_exceptions(dest.add)
total = len(dir_ids) + len(file_ids)
if total == 0:
return 0
with Tqdm(total=total, unit="file", desc="Transferring") as pbar:
func = pbar.wrap_fn(func)
with ThreadPoolExecutor(max_workers=jobs) as executor:
processor = partial(
_create_tasks,
executor,
jobs,
func,
src,
verify,
move,
)
processor.add_func = func
_do_transfer(
src,
dest,
dir_ids,
file_ids,
missing_ids,
processor,
verify=verify,
move=move,
**kwargs,
)
return total
def find_tree_by_obj_id(
odbs: Iterable[Optional["ObjectDB"]], obj_id: "HashInfo"
) -> Optional["Tree"]:
from .errors import ObjectFormatError
from .tree import Tree
def _create_tasks(executor, jobs, func, src, verify, move, obj_ids):
fails = 0
hash_iter = iter(obj_ids)
def submit(hash_info):
obj = src.get(hash_info)
return executor.submit(
func,
obj.path_info,
obj.fs,
obj.hash_info,
verify=verify,
move=move,
)
def create_taskset(amount):
return {
submit(hash_info)
for hash_info in itertools.islice(hash_iter, amount)
}
tasks = create_taskset(jobs * 5)
while tasks:
done, tasks = futures.wait(tasks, return_when=futures.FIRST_COMPLETED)
fails += sum(task.result() for task in done)
tasks.update(create_taskset(len(done)))
return fails
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",
dir_ids: Iterable["HashInfo"],
file_ids: Iterable["HashInfo"],
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,
**kwargs: Any,
):
from dvc.exceptions import FileTransferError
from dvc.objects.errors import ObjectFormatError
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:
from .tree import Tree
bound_file_ids = set()
dir_obj: Optional["Tree"] = None
for odb in (cache_odb, src):
if odb is not None:
try:
dir_obj = Tree.load(odb, dir_hash)
break
except (FileNotFoundError, ObjectFormatError):
pass
dir_obj = find_tree_by_obj_id([cache_odb, src], dir_hash)
assert dir_obj
entry_ids = {entry.hash_info for _, entry in dir_obj}
bound_file_ids = all_file_ids & entry_ids
all_file_ids -= entry_ids
for file_hash in all_file_ids.copy():
if file_hash in entry_ids:
bound_file_ids.add(file_hash)
all_file_ids.remove(file_hash)
dir_fails = processor(bound_file_ids)
dir_fails = sum(processor(bound_file_ids))
if dir_fails:
logger.debug(
"failed to upload full contents of '%s', "
@@ -164,19 +102,13 @@ def _do_transfer(
dir_obj.name,
)
else:
raw_obj = src.get(dir_obj.hash_info)
is_dir_failed = processor.add_func( # type: ignore[attr-defined]
raw_obj.path_info,
raw_obj.fs,
raw_obj.hash_info,
**kwargs,
)
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 += processor(all_file_ids)
total_fails += sum(processor(all_file_ids))
if total_fails:
if src_index:
src_index.clear()
@@ -222,18 +154,19 @@ def transfer(
if not status.new:
return 0
dir_ids, file_ids = split(lambda hash_info: hash_info.isdir, status.new)
if jobs is None:
jobs = dest.fs.jobs
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
)
return _transfer(
src,
dest,
set(dir_ids),
set(file_ids),
status.missing,
jobs,
verify,
move,
**kwargs,
)
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
+31 -13
View File
@@ -9,7 +9,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 +16,7 @@ from dvc.exceptions import (
MergeError,
RemoteCacheRequiredError,
)
from dvc.objects.checkout import checkout
from .fs import get_cloud_fs
from .fs.hdfs import HDFSFileSystem
@@ -81,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,
@@ -94,6 +95,7 @@ def loadd_from(stage, d_list):
desc=desc,
isexec=isexec,
live=live,
remote=remote,
)
)
return ret
@@ -109,6 +111,7 @@ def loads_from(
checkpoint=False,
isexec=False,
live=False,
remote=None,
):
return [
_get(
@@ -122,6 +125,7 @@ def loads_from(
checkpoint=checkpoint,
isexec=isexec,
live=live,
remote=remote,
)
for s in s_list
]
@@ -162,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]
@@ -185,6 +188,7 @@ def load_from_pipeline(stage, data, typ="outs"):
Output.PARAM_CACHE,
Output.PARAM_PERSIST,
Output.PARAM_CHECKPOINT,
Output.PARAM_REMOTE,
],
)
@@ -255,6 +259,7 @@ class Output:
PARAM_LIVE = "live"
PARAM_LIVE_SUMMARY = "summary"
PARAM_LIVE_HTML = "html"
PARAM_REMOTE = "remote"
METRIC_SCHEMA = Any(
None,
@@ -283,6 +288,7 @@ class Output:
live=False,
desc=None,
isexec=False,
remote=None,
):
self.repo = stage.repo if stage else None
@@ -326,7 +332,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":
@@ -843,6 +849,8 @@ class Output:
try:
objects.check(self.odb, obj)
except FileNotFoundError:
if self.remote:
kwargs["remote"] = self.remote
self.repo.cloud.pull([obj.hash_info], **kwargs)
if self.obj:
@@ -855,9 +863,9 @@ 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["HashInfo"]]:
) -> Optional["Tree"]:
"""Fetch dir cache and return used object IDs for this out."""
try:
@@ -878,13 +886,13 @@ class Output:
"unable to fully collect used cache"
" without cache for directory '{}'".format(self)
)
return {}
return None
obj = self.get_obj()
if filter_info and filter_info != self.path_info:
prefix = filter_info.relative_to(self.path_info).parts
obj = obj.filter(prefix)
return {None: set(self._named_obj_ids(obj))}
return obj
def get_used_objs(
self, **kwargs
@@ -917,22 +925,31 @@ 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)
return {}
return {None: set(self._named_obj_ids(obj))}
if self.remote:
remote = self.repo.cloud.get_remote_odb(name=self.remote)
else:
remote = None
return {remote: self._named_obj_ids(obj)}
def _named_obj_ids(self, obj):
name = str(self)
obj.hash_info.obj_name = name
yield obj.hash_info
oids = {obj.hash_info}
if isinstance(obj, Tree):
for key, entry_obj in obj:
entry_obj.hash_info.obj_name = self.fs.sep.join([name, *key])
yield entry_obj.hash_info
oids.add(entry_obj.hash_info)
return oids
def get_used_external(
self, **kwargs
@@ -1033,4 +1050,5 @@ SCHEMA = {
Output.PARAM_CACHE: bool,
Output.PARAM_METRIC: Output.METRIC_SCHEMA,
Output.PARAM_DESC: str,
Output.PARAM_REMOTE: str,
}
+35 -13
View File
@@ -1,5 +1,4 @@
"""Manages progress bars for DVC repo."""
import logging
import sys
from threading import RLock
@@ -137,8 +136,8 @@ class Tqdm(tqdm):
return wrapped
def as_callback(self, fs, path_info):
return FsspecCallback(fs, path_info, self)
def as_callback(self):
return FsspecCallback(self)
def close(self):
self.postfix["info"] = ""
@@ -168,23 +167,46 @@ class Tqdm(tqdm):
class FsspecCallback(fsspec.Callback):
def __init__(self, fs, path_info, progress_bar):
self.fs = fs
self.path_info = path_info
def __init__(self, progress_bar):
self.progress_bar = progress_bar
super().__init__()
def set_size(self, size):
"""``set_size()`` is an API that might be called with ``None`` if the
information is not already present on the caller. In that case, we'll
retrieve the size from an ``info()`` call."""
if size is None:
size = self.fs.info(self.path_info)["size"]
self.progress_bar.total = size
self.progress_bar.refresh()
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()
+33
View File
@@ -0,0 +1,33 @@
import logging
from typing import TYPE_CHECKING, Dict
if TYPE_CHECKING:
from dvc.types import StrPath
logger = logging.getLogger(__name__)
class Renderer:
def __init__(self, data: Dict):
self.data = data
from dvc.render.utils import get_files
files = get_files(self.data)
# we assume comparison of same file between revisions for now
assert len(files) == 1
self.filename = files.pop()
def _convert(self, path: "StrPath"):
raise NotImplementedError
@property
def DIV(self):
raise NotImplementedError
def generate_html(self, path: "StrPath"):
"""this method might edit content of path"""
partial = self._convert(path)
div_id = f"plot_{self.filename.replace('.', '_').replace('/', '_')}"
return self.DIV.format(id=div_id, partial=partial)
+39 -28
View File
@@ -1,27 +1,27 @@
from typing import Dict, List, Optional
import os
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional
from dvc.exceptions import DvcException
from dvc.types import StrPath
if TYPE_CHECKING:
from dvc.render import Renderer
from dvc.types import StrPath
PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
{refresh_tag}
<title>DVC Plot</title>
<script src="https://cdn.jsdelivr.net/npm/vega@5.10.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@4.8.1"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.5.1"></script>
<script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.18.2"></script>
</head>
<body>
{plot_divs}
</body>
</html>"""
VEGA_DIV_HTML = """<div id = "{id}"></div>
<script type = "text/javascript">
var spec = {vega_json};
vegaEmbed('#{id}', spec);
</script>"""
class MissingPlaceholderError(DvcException):
def __init__(self, placeholder):
@@ -31,14 +31,23 @@ class MissingPlaceholderError(DvcException):
class HTML:
PLACEHOLDER = "plot_divs"
PLACEHOLDER_FORMAT_STR = f"{{{PLACEHOLDER}}}"
REFRESH_PLACEHOLDER = "refresh_tag"
REFRESH_TAG = '<meta http-equiv="refresh" content="{}">'
def __init__(self, template: str = None):
def __init__(
self,
template: Optional[str] = None,
refresh_seconds: Optional[int] = None,
):
template = template or PAGE_HTML
if self.PLACEHOLDER_FORMAT_STR not in template:
raise MissingPlaceholderError(self.PLACEHOLDER_FORMAT_STR)
self.template = template
self.elements: List[str] = []
self.refresh_tag = ""
if refresh_seconds is not None:
self.refresh_tag = self.REFRESH_TAG.format(refresh_seconds)
def with_metrics(self, metrics: Dict[str, Dict]) -> "HTML":
import tabulate
@@ -56,41 +65,43 @@ class HTML:
self.elements.append(tabulate.tabulate(rows, header, tablefmt="html"))
return self
def with_plots(self, plots: Dict[str, Dict]) -> "HTML":
self.elements.extend(
[
VEGA_DIV_HTML.format(id=f"plot{i}", vega_json=plot)
for i, plot in enumerate(plots.values())
]
)
return self
def with_element(self, html: str) -> "HTML":
self.elements.append(html)
return self
def embed(self) -> str:
kwargs = {self.PLACEHOLDER: "\n".join(self.elements)}
kwargs = {
self.PLACEHOLDER: "\n".join(self.elements),
self.REFRESH_PLACEHOLDER: self.refresh_tag,
}
return self.template.format(**kwargs)
def write(
path: StrPath,
plots: Dict[str, Dict],
path: "StrPath",
renderers: List["Renderer"],
metrics: Optional[Dict[str, Dict]] = None,
template_path: Optional[StrPath] = None,
template_path: Optional["StrPath"] = None,
refresh_seconds: Optional[int] = None,
):
os.makedirs(path, exist_ok=True)
page_html = None
if template_path:
with open(template_path) as fobj:
page_html = fobj.read()
document = HTML(page_html)
document = HTML(page_html, refresh_seconds=refresh_seconds)
if metrics:
document.with_metrics(metrics)
document.with_element("<br>")
document.with_plots(plots)
for renderer in renderers:
document.with_element(renderer.generate_html(path))
with open(path, "w") as fd:
index = Path(os.path.join(path, "index.html"))
with open(index, "w") as fd:
fd.write(document.embed())
return index
+63
View File
@@ -0,0 +1,63 @@
import os
from typing import TYPE_CHECKING
from dvc.render import Renderer
from dvc.render.utils import get_files
from dvc.utils import relpath
if TYPE_CHECKING:
from dvc.types import StrPath
class ImageRenderer(Renderer):
DIV = """
<div
id="{id}"
style="border: 1px solid;">
{partial}
</div>"""
def _write_image(
self,
page_dir_path: "StrPath",
revision: str,
filename: str,
image_data: bytes,
):
static = os.path.join(page_dir_path, "static")
os.makedirs(static, exist_ok=True)
img_path = os.path.join(
static, f"{revision}_{filename.replace(os.sep, '_')}"
)
with open(img_path, "wb") as fd:
fd.write(image_data)
return """
<div>
<p>{title}</p>
<img src="{src}">
</div>""".format(
title=revision, src=(relpath(img_path, page_dir_path))
)
def _convert(self, path: "StrPath"):
div_content = []
for rev, rev_data in self.data.items():
if "data" in rev_data:
for file, file_data in rev_data.get("data", {}).items():
if "data" in file_data:
div_content.append(
self._write_image(
path, rev, file, file_data["data"]
)
)
if div_content:
div_content.insert(0, f"<p>{self.filename}</p>")
return "\n".join(div_content)
return ""
@staticmethod
def matches(data):
files = get_files(data)
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
return extensions.issubset({".jpg", ".jpeg", ".gif", ".png"})
+71
View File
@@ -0,0 +1,71 @@
import os.path
from typing import Dict, List
import dpath
def get_files(data: Dict) -> List:
files = set()
for rev in data.keys():
for file in data[rev].get("data", {}).keys():
files.add(file)
sorted_files = sorted(files)
return sorted_files
def group_by_filename(plots_data: Dict) -> List[Dict]:
files = get_files(plots_data)
grouped = []
for file in files:
grouped.append(dpath.util.search(plots_data, ["*", "*", file]))
return grouped
def find_vega(repo, plots_data, target):
found = dpath.util.search(plots_data, ["*", "*", target])
from dvc.render.vega import VegaRenderer
if found and VegaRenderer.matches(found):
return VegaRenderer(found, repo.plots.templates).get_vega()
return ""
def match_renderers(plots_data, templates):
from dvc.render.image import ImageRenderer
from dvc.render.vega import VegaRenderer
renderers = []
for g in group_by_filename(plots_data):
if VegaRenderer.matches(g):
renderers.append(VegaRenderer(g, templates))
if ImageRenderer.matches(g):
renderers.append(ImageRenderer(g))
return renderers
def render(
repo,
plots_data,
metrics=None,
path=None,
html_template_path=None,
refresh_seconds=None,
):
# TODO we could probably remove repo usages (here and in VegaRenderer)
renderers = match_renderers(plots_data, repo.plots.templates)
if not html_template_path:
html_template_path = repo.config.get("plots", {}).get(
"html_template", None
)
if html_template_path and not os.path.isabs(html_template_path):
html_template_path = os.path.join(repo.dvc_dir, html_template_path)
from dvc.render.html import write
return write(
path,
renderers,
metrics=metrics,
template_path=html_template_path,
refresh_seconds=refresh_seconds,
)
+175
View File
@@ -0,0 +1,175 @@
import os
from copy import copy, deepcopy
from typing import TYPE_CHECKING, Dict, List, Optional, Union
from funcy import first
from dvc.exceptions import DvcException
from dvc.render import Renderer
from dvc.render.utils import get_files
if TYPE_CHECKING:
from dvc.repo.plots.template import PlotTemplates
from dvc.types import StrPath
REVISION_FIELD = "rev"
INDEX_FIELD = "step"
class PlotMetricTypeError(DvcException):
def __init__(self, file):
super().__init__(
"'{}' - file type error\n"
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
)
class PlotDataStructureError(DvcException):
def __init__(self):
super().__init__(
"Plot data extraction failed. Please see "
"https://man.dvc.org/plots for supported data formats."
)
def _filter_fields(
datapoints: List[Dict], filename, revision, fields=None
) -> List[Dict]:
if not fields:
return datapoints
assert isinstance(fields, set)
new_data = []
for data_point in datapoints:
new_dp = copy(data_point)
keys = set(data_point.keys())
if keys & fields != fields:
raise DvcException(
"Could not find fields: '{}' for '{}' at '{}'.".format(
", ".join(fields), filename, revision
)
)
to_del = keys - fields
for key in to_del:
del new_dp[key]
new_data.append(new_dp)
return new_data
def _lists(dictionary):
for _, value in dictionary.items():
if isinstance(value, dict):
yield from _lists(value)
elif isinstance(value, list):
yield value
def _find_data(data: Union[Dict, List], fields=None) -> List[Dict]:
if not isinstance(data, dict):
return data
if not fields:
# just look for first list of dicts
fields = set()
for lst in _lists(data):
if (
all(isinstance(dp, dict) for dp in lst)
and set(first(lst).keys()) & fields == fields
):
return lst
raise PlotDataStructureError()
def _append_index(datapoints: List[Dict], append_index=False) -> List[Dict]:
if not append_index or INDEX_FIELD in first(datapoints).keys():
return datapoints
for index, data_point in enumerate(datapoints):
data_point[INDEX_FIELD] = index
return datapoints
def _append_revision(datapoints: List[Dict], revision) -> List[Dict]:
for data_point in datapoints:
data_point[REVISION_FIELD] = revision
return datapoints
class VegaRenderer(Renderer):
DIV = """
<div id = "{id}">
<script type = "text/javascript">
var spec = {partial};
vegaEmbed('#{id}', spec);
</script>
</div>
"""
def __init__(self, data: Dict, templates: "PlotTemplates"):
super().__init__(data)
self.templates = templates
def _squash_props(self) -> Dict:
resolved: Dict[str, str] = {}
for rev_data in self.data.values():
for file_data in rev_data.get("data", {}).values():
props = file_data.get("props", {})
resolved = {**resolved, **props}
return resolved
def _datapoints(self, props: Dict):
fields = props.get("fields", set())
if fields:
fields = {*fields, props.get("x"), props.get("y")} - {None}
datapoints = []
for revision, rev_data in self.data.items():
for filename, file_data in rev_data.get("data", {}).items():
if "data" in file_data:
tmp = deepcopy(file_data.get("data"))
tmp = _find_data(tmp, fields=fields - {INDEX_FIELD})
tmp = _append_index(
tmp, append_index=props.get("append_index", False)
)
tmp = _filter_fields(
tmp,
filename=filename,
revision=revision,
fields=fields,
)
tmp = _append_revision(tmp, revision=revision)
datapoints.extend(tmp)
return datapoints
def get_vega(self) -> Optional[str]:
props = self._squash_props()
template = self.templates.load(props.get("template") or "default")
if not props.get("x") and template.has_anchor("x"):
props["append_index"] = True
props["x"] = INDEX_FIELD
datapoints = self._datapoints(props)
if datapoints:
if not props.get("y") and template.has_anchor("y"):
fields = list(first(datapoints))
skip = (REVISION_FIELD, props.get("x"))
props["y"] = first(
f for f in reversed(fields) if f not in skip
)
return template.render(datapoints, props=props)
return None
def _convert(self, path: "StrPath"):
return self.get_vega()
@staticmethod
def matches(data):
files = get_files(data)
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
return extensions.issubset({".yml", ".yaml", ".json", ".csv", ".tsv"})
+44 -4
View File
@@ -12,6 +12,7 @@ from dvc.exceptions import IsADirectoryError as DvcIsADirectoryError
from dvc.exceptions import NotDvcRepoError, OutputNotFoundError
from dvc.ignore import DvcIgnoreFilter
from dvc.path_info import PathInfo
from dvc.utils import env2bool
from dvc.utils.fs import path_isin
if TYPE_CHECKING:
@@ -119,6 +120,34 @@ class Repo:
return root_dir, dvc_dir, tmp_dir
def _get_database_dir(self, db_name):
# NOTE: by default, store SQLite-based remote indexes and state's
# `links` and `md5s` caches in the repository itself to avoid any
# possible state corruption in 'shared cache dir' scenario, but allow
# user to override this through config when, say, the repository is
# located on a mounted volume — see
# https://github.com/iterative/dvc/issues/4420
base_db_dir = self.config.get(db_name, {}).get("dir", None)
if not base_db_dir:
return self.tmp_dir
import hashlib
from dvc.utils.fs import makedirs
root_dir_hash = hashlib.sha224(
self.root_dir.encode("utf-8")
).hexdigest()
db_dir = os.path.join(
base_db_dir,
self.DVC_DIR,
f"{os.path.basename(self.root_dir)}-{root_dir_hash[0:7]}",
)
makedirs(db_dir, exist_ok=True)
return db_dir
def __init__(
self,
root_dir=None,
@@ -134,6 +163,7 @@ class Repo:
from dvc.data_cloud import DataCloud
from dvc.fs.local import LocalFileSystem
from dvc.lock import LockNoop, make_lock
from dvc.machine import MachineManager
from dvc.objects.db import ODBManager
from dvc.repo.live import Live
from dvc.repo.metrics import Metrics
@@ -181,10 +211,8 @@ class Repo:
friendly=True,
)
# NOTE: storing state and link_state in the repository itself to
# avoid any possible state corruption in 'shared cache dir'
# scenario.
self.state = State(self.root_dir, self.tmp_dir, self.dvcignore)
state_db_dir = self._get_database_dir("state")
self.state = State(self.root_dir, state_db_dir, self.dvcignore)
self.odb = ODBManager(self)
self.stage_cache = StageCache(self)
@@ -196,6 +224,14 @@ class Repo:
self.params = Params(self)
self.live = Live(self)
if self.tmp_dir and (
self.config["feature"].get("machine", False)
or env2bool("DVC_TEST")
):
self.machine = MachineManager(self)
else:
self.machine = None
self.stage_collection_error_handler: Optional[
Callable[[str, Exception], None]
] = None
@@ -442,6 +478,10 @@ class Repo:
return RepoFileSystem(self, subrepos=self.subrepos, **self._fs_conf)
@cached_property
def index_db_dir(self):
return self._get_database_dir("index")
@contextmanager
def open_by_relpath(self, path, remote=None, mode="r", encoding=None):
"""Opens a specified resource as a file descriptor"""
-1
View File
@@ -49,7 +49,6 @@ def _collect_paths(
)
else:
logger.warning("'%s' was not found at: '%s'.", path_info, rev)
continue
target_infos.append(path_info)
return target_infos
+31 -30
View File
@@ -4,7 +4,6 @@ from collections import defaultdict
from typing import Dict, List
from dvc.exceptions import PathMissingError
from dvc.objects.stage import get_file_hash
from dvc.repo import locked
from dvc.repo.experiments.utils import fix_exp_head
@@ -45,7 +44,7 @@ def diff(self, a_rev="HEAD", b_rev=None, targets=None):
repo_fs, targets
)
results[rev] = _paths_checksums(self, repo_fs, targets_path_infos)
results[rev] = _paths_checksums(self, targets_path_infos)
if targets is not None:
# check for overlapping missing targets between a_rev and b_rev
@@ -98,7 +97,7 @@ def diff(self, a_rev="HEAD", b_rev=None, targets=None):
return ret if any(ret.values()) else {}
def _paths_checksums(repo, repo_fs, targets):
def _paths_checksums(repo, targets):
"""
A dictionary of checksums addressed by relpaths collected from
the current fs outputs.
@@ -110,10 +109,10 @@ def _paths_checksums(repo, repo_fs, targets):
file: "data"
"""
return dict(_output_paths(repo, repo_fs, targets))
return dict(_output_paths(repo, targets))
def _output_paths(repo, repo_fs, targets):
def _output_paths(repo, targets):
from dvc.fs.local import LocalFileSystem
from dvc.objects.stage import stage as ostage
@@ -131,45 +130,47 @@ def _output_paths(repo, repo_fs, targets):
else os.path.join(str(output), "")
)
def _to_checksum(output):
if on_working_fs:
return ostage(
repo.odb.local,
output.path_info,
repo.odb.local.fs,
"md5",
dry_run=True,
)[1].hash_info.value
return output.hash_info.value
for output in repo.index.outs:
if _exists(output):
yield_output = targets is None or any(
output.path_info.isin_or_eq(target) for target in targets
)
if on_working_fs:
_, obj = ostage(
repo.odb.local,
output.path_info,
repo.odb.local.fs,
"md5",
dry_run=True,
dvcignore=output.dvcignore,
)
hash_info = obj.hash_info
else:
hash_info = output.hash_info
obj = output.get_obj()
if yield_output:
yield _to_path(output), _to_checksum(output)
yield _to_path(output), hash_info.value
if not obj:
continue
if output.is_dir_checksum and (
yield_output
or any(target.isin(output.path_info) for target in targets)
):
yield from _dir_output_paths(repo_fs, output, targets)
yield from _dir_output_paths(output.path_info, obj, targets)
def _dir_output_paths(repo_fs, output, targets=None):
from dvc.config import NoRemoteError
try:
for fname in repo_fs.walk_files(output.path_info):
if targets is None or any(
fname.isin_or_eq(target) for target in targets
):
# pylint: disable=no-member
yield str(fname), get_file_hash(fname, repo_fs, "md5").value
except NoRemoteError:
logger.warning("dir cache entry for '%s' is missing", output)
def _dir_output_paths(path_info, obj, targets=None):
for key, entry_obj in obj:
fname = path_info.joinpath(*key)
if targets is None or any(
fname.isin_or_eq(target) for target in targets
):
# pylint: disable=no-member
yield str(fname), entry_obj.hash_info.value
def _filter_missing(repo_fs, paths):
+1 -1
View File
@@ -455,7 +455,7 @@ class Experiments:
)
logger.info(
"\nTo promote an experiment to a Git branch run:\n\n"
"\tdvc exp branch <exp>\n"
"\tdvc exp branch <exp> <branch>\n"
)
@scm_locked
+1
View File
@@ -348,6 +348,7 @@ class BaseExecutor(ABC):
quiet=True,
allow_missing=True,
checkpoint_reset=checkpoint_reset,
recursive=kwargs.get("recursive", False),
)
checkpoint_func = partial(
+193
View File
@@ -0,0 +1,193 @@
import os
from typing import TYPE_CHECKING, Dict, Iterable
from funcy import compact, lremove, post_processing
from rich.prompt import Prompt
from dvc.types import OptStr
if TYPE_CHECKING:
from dvc.repo import Repo
from dvc.dvcfile import DVCFile
from dvc.stage import Stage
from dvc.ui import ui
PROMPTS = {
"cmd": "[b]Command[/b] to execute",
"code": "Path to a [b]code[/b] file/directory",
"data": "Path to a [b]data[/b] file/directory",
"models": "Path to a [b]model[/b] file/directory",
"params": "Path to a [b]parameters[/b] file",
"metrics": "Path to a [b]metrics[/b] file",
"plots": "Path to a [b]plots[/b] file/directory",
"live": "Path to log [b]dvclive[/b] outputs",
}
class RequiredPrompt(Prompt):
def process_response(self, value: str):
from rich.prompt import InvalidResponse
ret = super().process_response(value)
if not ret:
raise InvalidResponse(
"[prompt.invalid]Response required. Please try again."
)
return ret
@classmethod
def get_input(cls, *args, **kwargs) -> str:
try:
return super().get_input(*args, **kwargs)
except (KeyboardInterrupt, EOFError):
ui.error_write()
raise
def render_default(self, default):
from rich.text import Text
return Text(f"{default!s}", "green")
class SkippablePrompt(RequiredPrompt):
skip_value: str = "n"
def process_response(self, value: str):
ret = super().process_response(value)
return None if ret == self.skip_value else ret
def make_prompt(self, default):
prompt = self.prompt.copy()
prompt.end = ""
prompt.append(" [")
if (
default is not ...
and self.show_default
and isinstance(default, (str, self.response_type))
):
_default = self.render_default(default)
prompt.append(_default)
prompt.append(", ")
prompt.append(f"{self.skip_value} to skip", style="italic")
prompt.append("]")
prompt.append(self.prompt_suffix)
return prompt
@post_processing(dict)
def _prompts(keys: Iterable[str], defaults: Dict[str, OptStr]):
for key in keys:
if key == "cmd":
prompt_cls = RequiredPrompt
else:
prompt_cls = SkippablePrompt
kwargs = {"default": defaults[key]} if key in defaults else {}
prompt = PROMPTS.get(key)
value = prompt_cls.ask(prompt, console=ui.error_console, **kwargs)
yield key, value
def init_interactive(
defaults: Dict[str, str],
provided: Iterable[str],
show_heading: bool = False,
live: bool = False,
) -> Dict[str, str]:
primary = lremove(provided, ["cmd", "code", "data", "models", "params"])
secondary = lremove(provided, ["live"] if live else ["metrics", "plots"])
if not (primary or secondary):
return {}
message = (
"This command will guide you to set up your first stage in "
"[green]dvc.yaml[/green].\n"
)
if show_heading:
ui.error_write(message, styled=True)
return compact(
{
**_prompts(primary, defaults),
**_prompts(secondary, defaults),
}
)
def _check_stage_exists(
dvcfile: "DVCFile", name: str, force: bool = False
) -> None:
if not force and dvcfile.exists() and name in dvcfile.stages:
from dvc.stage.exceptions import DuplicateStageName
hint = "Use '--force' to overwrite."
raise DuplicateStageName(
f"Stage '{name}' already exists in 'dvc.yaml'. {hint}"
)
def init(
repo: "Repo",
name: str = None,
type: str = "default", # pylint: disable=redefined-builtin
defaults: Dict[str, str] = None,
overrides: Dict[str, str] = None,
interactive: bool = False,
force: bool = False,
) -> "Stage":
from dvc.dvcfile import make_dvcfile
dvcfile = make_dvcfile(repo, "dvc.yaml")
name = name or type
_check_stage_exists(dvcfile, name, force=force)
defaults = defaults or {}
overrides = overrides or {}
with_live = type == "live"
if interactive:
defaults = init_interactive(
defaults=defaults or {},
show_heading=not dvcfile.exists(),
live=with_live,
provided=overrides.keys(),
)
else:
if with_live:
# suppress `metrics`/`params` if live is selected, unless
# it is also provided via overrides/cli.
# This makes output to be a checkpoint as well.
defaults.pop("metrics")
defaults.pop("params")
else:
defaults.pop("live") # suppress live otherwise
context: Dict[str, str] = {**defaults, **overrides}
assert "cmd" in context
params_kv = []
if context.get("params"):
from dvc.utils.serialize import LOADERS
path = context["params"]
assert isinstance(path, str)
_, ext = os.path.splitext(path)
params_kv = [{path: list(LOADERS[ext](path))}]
checkpoint_out = bool(context.get("live"))
models = context.get("models")
return repo.stage.add(
name=name,
cmd=context["cmd"],
deps=compact([context.get("code"), context.get("data")]),
params=params_kv,
metrics_no_cache=compact([context.get("metrics")]),
plots_no_cache=compact([context.get("plots")]),
live=context.get("live"),
force=force,
**{"checkpoints" if checkpoint_out else "outs": compact([models])},
)
+6 -29
View File
@@ -4,7 +4,7 @@ from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from .utils import exp_commits, remote_exp_refs_by_name
from .utils import exp_commits, resolve_exp_ref
logger = logging.getLogger(__name__)
@@ -14,7 +14,11 @@ logger = logging.getLogger(__name__)
def pull(
repo, git_remote, exp_name, *args, force=False, pull_cache=False, **kwargs
):
exp_ref = _get_exp_ref(repo, git_remote, exp_name)
exp_ref = resolve_exp_ref(repo.scm, exp_name, git_remote)
if not exp_ref:
raise InvalidArgumentError(
f"Experiment '{exp_name}' does not exist in '{git_remote}'"
)
def on_diverged(refname: str, rev: str) -> bool:
if repo.scm.get_ref(refname) == rev:
@@ -35,33 +39,6 @@ def pull(
_pull_cache(repo, exp_ref, **kwargs)
def _get_exp_ref(repo, git_remote, exp_name):
if exp_name.startswith("refs/"):
return exp_name
exp_refs = list(remote_exp_refs_by_name(repo.scm, git_remote, exp_name))
if not exp_refs:
raise InvalidArgumentError(
f"Experiment '{exp_name}' does not exist in '{git_remote}'"
)
if len(exp_refs) > 1:
cur_rev = repo.scm.get_rev()
for info in exp_refs:
if info.baseline_sha == cur_rev:
return info
msg = [
(
f"Ambiguous name '{exp_name}' refers to multiple "
"experiments in '{git_remote}'. Use full refname to pull one "
"of the following:"
),
"",
]
msg.extend([f"\t{info}" for info in exp_refs])
raise InvalidArgumentError("\n".join(msg))
return exp_refs[0]
def _pull_cache(repo, exp_ref, dvc_remote=None, jobs=None, run_cache=False):
revs = list(exp_commits(repo.scm, [exp_ref]))
logger.debug("dvc fetch experiment '%s'", exp_ref)
+6 -30
View File
@@ -2,10 +2,9 @@ import logging
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo import locked
from dvc.repo.experiments.base import ExpRefInfo
from dvc.repo.scm_context import scm_context
from .utils import exp_commits, exp_refs_by_name
from .utils import exp_commits, resolve_exp_ref
logger = logging.getLogger(__name__)
@@ -21,7 +20,11 @@ def push(
push_cache=False,
**kwargs,
):
exp_ref = _get_exp_ref(repo, exp_name)
exp_ref = resolve_exp_ref(repo.scm, exp_name)
if not exp_ref:
raise InvalidArgumentError(
f"'{exp_name}' is not a valid experiment name"
)
def on_diverged(refname: str, rev: str) -> bool:
if repo.scm.get_ref(refname) == rev:
@@ -42,33 +45,6 @@ def push(
_push_cache(repo, exp_ref, **kwargs)
def _get_exp_ref(repo, exp_name: str) -> ExpRefInfo:
if exp_name.startswith("refs/"):
return ExpRefInfo.from_ref(exp_name)
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
if not exp_refs:
raise InvalidArgumentError(
f"'{exp_name}' is not a valid experiment name"
)
if len(exp_refs) > 1:
cur_rev = repo.scm.get_rev()
for info in exp_refs:
if info.baseline_sha == cur_rev:
return info
msg = [
(
f"Ambiguous name '{exp_name}' refers to multiple "
"experiments. Use full refname to push one of the "
"following:"
),
"",
]
msg.extend([f"\t{info}" for info in exp_refs])
raise InvalidArgumentError("\n".join(msg))
return exp_refs[0]
def _push_cache(repo, exp_ref, dvc_remote=None, jobs=None, run_cache=False):
revs = list(exp_commits(repo.scm, [exp_ref]))
logger.debug("dvc push experiment '%s'", exp_ref)
+50 -47
View File
@@ -6,33 +6,47 @@ from dvc.repo import locked
from dvc.repo.scm_context import scm_context
from dvc.scm.base import RevError
from .base import EXPS_NAMESPACE, ExpRefInfo
from .utils import exp_refs_by_name, remove_exp_refs
from .utils import exp_refs, remove_exp_refs, resolve_exp_ref
logger = logging.getLogger(__name__)
@locked
@scm_context
def remove(repo, exp_names=None, queue=False, **kwargs):
if not exp_names and not queue:
def remove(
repo,
exp_names=None,
queue=False,
clear_all=False,
remote=None,
**kwargs,
):
if not any([exp_names, queue, clear_all]):
return 0
removed = 0
if queue:
removed += len(repo.experiments.stash)
repo.experiments.stash.clear()
removed += _clear_stash(repo)
if clear_all:
removed += _clear_all(repo)
if exp_names:
remained = _remove_commited_exps(repo, exp_names)
remained = _remove_queued_exps(repo, remained)
if remained:
raise InvalidArgumentError(
"'{}' is not a valid experiment".format(";".join(remained))
)
removed += len(exp_names) - len(remained)
removed += _remove_exp_by_names(repo, remote, exp_names)
return removed
def _clear_stash(repo):
removed = len(repo.experiments.stash)
repo.experiments.stash.clear()
return removed
def _clear_all(repo):
ref_infos = list(exp_refs(repo.scm))
remove_exp_refs(repo.scm, ref_infos)
return len(ref_infos)
def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
stash_revs = repo.experiments.stash_revs
for _, ref_info in stash_revs.items():
@@ -47,46 +61,24 @@ def _get_exp_stash_index(repo, ref_or_rev: str) -> Optional[int]:
return None
def _get_exp_ref(repo, exp_name: str) -> Optional[ExpRefInfo]:
cur_rev = repo.scm.get_rev()
if exp_name.startswith(EXPS_NAMESPACE):
if repo.scm.get_ref(exp_name):
return ExpRefInfo.from_ref(exp_name)
else:
exp_refs = list(exp_refs_by_name(repo.scm, exp_name))
if exp_refs:
return _get_ref(exp_refs, exp_name, cur_rev)
return None
def _get_ref(ref_infos, name, cur_rev) -> Optional[ExpRefInfo]:
if len(ref_infos) > 1:
for info in ref_infos:
if info.baseline_sha == cur_rev:
return info
msg = [
(
f"Ambiguous name '{name}' refers to multiple "
"experiments. Use full refname to remove one of "
"the following:"
)
]
msg.extend([f"\t{info}" for info in ref_infos])
raise InvalidArgumentError("\n".join(msg))
return ref_infos[0]
def _remove_commited_exps(repo, refs: List[str]) -> List[str]:
def _remove_commited_exps(
repo, remote: Optional[str], exp_names: List[str]
) -> List[str]:
remain_list = []
remove_list = []
for ref in refs:
ref_info = _get_exp_ref(repo, ref)
for exp_name in exp_names:
ref_info = resolve_exp_ref(repo.scm, exp_name, remote)
if ref_info:
remove_list.append(ref_info)
else:
remain_list.append(ref)
remain_list.append(exp_name)
if remove_list:
remove_exp_refs(repo.scm, remove_list)
if not remote:
remove_exp_refs(repo.scm, remove_list)
else:
for ref_info in remove_list:
repo.scm.push_refspec(remote, None, str(ref_info))
return remain_list
@@ -99,3 +91,14 @@ def _remove_queued_exps(repo, refs_or_revs: List[str]) -> List[str]:
else:
repo.experiments.stash.drop(stash_index)
return remain_list
def _remove_exp_by_names(repo, remote, exp_names: List[str]) -> int:
remained = _remove_commited_exps(repo, remote, exp_names)
if not remote:
remained = _remove_queued_exps(repo, remained)
if remained:
raise InvalidArgumentError(
"'{}' is not a valid experiment".format(";".join(remained))
)
return len(exp_names) - len(remained)
+42
View File
@@ -1,5 +1,6 @@
from typing import Generator, Iterable, Optional, Set
from dvc.exceptions import InvalidArgumentError
from dvc.scm.git import Git
from .base import (
@@ -115,3 +116,44 @@ def fix_exp_head(scm: "Git", ref: Optional[str]) -> Optional[str]:
if name == "HEAD" and scm.get_ref(EXEC_BASELINE):
return "".join((EXEC_BASELINE, tail))
return ref
def resolve_exp_ref(
scm, exp_name: str, git_remote: Optional[str] = None
) -> Optional[ExpRefInfo]:
if exp_name.startswith("refs/"):
return ExpRefInfo.from_ref(exp_name)
if git_remote:
exp_ref_list = list(remote_exp_refs_by_name(scm, git_remote, exp_name))
else:
exp_ref_list = list(exp_refs_by_name(scm, exp_name))
if not exp_ref_list:
return None
if len(exp_ref_list) > 1:
cur_rev = scm.get_rev()
for info in exp_ref_list:
if info.baseline_sha == cur_rev:
return info
if git_remote:
msg = [
(
f"Ambiguous name '{exp_name}' refers to multiple "
"experiments. Use full refname to push one of the "
"following:"
),
"",
]
else:
msg = [
(
f"Ambiguous name '{exp_name}' refers to multiple "
f"experiments in '{git_remote}'. Use full refname to pull "
"one of the following:"
),
"",
]
msg.extend([f"\t{info}" for info in exp_ref_list])
raise InvalidArgumentError("\n".join(msg))
return exp_ref_list[0]
+2 -1
View File
@@ -61,7 +61,8 @@ def fetch(
for odb, obj_ids in sorted(
used.items(),
key=lambda item: item[0] and item[0].fs.scheme == Schemes.MEMORY,
key=lambda item: item[0] is not None
and item[0].fs.scheme == Schemes.MEMORY,
):
d, f = _fetch(
self,
+7 -3
View File
@@ -2,6 +2,8 @@ import logging
import os
from typing import TYPE_CHECKING, List, Optional
from dvc.render.utils import render
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
@@ -15,10 +17,12 @@ def create_summary(out):
metrics, plots = out.repo.live.show(str(out.path_info))
html_path = out.path_info.with_suffix(".html")
html_path = out.path_info.fspath + "_dvc_plots"
out.repo.plots.write_html(html_path, plots, metrics)
logger.info(f"\nfile://{os.path.abspath(html_path)}")
index_path = render(
out.repo, plots, metrics=metrics, path=html_path, refresh_seconds=5
)
logger.info(f"\nfile://{os.path.abspath(index_path)}")
def summary_path_info(out: "Output") -> Optional["PathInfo"]:
+11 -138
View File
@@ -8,8 +8,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional
from funcy import cached_property, first, project
from dvc.exceptions import DvcException
from dvc.repo.plots.data import PlotMetricTypeError
from dvc.types import StrPath
from dvc.render.vega import PlotMetricTypeError
from dvc.utils import (
error_handler,
errored_revisions,
@@ -113,58 +112,33 @@ class Plots:
props = props or {}
for path, repo_path in plot_files:
res[repo_path] = {"props": rev_props}
joined_props = {**rev_props, **props}
res[repo_path] = {"props": joined_props}
res[repo_path].update(
parse(
fs,
path,
rev_props=rev_props,
props=props,
props=joined_props,
onerror=onerror,
)
)
return res
@staticmethod
def render(data, revs=None, props=None, templates=None):
"""Renders plots"""
props = props or {}
# Merge data by plot file and apply overriding props
plots = _prepare_plots(data, revs, props)
result = {}
for datafile, desc in plots.items():
rendered = _render(
datafile,
desc["data"],
desc["props"],
templates,
)
if rendered:
result[datafile] = rendered
return result
def show(
self,
targets: List[str] = None,
revs=None,
props=None,
templates=None,
recursive=False,
onerror=None,
):
if onerror is None:
onerror = onerror_collect
data = self.collect(
return self.collect(
targets, revs, recursive, onerror=onerror, props=props
)
if templates is None:
templates = self.templates
return self.render(data, revs, props, templates)
def diff(self, *args, **kwargs):
from .diff import diff
@@ -217,26 +191,6 @@ class Plots:
return PlotTemplates(self.repo.dvc_dir)
def write_html(
self,
path: StrPath,
plots: Dict[str, Dict],
metrics: Optional[Dict[str, Dict]] = None,
html_template_path: Optional[StrPath] = None,
):
if not html_template_path:
html_template_path = self.repo.config.get("plots", {}).get(
"html_template", None
)
if html_template_path and not os.path.isabs(html_template_path):
html_template_path = os.path.join(
self.repo.dvc_dir, html_template_path
)
from dvc.utils.html import write
write(path, plots, metrics, html_template_path)
def _is_plot(out: "Output") -> bool:
return bool(out.plot) or bool(out.live)
@@ -264,17 +218,19 @@ def _collect_plots(
@error_handler
def parse(fs, path, props=None, rev_props=None, **kwargs):
def parse(fs, path, props=None, **kwargs):
props = props or {}
rev_props = rev_props or {}
_, extension = os.path.splitext(path)
if extension in (".tsv", ".csv"):
header = {**rev_props, **props}.get("header", True)
header = props.get("header", True)
if extension == ".csv":
return _load_sv(path=path, fs=fs, delimiter=",", header=header)
return _load_sv(path=path, fs=fs, delimiter="\t", header=header)
if extension in LOADERS or extension in (".yml", ".yaml"):
return LOADERS[extension](path=path, fs=fs)
if extension in (".jpeg", ".jpg", ".gif", ".png"):
with fs.open(path, "rb") as fd:
return fd.read()
raise PlotMetricTypeError(path)
@@ -291,89 +247,6 @@ def _plot_props(out: "Output") -> Dict:
return project(out.plot, PLOT_PROPS)
def _prepare_plots(data, revs, props):
"""Groups data by plot file.
Also resolves props conflicts between revs and applies global props.
"""
# we go in order revs are supplied on props conflict first ones win.
revs = iter(data) if revs is None else revs
plots, props_revs = {}, {}
for rev in revs:
# Asked for revision without data
if rev not in data:
continue
for datafile, desc in data[rev].get("data", {}).items():
# We silently skip on an absent data file,
# see also try/except/pass in .collect()
if "data" not in desc:
continue
# props from command line overwrite plot props from out definition
full_props = {**desc["props"], **props}
if datafile in plots:
saved = plots[datafile]
if saved["props"] != full_props:
logger.warning(
f"Inconsistent plot props for '{datafile}' in "
f"'{props_revs[datafile]}' and '{rev}'. "
f"Going to use ones from '{props_revs[datafile]}'"
)
saved["data"][rev] = desc["data"]
else:
plots[datafile] = {
"props": full_props,
"data": {rev: desc["data"]},
}
# Save rev we got props from
props_revs[datafile] = rev
return plots
def _render(datafile, datas, props, templates):
from .data import PlotData, plot_data
# Copy it to not modify a passed value
props = props.copy()
# Add x and y to fields if set
fields = props.get("fields")
if fields is not None:
fields = {*fields, props.get("x"), props.get("y")} - {None}
template = templates.load(props.get("template") or "default")
# If x is not set add index field
if not props.get("x") and template.has_anchor("x"):
props["append_index"] = True
props["x"] = PlotData.INDEX_FIELD
# Parse all data, preprocess it and collect as a list of dicts
data = []
for rev, unstructured_data in datas.items():
rev_data = plot_data(datafile, rev, unstructured_data).to_datapoints(
fields=fields,
path=props.get("path"),
append_index=props.get("append_index", False),
)
data.extend(rev_data)
# If y is not set then use last field not used yet
if data:
if not props.get("y") and template.has_anchor("y"):
fields = list(first(data))
skip = (PlotData.REVISION_FIELD, props.get("x"))
props["y"] = first(f for f in reversed(fields) if f not in skip)
return template.render(data, props=props)
return None
def _load_sv(path, fs, delimiter=",", header=True):
with fs.open(path, "r") as fd:
content = fd.read()
-138
View File
@@ -1,138 +0,0 @@
import os
from copy import copy
from funcy import first
from dvc.exceptions import DvcException
from dvc.utils.serialize import ParseError
class PlotMetricTypeError(DvcException):
def __init__(self, file):
super().__init__(
"'{}' - file type error\n"
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
)
class PlotDataStructureError(DvcException):
def __init__(self):
super().__init__(
"Plot data extraction failed. Please see "
"https://man.dvc.org/plot for supported data formats."
)
class PlotParsingError(ParseError):
def __init__(self, path, revision):
self.path = path
self.revision = revision
super().__init__(path, f"revision: '{revision}'")
def plot_data(filename, revision, content):
_, extension = os.path.splitext(filename.lower())
if extension in (".json", ".yaml"):
return DictData(filename, revision, content)
if extension in (".csv", ".tsv"):
return ListData(filename, revision, content)
raise PlotMetricTypeError(filename)
def _filter_fields(data_points, filename, revision, fields=None, **kwargs):
if not fields:
return data_points
assert isinstance(fields, set)
new_data = []
for data_point in data_points:
new_dp = copy(data_point)
keys = set(data_point.keys())
if keys & fields != fields:
raise DvcException(
"Could not find fields: '{}' for '{}' at '{}'.".format(
", ".join(fields), filename, revision
)
)
to_del = keys - fields
for key in to_del:
del new_dp[key]
new_data.append(new_dp)
return new_data
def _lists(dictionary):
for _, value in dictionary.items():
if isinstance(value, dict):
yield from _lists(value)
elif isinstance(value, list):
yield value
def _find_data(data, fields=None, **kwargs):
if not isinstance(data, dict):
return data
if not fields:
# just look for first list of dicts
fields = set()
for lst in _lists(data):
if (
all(isinstance(dp, dict) for dp in lst)
and set(first(lst).keys()) & fields == fields
):
return lst
raise PlotDataStructureError()
def _append_index(data_points, append_index=False, **kwargs):
if not append_index or PlotData.INDEX_FIELD in first(data_points).keys():
return data_points
for index, data_point in enumerate(data_points):
data_point[PlotData.INDEX_FIELD] = index
return data_points
def _append_revision(data_points, revision, **kwargs):
for data_point in data_points:
data_point[PlotData.REVISION_FIELD] = revision
return data_points
class PlotData:
REVISION_FIELD = "rev"
INDEX_FIELD = "step"
def __init__(self, filename, revision, content, **kwargs):
self.filename = filename
self.revision = revision
self.content = content
def _processors(self):
return [_filter_fields, _append_index, _append_revision]
def to_datapoints(self, **kwargs):
data = self.content
for data_proc in self._processors():
data = data_proc(
data, filename=self.filename, revision=self.revision, **kwargs
)
return data
class DictData(PlotData):
# For files usually parsed as dicts: eg JSON, Yaml
def _processors(self):
parent_processors = super()._processors()
return [_find_data] + parent_processors
class ListData(PlotData):
# For files parsed as list: CSV, TSV
pass
+20 -1
View File
@@ -1,3 +1,5 @@
from collections.abc import Mapping
from voluptuous import Any, Optional, Required, Schema
from dvc import dependency, output
@@ -49,6 +51,7 @@ OUT_PSTAGE_DETAILED_SCHEMA = {
Output.PARAM_PERSIST: bool,
Output.PARAM_CHECKPOINT: bool,
Output.PARAM_DESC: str,
Output.PARAM_REMOTE: str,
}
}
@@ -93,11 +96,27 @@ STAGE_DEFINITION = {
Optional(StageParams.PARAM_LIVE): Any(str, LIVE_PSTAGE_SCHEMA),
}
def either_or(primary, fallback, fallback_includes=None):
def validator(data):
schema = primary
if (
isinstance(data, Mapping)
and set(fallback_includes or []) & data.keys()
):
schema = fallback
return Schema(schema)(data)
return validator
FOREACH_IN = {
Required(FOREACH_KWD): Any(dict, list, str),
Required(DO_KWD): STAGE_DEFINITION,
}
SINGLE_PIPELINE_STAGE_SCHEMA = {str: Any(FOREACH_IN, STAGE_DEFINITION)}
SINGLE_PIPELINE_STAGE_SCHEMA = {
str: either_or(STAGE_DEFINITION, FOREACH_IN, [FOREACH_KWD, DO_KWD])
}
MULTI_STAGE_SCHEMA = {
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
VARS_KWD: VARS_SCHEMA,
+9
View File
@@ -47,6 +47,15 @@ class InvalidRemoteSCMRepo(SCMError):
super().__init__(msg)
class GitAuthError(SCMError):
def __init__(self, url: str):
super().__init__(
f"HTTP Git authentication is not supported: '{url}'"
"\nSee https://dvc.org/doc//user-guide/"
"troubleshooting#git-auth"
)
class Base:
"""Base class for source control management driver implementations."""
+19 -5
View File
@@ -20,7 +20,7 @@ from funcy import cached_property
from dvc.path_info import PathInfo
from dvc.progress import Tqdm
from dvc.scm.base import InvalidRemoteSCMRepo, SCMError
from dvc.scm.base import GitAuthError, InvalidRemoteSCMRepo, SCMError
from dvc.utils import relpath
from ..objects import GitObject
@@ -39,7 +39,7 @@ class DulwichObject(GitObject):
self.repo = repo
self._name = name
self._mode = mode
self.sha = sha
self._sha = sha
def open(self, mode: str = "r", encoding: str = None):
if not encoding:
@@ -68,6 +68,14 @@ class DulwichObject(GitObject):
self.repo, entry.path.decode(), entry.mode, entry.sha
)
@property
def size(self) -> int:
return len(self.repo[self.sha].as_raw_string())
@property
def sha(self) -> str:
return self._sha
class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
"""Dulwich Git backend."""
@@ -347,7 +355,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
yield os.fsdecode(key)
def iter_remote_refs(self, url: str, base: Optional[str] = None):
from dulwich.client import get_transport_and_path
from dulwich.client import HTTPUnauthorized, get_transport_and_path
from dulwich.errors import NotGitRepository
from dulwich.porcelain import get_remote_repo
@@ -368,6 +376,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
yield from (os.fsdecode(ref) for ref in client.get_refs(path))
except NotGitRepository as exc:
raise InvalidRemoteSCMRepo(url) from exc
except HTTPUnauthorized:
raise GitAuthError(url)
def get_refs_containing(self, rev: str, pattern: Optional[str] = None):
raise NotImplementedError
@@ -380,7 +390,7 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
force: bool = False,
on_diverged: Optional[Callable[[str, str], bool]] = None,
):
from dulwich.client import get_transport_and_path
from dulwich.client import HTTPUnauthorized, get_transport_and_path
from dulwich.errors import NotGitRepository, SendPackError
from dulwich.porcelain import (
DivergedBranches,
@@ -399,9 +409,11 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
) from exc
def update_refs(refs):
from dulwich.objects import ZERO_SHA
new_refs = {}
for ref, value in zip(dest_refs, values):
if ref in refs:
if ref in refs and value != ZERO_SHA:
local_sha = self.repo.refs[ref]
remote_sha = refs[ref]
try:
@@ -437,6 +449,8 @@ class DulwichBackend(BaseGitBackend): # pylint:disable=abstract-method
)
except (NotGitRepository, SendPackError) as exc:
raise SCMError("Git failed to push '{src}' to '{url}'") from exc
except HTTPUnauthorized:
raise GitAuthError(url)
def _push_dest_refs(
self, src: Optional[str], dest: str
+8
View File
@@ -92,6 +92,14 @@ class GitPythonObject(GitObject):
for obj in self.obj:
yield GitPythonObject(obj)
@property
def size(self) -> int:
return self.obj.size
@property
def sha(self) -> str:
return self.obj.hexsha
class GitPythonBackend(BaseGitBackend): # pylint:disable=abstract-method
"""git-python Git backend."""
+8
View File
@@ -58,6 +58,14 @@ class Pygit2Object(GitObject):
return stat.S_IFDIR
return self.obj.filemode
@property
def size(self) -> int:
return len(self.obj.read_raw())
@property
def sha(self) -> str:
return self.obj.hex
def scandir(self) -> Iterable["Pygit2Object"]:
for entry in self.obj: # noqa: B301
yield Pygit2Object(entry)
+17 -3
View File
@@ -1,4 +1,3 @@
import os
import stat
from abc import ABC, abstractmethod
from dataclasses import dataclass
@@ -44,6 +43,16 @@ class GitObject(ABC):
def issubmodule(self) -> bool:
return S_ISGITLINK(self.mode)
@property
@abstractmethod
def size(self) -> int:
pass
@property
@abstractmethod
def sha(self) -> str:
pass
class GitTrie:
def __init__(self, tree: GitObject, rev: str):
@@ -116,9 +125,14 @@ class GitTrie:
if not topdown:
yield top, dirs, nondirs
def stat(self, key: tuple) -> os.stat_result:
def info(self, key: tuple) -> dict:
obj = self.trie[key]
return os.stat_result((obj.mode, 0, 0, 0, 0, 0, 0, 0, 0, 0))
return {
"size": obj.size,
"type": "directory" if stat.S_ISDIR(obj.mode) else "file",
"sha": obj.sha,
"mode": obj.mode,
}
@dataclass
+1 -1
View File
@@ -273,7 +273,7 @@ class Stage(params.StageParams):
from dvc.output import Output
from dvc.schema import LIVE_PROPS
env[DVCLIVE_PATH] = str(out.path_info)
env[DVCLIVE_PATH] = relpath(out.path_info, self.wdir)
if isinstance(out.live, dict):
config = project(out.live, LIVE_PROPS)
-4
View File
@@ -9,10 +9,6 @@ class StageCmdFailedError(DvcException):
super().__init__(msg)
class StageFileFormatError(DvcException):
pass
class StageFileDoesNotExistError(DvcException):
DVC_IGNORED = "is dvc-ignored"
DOES_NOT_EXIST = "does not exist"
+3
View File
@@ -28,6 +28,7 @@ PARAM_PLOT = Output.PARAM_PLOT
PARAM_PERSIST = Output.PARAM_PERSIST
PARAM_CHECKPOINT = Output.PARAM_CHECKPOINT
PARAM_DESC = Output.PARAM_DESC
PARAM_REMOTE = Output.PARAM_REMOTE
DEFAULT_PARAMS_FILE = ParamsDependency.DEFAULT_PARAMS_FILE
@@ -52,6 +53,8 @@ def _get_flags(out):
yield from out.plot.items()
if out.live and isinstance(out.live, dict):
yield from out.live.items()
if out.remote:
yield PARAM_REMOTE, out.remote
def _serialize_out(out):
+10 -22
View File
@@ -53,7 +53,6 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
self.tmp_dir = tmp_dir
self.root_dir = root_dir
self.dvcignore = dvcignore
self.fs = LocalFileSystem()
if not tmp_dir:
return
@@ -80,12 +79,7 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
if not isinstance(fs, LocalFileSystem):
return
assert isinstance(path_info, str) or path_info.scheme == "local"
assert hash_info
assert isinstance(hash_info, HashInfo)
assert os.path.exists(path_info)
mtime, size = get_mtime_and_size(path_info, self.fs, self.dvcignore)
mtime, size = get_mtime_and_size(path_info, fs, self.dvcignore)
inode = get_inode(path_info)
logger.debug(
@@ -112,17 +106,12 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
if not isinstance(fs, LocalFileSystem):
return None
assert isinstance(path_info, str) or path_info.scheme == "local"
path = os.fspath(path_info)
# NOTE: use os.path.exists instead of LocalFileSystem.exists
# because it uses lexists() and will return True for broken
# symlinks that we cannot stat() in get_mtime_and_size
if not os.path.exists(path):
try:
mtime, size = get_mtime_and_size(path_info, fs, self.dvcignore)
except FileNotFoundError:
return None
mtime, size = get_mtime_and_size(path, self.fs, self.dvcignore)
inode = get_inode(path)
inode = get_inode(path_info)
value = self.md5s.get(inode)
@@ -141,12 +130,11 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
if not isinstance(fs, LocalFileSystem):
return
assert isinstance(path_info, str) or path_info.scheme == "local"
if not self.fs.exists(path_info):
try:
mtime, _ = get_mtime_and_size(path_info, fs, self.dvcignore)
except FileNotFoundError:
return
mtime, _ = get_mtime_and_size(path_info, self.fs, self.dvcignore)
inode = get_inode(path_info)
relative_path = relpath(path_info, self.root_dir)
@@ -168,11 +156,11 @@ class State(StateBase): # pylint: disable=too-many-instance-attributes
for relative_path in ref:
path = os.path.join(self.root_dir, relative_path)
if path in used or not self.fs.exists(path):
if path in used or not fs.exists(path):
continue
inode = get_inode(path)
mtime, _ = get_mtime_and_size(path, self.fs, self.dvcignore)
mtime, _ = get_mtime_and_size(path, fs, self.dvcignore)
if ref[relative_path] == (inode, mtime):
logger.debug("Removing '%s' as unused link.", path)
+3 -105
View File
@@ -2,30 +2,13 @@ import errno
import logging
import os
import platform
import sys
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
if (
sys.platform == "win32"
and sys.version_info < (3, 8)
and sys.getwindowsversion() >= (6, 2)
):
try:
import speedcopy
speedcopy.patch_copyfile()
except ImportError:
pass
class System:
@staticmethod
def is_unix():
return os.name != "nt"
@staticmethod
def hardlink(source, link_name):
try:
@@ -117,99 +100,14 @@ class System:
# so we need to chmod it to look like a normal copy.
os.chmod(link_name, 0o666 & ~umask)
@staticmethod
def _getdirinfo(path):
from collections import namedtuple
from win32file import ( # pylint: disable=import-error
FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT,
FILE_SHARE_READ,
OPEN_EXISTING,
CreateFileW,
GetFileInformationByHandle,
)
# NOTE: use FILE_FLAG_OPEN_REPARSE_POINT to open symlink itself and not
# the target See https://docs.microsoft.com/en-us/windows/desktop/api/
# fileapi/nf-fileapi-createfilew#symbolic-link-behavior
flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT
hfile = CreateFileW(
path, 0, FILE_SHARE_READ, None, OPEN_EXISTING, flags, None
)
# See BY_HANDLE_FILE_INFORMATION structure from fileapi.h
Info = namedtuple(
"BY_HANDLE_FILE_INFORMATION",
[
"dwFileAttributes",
"ftCreationTime",
"ftLastAccessTime",
"ftLastWriteTime",
"dwVolumeSerialNumber",
"nFileSizeHigh",
"nFileSizeLow",
"nNumberOfLinks",
"nFileIndexHigh",
"nFileIndexLow",
],
)
return Info(*GetFileInformationByHandle(hfile))
@staticmethod
def inode(path):
path = os.fspath(path)
if System.is_unix():
import ctypes
inode = os.lstat(path).st_ino
# NOTE: See https://bugs.python.org/issue29619 and
# https://stackoverflow.com/questions/34643289/
# pythons-os-stat-is-returning-wrong-inode-value
inode = ctypes.c_ulong(inode).value
else:
# getdirinfo from ntfsutils works on both files and dirs
info = System._getdirinfo(path)
inode = abs(
hash(
(
info.dwVolumeSerialNumber,
info.nFileIndexHigh,
info.nFileIndexLow,
)
)
)
assert inode >= 0
assert inode < 2 ** 64
return inode
return os.lstat(path).st_ino
@staticmethod
def is_symlink(path):
path = os.fspath(path)
if System.is_unix():
return os.path.islink(path)
# https://docs.microsoft.com/en-us/windows/desktop/fileio/
# file-attribute-constants
from winnt import ( # pylint: disable=import-error
FILE_ATTRIBUTE_REPARSE_POINT,
)
if os.path.lexists(path):
info = System._getdirinfo(path)
return info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT
return False
return os.path.islink(path)
@staticmethod
def is_hardlink(path):
path = os.fspath(path)
if System.is_unix():
return os.stat(path).st_nlink > 1
info = System._getdirinfo(path)
return info.nNumberOfLinks > 1
return os.stat(path).st_nlink > 1
+57 -6
View File
@@ -1,12 +1,15 @@
import sys
from contextlib import contextmanager
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
Iterator,
Optional,
Sequence,
TextIO,
Type,
Union,
)
@@ -14,6 +17,7 @@ from funcy import cached_property
if TYPE_CHECKING:
from rich.status import Status
from rich.text import Text as RichText
from dvc.progress import Tqdm
from dvc.ui.table import Headers, Styles, TableData
@@ -40,8 +44,13 @@ class Console:
def __init__(
self, formatter: Formatter = None, enable: bool = False
) -> None:
from contextvars import ContextVar
self.formatter: Formatter = formatter or Formatter()
self._enabled: bool = enable
self._paginate: ContextVar[bool] = ContextVar(
"_paginate", default=False
)
def enable(self) -> None:
self._enabled = True
@@ -74,6 +83,23 @@ class Console:
styled=styled,
)
def write_json(
self, data: Any, indent: int = 2, default: Callable = None
) -> None:
import json
if self.isatty():
from rich.highlighter import JSONHighlighter
j = json.dumps(data, indent=indent, default=default)
highlighter = JSONHighlighter()
text = highlighter(j)
text.no_wrap = True
text.overflow = None
return self.write(text, styled=True)
return self.write(json.dumps(data))
def write(
self,
*objects: Any,
@@ -85,6 +111,8 @@ class Console:
styled: bool = False,
file: TextIO = None,
) -> None:
import sys
from dvc.progress import Tqdm
sep = " " if sep is None else sep
@@ -94,21 +122,39 @@ class Console:
file = file or (sys.stderr if stderr else sys.stdout)
with Tqdm.external_write_mode(file=file):
if styled:
# if we are inside pager context, send the output to rich's buffer
if styled or self._paginate.get():
console = self.error_console if stderr else self.rich_console
return console.print(*objects, sep=sep, end=end)
if styled:
return console.print(*objects, sep=sep, end=end)
return console.out(*objects, sep=sep, end=end, highlight=False)
values = (
self.formatter.format(obj, style=style) for obj in objects
)
values = (self.formatter.format(obj, style) for obj in objects)
return print(*values, sep=sep, end=end, file=file)
@property
def rich_text(self) -> "Type[RichText]":
from rich.text import Text
return Text
@staticmethod
def progress(*args, **kwargs) -> "Tqdm":
from dvc.progress import Tqdm
return Tqdm(*args, **kwargs)
@contextmanager
def pager(self, styles: bool = True) -> Iterator[None]:
from .pager import DvcPager
tok = self._paginate.set(True)
try:
with self.rich_console.pager(pager=DvcPager(), styles=styles):
yield
finally:
self._paginate.reset(tok)
def prompt(
self, text: str, choices: Iterable[str] = None, password: bool = False
) -> Optional[str]:
@@ -195,6 +241,11 @@ class Console:
def status(self, status: str, **kwargs: Any) -> "Status":
return self.error_console.status(status, **kwargs)
def isatty(self) -> bool:
import sys
return sys.stdout.isatty()
ui = Console()

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