Compare commits

...
290 Commits
Author SHA1 Message Date
Ruslan Kuprieiev 71f8ffe13d dvc: bump to 2.0.2 2021-03-05 01:04:00 +02:00
Ruslan Kuprieiev 06aad3e2b3 dependency: repo: try to fetch before staging (#5549)
Related to https://github.com/iterative/dvc-bench/issues/234
2021-03-05 01:03:33 +02:00
Peter Rowlands (변기호) 1e7df0c27e dulwich: update to 0.20.20 (#5545) 2021-03-04 12:37:19 +02:00
Peter Rowlands (변기호) ce2d7c65db repro/exp run: use git status when reminding to git-track files (#5544)
* repro: skip DVC deps in SCM track stage

(DVC deps will be tracked when their own stage is repro'd)

* scm: use git status when reminding to git-track files

* sort names in reminder instead of random set order

* update unit test
2021-03-04 17:48:12 +09:00
Peter Rowlands (변기호) 2075a99233 exp diff: fix unresolved experiment names bug (#5543) 2021-03-04 15:39:21 +09:00
Ruslan Kuprieiev 98a1a92fa3 snap: debug failing tests 2021-03-03 23:11:19 +02:00
Ruslan Kuprieiev 16304b544f dvc: bump to 2.0.1 2021-03-03 17:38:26 +02:00
Ruslan Kuprieiev 803a100f32 setup: move google-cloud-storage to test requirements (#5540) 2021-03-03 17:11:30 +02:00
Ruslan Kuprieiev c3a0e8733e dvc: bump to 2.0.0 2021-03-03 12:21:03 +02:00
Peter Rowlands (변기호) 1542025afa exp: bash tab completion (#5532)
* exp list: add --names-only option

* completion: add exp name completion

* dvc exp: use exp name completion

* exp: fix bash completion
2021-03-03 15:46:11 +09:00
Batuhan Taskaya 8f6061f8cf update: clear error message for updating repo imports (#5520) 2021-03-02 20:34:59 +02:00
Paweł Redzyński 4a8cb806d2 live integration: get rid of dvc from live (#5466)
* live integration: get rid of dvc from live

* live: remove from api, add test for html generation during run

* stage: run: convert monitors to class context managers

* run: make monitors run in single thread

* run: move monitor logic out of run
2021-03-03 00:45:35 +09:00
Ruslan Kuprieiev e385e59a09 dvc: bump to 2.0.0a4 2021-03-02 13:54:16 +02:00
Batuhan Taskaya 2982e98c81 gs: Use rm() instead of delete() calls (#5535)
delete is a direct alias to rm(), though apparently it is kind of
broken on the latest release of gcsfs. This doesn't change any behavior
but only uses rm() directly instead of the given alias.
2021-03-02 13:53:44 +02:00
Batuhan Taskaya 3ebccda62c gs: use etag for the checksum parameter (#5534)
Switch from `md5` to `etag` as the checksum parameter. Due to `etag`
is not preserved while doing server side copies, this patch drops support
for external gs outputs.
2021-03-02 12:43:27 +02:00
Ruslan Kuprieiev 96092e5e27 dvc: bump to 2.0.0a3 2021-03-01 18:20:35 +02:00
Batuhan Taskaya b65dfe04f9 gs: Migrate Google Storage to FSSpec (#5526)
* azure: generalize fsspec functionality into FSSpecWrapper

* gs: migrate google storage to fsspec

* tests: disable empty_dir tests

* setup: add gcsfs to requirements

* gs: decode md5Hash into md5

* gs: speed up downloads via increasing chunk size
2021-03-01 17:15:31 +02:00
Paweł Redzyński 92b95bae9b plots: ignore binary plot files (#5523) 2021-03-01 15:00:42 +02:00
Ruslan Kuprieiev f2e0059392 dvc: bump to 2.0.0a2 2021-02-26 13:35:28 +02:00
Batuhan Taskaya a22101e0a6 azure: add account key to the config schema (#5522) 2021-02-25 19:53:47 +02:00
Peter Rowlands (변기호) fed6179992 exp run: if target is provided, only checkout target and dependencies (#5519)
* tests: test that only target and its deps are checked out on exp run

* exp run: if target is provided, only checkout target + deps
2021-02-25 15:12:27 +09:00
Peter Rowlands (변기호) b3a32809e9 exp show: fix bug where already collected exp results could be dropped (#5517) 2021-02-25 14:41:56 +09:00
Batuhan Taskaya e670644634 azure: support service principal in error messages as login_method (#5514) 2021-02-24 10:50:17 +02:00
Ruslan Kuprieiev a26f2995ef dir_info: only build trie when needed (#5512) 2021-02-24 09:15:20 +02:00
Ruslan Kuprieiev 502f0eecc9 tests: temporarily skip dvclive tests (#5513) 2021-02-24 08:52:33 +02:00
Ruslan Kuprieiev 8f568ab197 state: use diskcache (#5505)
* state: only load when needed

* state: don't use separate load/dump

* checkout: group link removal logic together

* state: use diskcache.Cache

* staging: use pbar and save to state

* state: don't pass the whole repo to __init__

* staging: explicitly pass state object

* fs: get rid of odb property

* state: don't close the connection to cache

* repo: close: close state
2021-02-23 20:51:34 +02:00
Ruslan Kuprieiev 2e01ce1974 dvc: bump to 2.0.0a1 2021-02-23 20:48:42 +02:00
Batuhan Taskaya d587d41d6c utils: proper file interface for HashedStreamReader (#5508) 2021-02-23 02:47:36 +02:00
Batuhan Taskaya 27c2401f59 azure: transfer bigger chunks of data (#5504) 2021-02-22 14:08:26 +02:00
Batuhan Taskaya 174621696d azure: migrate AzureFileSystem to fsspec (#5463)
* azure: migrate AzureFileSystem to fsspec

* client => fs

* principal auth

* better login error handling
2021-02-22 12:05:15 +02:00
Batuhan Taskaya e1a193f00b update: forbid updating repo-imports with --to-remote (#5475) 2021-02-22 11:55:00 +02:00
Saugat PachhaiandDave Berenbaum cccead3441 stage:list: update output and help text (#5499)
* stage:list: update output and help text

* Replaced 'Produces' with 'Outputs'
* Replaced 'Stage depends on' with just 'Depends on'
* Changes help text of --names-only and --fail

* Update dvc/command/stage.py

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

* s/Generates/Reports

Co-authored-by: Dave Berenbaum <[email protected]>
2021-02-20 13:21:39 +05:45
Batuhan Taskaya 8102ede5cc add, import-url: always resolve the output in the local path for --to-remote (#5473)
* add, import-url: always resolve the output in the local path for --to-remote

* windows

* use the proper fixture
2021-02-19 15:22:59 +02:00
Saugat Pachhai 14944dd9e0 Minor refactoring stage:list command (#5498)
Moving text truncation to utils and adding tests for it
2021-02-19 18:27:37 +05:45
Paweł Redzyński 63f32936b2 live integration: turn on tests (#5487) 2021-02-19 09:25:28 +01:00
Peter Rowlands (변기호) 7d13d24a1f pygit/dulwich: implement create branch (#5497)
* pygit: implement create branch

* dulwich: implement create branch
2021-02-19 17:18:15 +09:00
Peter Rowlands (변기호) 9cb8187cb3 pygit: implement resolve_commit (#5496)
* git: return backend-agnostic object for resolve_commit

* use int timestamps instead of datetime

* make branch_revs Git method instead of backend

* pygit: implement resolve_commit
2021-02-19 17:00:22 +09:00
Peter Rowlands (변기호) 98590779eb pygit: implement get_refs_containing (#5495)
* git: make is_sha a Git class method instead of backend function

* pygit: implement get_refs_containing

* use merge base instead of parents
2021-02-19 16:42:04 +09:00
Batuhan Taskaya 9ae8bcdf52 gdrive: support service auth with json (#5485)
* gdrive: support service auth with json

* retry logic
2021-02-18 17:10:35 +02:00
Paweł Redzyński 14958ff85f plots: fix bug preventing usage from being displayed (#5492) 2021-02-18 14:24:47 +01:00
Peter Rowlands (변기호) 9909ee85a1 git: implement dulwich add --update (#5491)
* dulwich: implement add with --update

* add git backend test for add()

* fix list comprehension in dulwich status()

* allow adding single string path instead of list

* handle windows paths in add(update=...)
2021-02-18 17:56:56 +09:00
Saugat Pachhai 45477a2a81 dvc.lock: raise error if the file is gitignored (#5484) 2021-02-18 03:08:00 +02:00
Ruslan Kuprieiev 282e83c481 tests: temporarily exclude gdrivefs from extras (#5483)
When trying to publish to pypi, we get:

```
Invalid value for requires_dist. Error: Can't have direct dependency:
"gdrivefs @ git+https://github.com/intake/gdrivefs.git ; extra ==
'tests'"
```
2021-02-17 05:49:34 +02:00
Ruslan Kuprieiev 51988475a4 gha: packages: update for 2.0 2021-02-17 04:38:45 +02:00
Corentin Hembise 52ee995f5e Update requirements of dulwich to 0.20.19 (#5480)
This should fix #5438
2021-02-17 10:12:04 +09:00
Ruslan Kuprieiev e0842ce01e dvc: rename cache -> odb (#5481) 2021-02-16 22:49:25 +02:00
Ruslan Kuprieiev a814a49181 fs: detach state from fs (#5479)
We used to think this way, but state is not really an fs concept
(fsspec's dircache is though), as it is a cache of hashes of objects
that we are staging, and the hash doesn't depend on fs, as it is usually
computed by us by reading from fs.

Pre-requisite for diskcache-based state.
2021-02-16 17:42:50 +02:00
Saugat Pachhai d28f5675b4 remove support for double braces (#5476) 2021-02-16 17:21:47 +02:00
Sami Jawhar 66f24b8708 exp run: Separate exp param parsing from other commands (#5310)
* Separate exp param parsing from other commands

* Update exp run params metavar

* Updated experiments run param argument name
2021-02-16 23:19:56 +09:00
Ruslan Kuprieiev ae8efdeb0f state: remove unused files (#5474)
They've been used before we've moved state to .dvc/tmp
2021-02-16 14:48:48 +02:00
Ruslan Kuprieiev 53ba51b5fe objects: separate staging (#5470) 2021-02-16 02:21:54 +02:00
Ruslan Kuprieiev e90c5fdd19 fs: remove unused use_state decorator (#5469)
* fs: remove unused use_state decorator

* pylint: limit astroid version
2021-02-16 02:00:55 +02:00
Ruslan Kuprieiev a72cea67f1 build: require git >= 1.7.0 (#5467) 2021-02-15 20:54:07 +02:00
Ruslan Kuprieiev 242d346f15 gs: ls: add etag (#5465) 2021-02-15 17:06:16 +02:00
Ruslan Kuprieiev bb24131530 fs/outs/deps: remove supported() logic (#5462) 2021-02-13 22:59:20 +02:00
Ruslan Kuprieiev b0fd40f12c dvc: rename tree -> fs (#5461) 2021-02-13 15:40:44 +02:00
Ruslan Kuprieiev 9ce6bce9b6 tree: move get_hash to object id logic (#5460) 2021-02-13 04:22:29 +02:00
Batuhan TaskayaandRuslan Kuprieiev f4b14c2d4a gdrive: Implement .ls() protocol / proper tree walking with .walk_files() (#5433)
* gdrive: .ls()/.walk()/.walk_dir()

* better walking

* proper tests

* ._ls_recursive()

* use posxipath for tests

* store ids instead of raw names

* add gdrive fixture

* add dependency

* tests: temporarily disable gdrive tests

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-02-13 03:10:38 +02:00
Peter Rowlands (변기호) 273b02fc54 metrics/params: fix bug preventing usage from being displayed (#5453) 2021-02-12 10:46:13 +09:00
Ruslan Kuprieiev 7c5e0f2949 tree: get_hash: accept explicit hash name (#5452) 2021-02-11 22:44:28 +02:00
Saugat Pachhai ac9819eb02 introduce dvc.io module to consolidate io utilities (#5348)
* add dvc.ui module

The name of class has been changed to `Console` as the short form
name is too bad.

* add demo
2021-02-11 20:42:08 +02:00
Ruslan Kuprieiev 02cfe33cf9 output: handle missing hash_info.nfiles (#5448) 2021-02-11 14:53:40 +02:00
Ruslan Kuprieiev c635493104 tests: don't limit the number of pytest workers (#5441)
We used to do this because of gitpython leaks.
2021-02-11 12:27:00 +02:00
Ruslan Kuprieiev 039c2421d9 cache: misc cleanups (#5447)
* cache: remove duplicated use_state

* tree: move hash_to* helper to cache
2021-02-11 11:28:24 +02:00
Peter Rowlands (변기호) e3ee960e8e exp run: remove exp reset in favor of exp run --reset (#5442)
* exp run: remove `exp reset` in favor of `exp run --reset`

* update command unit test
2021-02-11 11:53:20 +09:00
Peter Rowlands (변기호) 9d82739b3a exp: add exp remove command (#5443)
* exp: add remove function for removing specific experiment(s)

* add `dvc exp remove` command

* update tests
2021-02-11 11:45:25 +09:00
Ruslan Kuprieiev fa550f91c3 cache: move unrelated helpers out of CloudCache class (#5439)
* cache: move transfer to objects

Moving it as is for now.

* cache: move link() to checkout
2021-02-10 07:49:27 +02:00
Batuhan Taskaya 384a4113ca update: support --jobs even without --to-remote (#5434) 2021-02-09 17:58:59 +02:00
Peter Rowlands (변기호) b370465209 exp show: customize rich table rendering behavior (#5381)
* exp show: use rich native pager support

* exp show: re-enable column header styles

* utils: add custom classes to override rich table collapse behavior

* set final collapsed column width to 1 to allow ellipsis
2021-02-09 23:34:04 +09:00
Peter Rowlands (변기호) 748bda8950 exp run: clean untracked lockfiles before running (#5426)
* exp run/reset: clean untracked lockfiles

* update log level for run -f/--force message
2021-02-09 21:38:46 +09:00
Ruslan Kuprieiev 47bdf523d7 cache: add: accept object id and it's path (#5431)
Unnatural leftover from previous PRs
2021-02-09 08:15:50 +02:00
Ruslan Kuprieiev 0fdb119ed5 cache: move save() to objects (#5430) 2021-02-09 04:56:45 +02:00
Ruslan Kuprieiev 226c68f8ff dvc: introduce File/Tree cache objects (#5424) 2021-02-09 04:00:06 +02:00
Saugat Pachhai 02a0d1af52 stage: implement add/create methods in stage loader (#5397)
* stage: add/create methods in stage loader

* minor fixups
2021-02-08 18:30:10 +05:45
Peter Rowlands (변기호) eeeaa9d324 exp: cleanup executor temp directories ourselves (#5425) 2021-02-08 16:30:24 +09:00
Paweł Redzyński a7da6c2a80 live: support cache and no_cache use case (#5394)
* live: add cache functionality

* live: add tests for cache use case
2021-02-07 03:51:18 +02:00
Ruslan Kuprieiev b75aab4d85 utils: don't use unnecessary exists() calls (#5423)
* utils: file_md5: only return hex

* utils: file_md5: don't use exists()

* checkout: don't call exists()
2021-02-06 16:13:58 +02:00
Ruslan Kuprieiev f30d51ca42 cache: use add() to add objects to cache (#5421)
This makes all cache objects go through the same logic, so that all of
the appropriate rules are applied to them (e.g. proper permissions).

Pre-requisite for #5337
2021-02-06 03:07:29 +02:00
Ruslan Kuprieiev 4b938c5104 gha: temporarily disable macos 11
Those workers take a very long time to start up.
2021-02-06 03:07:17 +02:00
Peter Rowlands (변기호) 550cbb5e11 git: update dulwich dependency to 0.20.18 (#5420) 2021-02-05 20:43:44 +09:00
Batuhan Taskaya 5900151ebd gs: fix test failures caused by .ls() (#5419) 2021-02-05 12:09:04 +02:00
Pavel Ditenbir e7686f90a6 term: replace 'DVC-file' and 'stage file' with correct terms (#3960) (#5280) 2021-02-05 16:01:42 +09:00
RogerandPeter Rowlands e97f8fe19c pygit2, gitpython: handle relpaths in reset/checkout_index backends (#5414)
* Make minor change that fixes things for some reason

While working on vscode-dvc, both mattseddon and I found that this change allows
us to run `dvc exp run`, and we cannot otherwise.

I don't exactly know what this change does, and I'm hoping someone more
knowledgable does- after which we can give this PR a better name.

* test relpaths in git backend funcs which take path lists

* gitpython: handle relpaths in reset and checkout_index

* use / for git relpaths on windows

Co-authored-by: Peter Rowlands <[email protected]>
2021-02-05 12:05:51 +09:00
Ruslan Kuprieiev 320715a5f8 dvc: move checkout out of cache (#5415)
Checkout is an operation that builds object from cache(aka object
database) in a particular tree(aka filesystem). Thus it shouldn't be
part of the object database itself nor of the filesystem.

Pre-requisite for #5337
2021-02-05 03:27:00 +02:00
Ruslan Kuprieiev 8676617ae0 cache: separate save() and checkout() (#5412) 2021-02-05 01:54:46 +02:00
Batuhan Taskaya e7cc7765ac config: Support ~/.aws/config parsing (#5378)
* config: Support ~/.aws/config parsing

* skip when the ~/.aws/config doesn't exist

* Expand windows paths properly
2021-02-04 14:58:42 +02:00
Saugat Pachhai 21200c14df refactor webdav to use client; extract write_text to base (#5411) 2021-02-04 16:48:25 +05:45
Batuhan Taskaya ce554b6ae7 tree: Introduce the new .ls() API (#5275)
* s3: faster directory hashing

* Introduce Tree.info() API

* tests for s3

* Implement support for azure

* New .ls() API

* remove _iter_hashes from other trees

* assert recursive

* yield strings instead of file_infos

* Add GSTree.ls()

* Add DETAIL_FIELDS to the GSTree

* WebDAVTree.ls()

* proper webdav root directory initalization

* disable arguments-differ
2021-02-04 12:10:42 +02:00
Peter Rowlands (변기호) 79dafe8310 exp gc: prune any exp/exec refs which point to gc'd experiments (#5408) 2021-02-04 17:50:04 +09:00
Peter Rowlands (변기호) 982802effc experiments: bug fixes (#5407)
* exp run: display metrics on -m/--metrics

* exp show: fix sort handling
2021-02-04 16:42:22 +09:00
Ruslan Kuprieiev b87e76aa84 tree: stop using self.path_info (#5403)
* webhdfs: don't use self.path_info

* azure: don't use self.path_info

* webdav: don't use self.path_info

* oss: don't use self.path_info

* http: don't use self.path_info

* gdrive: don't use self.path_info
2021-02-03 20:50:30 +02:00
Ruslan Kuprieiev 2c81bf1748 api: use Repo.open (#5401) 2021-02-03 18:39:40 +02:00
Batuhan Taskaya adedd19c52 gha: switch to pre-commit/action for caching (#5389) 2021-02-03 17:14:46 +02:00
Ruslan Kuprieiev 985aef883e cli: config: rename --repo to --project (#5399)
Per https://github.com/iterative/dvc.org/pull/2064

Not changing the internal terminology because we stick to Repo in a lot
of places there and adjusting all of it would be wasteful. Maybe in the
future.
2021-02-03 17:13:53 +02:00
Ruslan Kuprieiev ffaac0f088 repo: remove unneeded filter (#5393)
Leftover from #5385
2021-02-03 17:13:36 +02:00
Batuhan Taskaya fc77618ad1 update: implement --to-remote flag (#5343)
* upload: implement --to-remote flag

* unit tests

* preserve frozen= for stage
2021-02-03 12:31:16 +02:00
Saugat Pachhai b2c2929869 pyarrow support for Python3.9 (#5391) 2021-02-02 17:31:11 +02:00
Batuhan Taskaya a92bfd272d get, get-url: add --jobs option (#5362) 2021-02-02 15:49:58 +02:00
Paweł Redzyński 31e4a3325d live integration: rename report to html (#5366) 2021-02-02 13:44:38 +01:00
Saugat Pachhai 67980de44d tests: mock global config dir for Updater (#5386) 2021-02-02 17:08:09 +05:45
Ruslan Kuprieiev 8932e0be84 dvc: get rid of can_be_skipped logic (#5385) 2021-02-02 13:13:48 +02:00
Batuhan Taskaya 9ad1e0e159 ssh: show total-size when uploading/downloading on ssh remote (#5383) 2021-02-02 11:27:19 +02:00
Saugat Pachhai 4348832b44 keep stage.dump and dvcfile.dump in sync (#5382) 2021-02-02 15:11:13 +05:45
Peter Rowlands (변기호) d05be36694 exp show: cleanup UI issues (#5361)
* exp show: add -n/--num to display the last N commits from HEAD

* exp run: fix empty commit with no changes

* add test for exp show with multiple commits

* allow -n greater than num commits in repo

* review fix
2021-02-02 11:35:02 +09:00
Peter Rowlands (변기호) 2ea5ef07dd exp apply: force apply by default (#5360)
* exp apply: force apply by default

* update tests
2021-02-02 11:07:19 +09:00
Batuhan Taskaya 8205c84077 pull, fetch: optimize memory consuption by ~%35 (#5346) 2021-02-01 18:46:02 +02:00
Saugat Pachhai b020918f5d stage:add: use remainder as a command script (#5350)
* stage:add use remainder as a command

* use -c for checkpoints

* use -n for stage name, make it required, fix tests
2021-02-01 17:50:04 +02:00
Batuhan Taskaya c84833bab1 params: support unicode characters (#5363)
* params: support unicode characters

* open remove() calls with utf-8
2021-02-01 15:56:53 +02:00
Paweł Redzyński 13db267a5d collect: unify message for no file in workspace (#5339) 2021-02-01 14:25:22 +01:00
Batuhan Taskaya b337396144 cache pip directory on github actions (#5365) 2021-02-01 13:48:03 +01:00
Piotr Bielak 3feaa86696 Pass missing glob flag to pull command (#5354)
The `--glob` flag is parsed from the command line, but in the
`CmdDataPull` class in the invocation the parameter (`self.args.glob`)
is not passed to the `pull` method implementation. Hence, always the
default value (False) is used. This commit fixes this issue and sets the
`glob` parameter properly.
2021-02-01 14:21:21 +02:00
Saugat Pachhai 131eb7b3a1 don't allow loading stages without cmd (#5373)
* don't allow loading stages without `cmd`

This was loosened when introducing parametrization.
But, `foreach`..`do` should be considered first
before stage's regular structure. And, cmd is made
required (as it should have been)

Related: #5371, #5370, #5312

* fix schema
2021-02-01 13:23:40 +05:45
Peter Rowlands (변기호) b771b06b25 tests: reduce checkpoint experiment iterations (#5352)
* exp tests: reduce default checkpoint run iterations from 5 to 2

* api: reduce make_checkpoint sleep interval
2021-02-01 14:20:51 +09:00
Peter Rowlands (변기호) 08300d3bcf experiments: drop exp resume in favor of auto-resuming in exp run (#5351)
* exp: auto determine checkpoint resume rev based on workspace state

* exp: add 'dvc exp reset' for reset/restarting checkpoints

* update tests

* fix reset usage
2021-01-29 13:18:20 +09:00
Batuhan Taskaya bb46043689 add: implement saving straight to cache (#5301)
* add: implement saving straight to cache

* Adjust CLI error cases to handle -o

* make tests pass on windows

* support explicit --jobs

* simplify the implementation a bit

* polish the tests
2021-01-28 14:42:58 +02:00
Ruslan Kuprieiev fe2d913821 list: don't clone local repos (#5341)
* repo: introduce Repo.open

* ls: don't clone local repos
2021-01-28 03:05:05 +02:00
Ruslan Kuprieiev 969a85efb6 RepoTree: get rid if implicit fetching (#5324) 2021-01-27 02:01:09 +02:00
Saugat Pachhai b1d6918c5a stage collection: do not collect .gitignored dvcfiles (#5265)
* Do not collect .gitignored dvcfiles

* fix exc

* consistent error message

* fix imports

* add tests
2021-01-26 14:51:02 +02:00
Batuhan TaskayaandRuslan Kuprieiev a5e19ecd6a tree: Implement upload_fobj protocol to all trees (#5307)
* tree: Implement upload_fobj protocol to azure

* tree: Implement upload_fobj protocol to google storage

* tree: Implement upload_fobj protocol to google drive

* tree: Implement upload_fobj protocol to hdfs

* tree: Implement upload_fobj protocol to oss (alibaba cloud)

* tree: Implement upload_fobj protocol to webhdfs (also some cleanup)

* tree: Implement upload_fobj protocol to http/https

* Add _generate_download_url() to the HTTP (for tests)

* tree: Implement a temporary upload_fobj protocol to webdav/webdavs

* Unify progress bars

* Lazily evaluate the file size, for http uploads

* Remove copy_fobj protocol and _transfer_file_as_whole

* show progress bar on cache transfer

* Simplify s3 upload

* Remove CHUNK_SIZE (except for some special cases)

* remove concurrency restrictions

* Update dvc/tree/oss.py

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

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-26 13:57:51 +02:00
Ruslan Kuprieiev 434cd305e9 dvc: ignore errors on protect/set_exec (#5335)
Both of these are not fatal and could safely be ignored.

Fixes #5255
2021-01-26 03:46:14 +02:00
Ruslan Kuprieiev 8112ce36d1 dvc: move 'verify' property from tree to cache (#5334) 2021-01-26 02:00:35 +02:00
Ruslan Kuprieiev 6a1f3659f5 tests: limit pytest version by <6.2.2 (#5333) 2021-01-25 22:01:54 +02:00
Batuhan Taskaya ec82c3fff5 tests: azure directory generation (#5328) 2021-01-25 15:41:29 +02:00
Peter Rowlands (변기호) d4757120c9 exp show: cleanup data column handling (#5329)
* account for identical param/metric names in columns (disable filtering)

* update filtering
2021-01-25 22:19:20 +09:00
Saugat Pachhai 9f0b86f221 unit.scm.test_git: use pytest instead of unittest (#5325) 2021-01-25 10:23:46 +05:45
Ruslan Kuprieiev 48dbcf9163 repo: remove unused find_out_by_relpath (#5323) 2021-01-25 01:24:32 +02:00
Saugat Pachhai 3741f730e0 show git-add hint on stage:add, refactor scm_context (#5290)
* Refactor scm_context, show git-add hint on stage:add

* move tests
2021-01-24 22:43:38 +02:00
Ruslan Kuprieiev 58c97b963c hash_info: add __str__ (#5319) 2021-01-24 22:40:38 +02:00
Paweł Redzyński 6a9c0c5ce5 live/checkpoints: test continue (#5300)
* live: test resuming

* initial test, not working

* experiments: introduce DVCLIVE_RESUME env variable on resume
2021-01-24 22:06:37 +02:00
Saugat Pachhai 1d8761e0c9 Remove empty file tests/unit/command/test_commit.py (#5316)
When I opened it, I got confused for a second and had to double-check if it was really empty.
The empty file gives a false assurance. We do need to add one, which I'll add later, but for now,
we better remove it.
2021-01-23 18:27:43 +05:45
Paweł Redzyński 2277a1a655 metrics/plots/params: return ordered (#5297)
* metrics/plots/params: return ordered

* metrics/params/plots: add tests for return order
2021-01-22 20:57:43 +02:00
Ruslan Kuprieiev cbd7472079 dvc: refactor cache saving (#5314) 2021-01-22 19:07:36 +02:00
Batuhan Taskaya 7b59108379 push: Gradual task allocation for pushing big datasets (#5285) 2021-01-22 15:29:06 +02:00
Ruslan Kuprieiev c4888cbd7d dvc: get rid of ExternalRepo (#5309) 2021-01-21 23:03:53 +02:00
Ruslan Kuprieiev 9203d33a3c reqs: tests: remove awscli (#5306) 2021-01-21 18:09:35 +02:00
Timofey Yefimovandkarajan1001 60d83a74d7 dvcignore: Fix incorrect ignored output computation (#4986)
* dvcignore: Fix incorrect ignored output computation

* dvcignore: Fixed unignored patterns

Unignored patterns were shown in the dvc status output and caused Error.

Also made a bit of a refactoring in `matches` and `ignore`.

* dvcignore: Fix functional tests

Not sure if I follow, but tests assume that unignored files should trigger `check-ignore`. What was the intention?

* dvcignore: Fix stage.wdir being MagicMock

* Use `is_ignored` instead of `check_ignore`

1. use is_ignored to judge DVC ignore status.
2. use check_ignore to show ignore patterns.

* Solve tests fails

* Add a new tests

* Return to the old check_ignore logic.

* Pass the pylint

* Simplify ignore tests

Co-authored-by: karajan1001 <[email protected]>
2021-01-21 14:28:08 +02:00
Saugat Pachhai 689ae22fb4 Fix code-style issues in the parsing module (#5299) 2021-01-20 17:21:57 +05:45
Batuhan Taskaya b5068a9200 checkout: don't filter hash's if it is a file (#5298) 2021-01-20 12:36:47 +02:00
Ruslan Kuprieiev c6ce69dd30 erepo: move misc stuff out of erepo (#5296)
* erepo: move get_rev to repo

* erepo: move fetch defaults to external_repo
2021-01-20 07:08:00 +02:00
Ruslan Kuprieiev 7cbcd4a7be erepo: separate caching and repo (#5295)
* erepo: don't pass for_write

* erepo: move get_cache_dir out of ExternalRepo
2021-01-20 00:25:15 +02:00
Ruslan Kuprieiev 03fc531a5c pylint: enable no-else-return (#5294)
From https://github.com/iterative/dvc/pull/5270#discussion_r559440586
2021-01-19 22:12:46 +02:00
Ruslan Kuprieiev d038bca05e tests: add basic [web]hdfs mockers (#5279) 2021-01-19 20:03:16 +02:00
Saugat Pachhai d8982c6db7 Override __str__ implementation for StageNotFound (#5289)
This happens because KeyError quotes the error message.
We'd still like to keep StageNotFound being a KeyError
semantics, but workaround the __str__ implementation.

Mypy was having some problem with the previous `__str__`
override.
2021-01-19 18:33:18 +05:45
Saugat Pachhai 372d6f045b Introduce stage:list command with autocompletions (#5282)
* Implement stage:list

* change targets help message
2021-01-19 17:35:55 +05:45
Batuhan Taskaya 51ada6d272 Implement straight-to-remote functionality (#5198)
* Implement export-to-remote functionality

* Redistrubition of functionality from tree to remote

* Pass chunk size into the open_url

* Move functionality away from remote to cache

* Basic progress bars for both transfer types

* Proper job calculation

* Redesign .open() usage in order to work with different trees

* Align the signatures of other open() functions to accept **kwargs

* Implement chunked uploading to the ssh

* Use local cache to compute directory hash (md5)

* S3: multipart upload test

* Wrap local cache's hash_to_path with PathInfo

* Use .isdir() instead of a try/except statement for calculating the from_infos

* New CLI layout (add/import-url)

* Adjust CLI tests to accept new arguments

* tests: import_url/add func/unit tests

* Introduce cache.transfer()

* Invalid CLI combination tests

* Store dir_info key in the transfer_directory

* LocalTree.upload_fobj

* Tests for utils

* Lazy imports on some places

* --to-remote migration (and repo._transfer)

* Use proper temporary location, more efficient pbar calc

* Directory transfer logic rewrite

* Use shutil.copyfileobj()

* Rebase...

* Better tests

* Inline transfer operation to .add() / .transfer()

* Open in the 'rb' mode for the tests

* More tests, better invalid option handling at repo level

* .read() certain amount of bytes to validate the content

* Better invalid argument handling

* Better progress bars by using .getsize() API

* For fallback upload, use the from_info.name instead of temporary naem

* introduce --jobs for add/import-url
2021-01-19 12:53:32 +02:00
Saugat Pachhai 55df6b8a6a cli: remove: Show traceback on verbose (#5287) 2021-01-19 15:10:56 +05:45
Pavel Ditenbir 8b0e0b995e cli: remove: fix error message for the case when file is not under control (#4497) (#5236)
* cli: remove: fix error message for the case when file is not under control (#4497)

* cli: remove: fix error message for the case when file is not under control (#4497)

fixes after code review

* cli: remove: fix error message for the case when file is not under control (#4497)

2nd round of fixes after code review

* cli: remove: fix error message for the case when file is not under control (#4497)

3rd round of fixes after code review
2021-01-19 15:04:19 +05:45
Peter Rowlands (변기호) 407f5607a2 scm: implement pygit2 resolve rev and native checkout (#5270)
* pygit2: implement get/set/remove refs

* pygit: implement iter_refs

* pygit: implement resolve_rev

* pygit2: implement working tree checkout

* pygit2: implement reset, explicitly read/write index when used

* pygit2: checkout does not need to reset index

* pygit2: handle "-" branch shorthand in checkout

* tests: install post-checkout hook should be run via gitpython

* exp apply: use stricter exception handling for merge conflict

* pygit: fix index read/write usage

* gitpython: handle untracked file conflicts in stash apply

* fix flaky/race index issue
2021-01-18 17:41:25 +02:00
Saugat Pachhai 78e12327bc use repo.stage.create_from_cli to create stages (#5281) 2021-01-18 18:55:19 +05:45
Ruslan Kuprieiev 4f2de7a3ef tests: remove psutil checks (#5283)
Leftover from https://github.com/iterative/dvc/pull/5232
2021-01-18 13:50:21 +02:00
Asif Saif UddinandRuslan Kuprieiev f8c9797f2a add psutil to install requirement (#5232)
* add  psutil to install requirement

* info: remove unneded checks

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-18 12:30:38 +02:00
Paweł Redzyński 28f6beaf78 checkpoints/live: move execution env to stage (#5257)
* checkpoints/live: move execution env to stage

* stage: env: provide DVC_ROOT for all repo runs
2021-01-18 10:35:07 +02:00
Saugat Pachhai 628f87845c introduce stage:add command (#5263)
* Implement stage:add

* fix tests

* Add short flag -C for checkpoints
2021-01-18 10:05:36 +02:00
Ruslan Kuprieiev 273518afab hdfs: copy: don't read everything into memory (#5277)
Regression from #4973 and #4747
2021-01-16 00:57:13 +02:00
Ruslan Kuprieiev b027760e12 dvc: move protect from tree to cache (#5271) 2021-01-14 21:10:53 +02:00
Ruslan Kuprieiev a2cac8b423 tests: upgrade azurite and enable loose mode (#5272)
* tests: upgrade azurite and enable loose mode

Azure tests started failing recently. They still work with real azure, so this is on azurite side.

* tests: use cloud instead of workspace

also gdrive doesn't support it yet :(
2021-01-14 20:07:24 +02:00
Ruslan Kuprieiev 3e42e015d6 dvc: redefine is_callback as cmd and no outs/deps (#5187) 2021-01-14 17:23:20 +02:00
Saugat Pachhai 257f58450a refactor run; move to stage.utils (#5262)
* refactor run; move to stage.utils

* Update dvc/repo/run.py

* fix pylint

* skip on run-cache
2021-01-13 21:19:36 +02:00
Batuhan Taskaya c840cecc91 tree: implement getsize() (#5261)
* Implement Tree.getsize() API

* Remove azure from tests

* strip support for recursive size calculation

* _getsize() -> getsize()
2021-01-13 18:35:57 +02:00
Batuhan Taskaya e5f91f5c7f cache: don't initalize cloud cache until its needed (#5264) 2021-01-13 17:01:06 +02:00
Paweł RedzyńskiandSaugat Pachhai 2ee68b01b0 system: prevent mypy from checking windows-related method (#5260)
* system: prevent mypy from checking windows-related method

* Update dvc/system.py

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

Co-authored-by: Saugat Pachhai <[email protected]>
2021-01-13 13:48:57 +02:00
Saugat Pachhai f8200ca2b8 move parsing params to utils (#5259)
* move parsing params to utils

* fix tests

* move chunk_dict and test it

* update test
2021-01-13 13:36:49 +02:00
Peter Rowlands (변기호) 228b3b16e5 experiments: exp res bug fixes (#5258)
* dvc repro: fail on checkpoint stages

* exp res: check for moved HEAD/baseline before resuming

* exp run/res: always consider last checkpoint as applied for workspace runs
2021-01-13 17:49:46 +09:00
Peter Rowlands (변기호) 1ab1abf62a git: implement merge/checkout via pygit2 (#5245)
* git: implement merge (gitpython, pygit2)

* pylint: add pygit2 as C extension

* git: s/checkout_paths/checkout_index/ and add pygit implementation

* git: support basic checkout/merge conflict strategies in checkout_index

* fix relpath issue in pygit merge

* git: add stubs for add --update

* experiments: drop remaining gitpython specific scm usage
2021-01-12 13:26:27 +02:00
Saugat Pachhai 49974c075c dvc: bump to 2.0.0a0 (#5250)
Looking at the log is confusing, as the version is stuck in `1.11.0` whereas the recent one is `1.11.10`.

As the updater is also dependent on that version, it prints an ugly update message while running too.
https://github.com/iterative/dvc/issues/5023#issuecomment-758450519
2021-01-12 09:24:57 +02:00
Saugat Pachhai a2c91d2058 scm: implement gitignore (#5243)
* scm: implement gitignore; use `pygit2` backend

* Add is_ignored implementation on `pygit2` and `gitpython` backends.
* Also implement `._reset()` on `repo.scm` which is most of the changes.

* fix tests

* use _backend_func

* minor fixups
2021-01-11 12:31:26 +02:00
Batuhan TaskayaandRuslan Kuprieiev 7fd191126e S3Tree._list_paths now recognizes the max_items limit (#5239)
* S3Tree._list_paths now recognizes the max_items limit

* Use modern approach + add a test

* refactor tests

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

* lint

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-11 11:54:42 +02:00
Batuhan Taskaya 5374880220 s3: Allow authentication using session token (#5240)
* S3: Allow authentication using session token

* Add a little test
2021-01-11 10:48:57 +02:00
Ruslan Kuprieiev c83a4ea406 erepo: refactor download/fetch logic (#5234)
* erepo: don't use _get_tree_for

* erepo: refactor download/fetch logic

* dvc: unify handling existing output
2021-01-10 10:48:36 +02:00
Dmitry Kropachev 6b05cc5e24 dvc.command.diff: add renamed detection feature (#5160)
fixes #5150
2021-01-09 13:49:03 +09:00
Saugat Pachhai 388eccad13 fix mypy errors; add mypy precommit hooks (#5237) 2021-01-08 16:05:52 +02:00
Saugat Pachhai ccc17917cf Make few imports lazy on dvc.main (#5233)
* Moved exception handling imports wherever they occur
* Move dvc.tree and dvc.external_repo imports to it's
  nearest use, as it is expensive for `--version`.
* Move analytics import to when it's used
* Move format_link to place where error occurs
2021-01-07 15:55:34 +02:00
Paweł Redzyński 386d0c463a live: add report switch (#5223) 2021-01-07 13:52:21 +02:00
Saugat Pachhai c04cdaf70f Split config and schema (#5229)
so as not to compile a schema unless needed
2021-01-07 13:47:14 +02:00
Yihao JiangandSaugat Pachhai fa5487ebfe show more info when successfully init dvc repo (#5190)
* show more info when successfully init dvc repo

* Ignore 'line too long' linter error

* simpler solution

* black reformat

* don't break dvc init --no-scm case

* Update dvc/repo/init.py

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

* Update dvc/repo/init.py

Co-authored-by: Saugat Pachhai <[email protected]>
2021-01-07 16:05:35 +05:45
Saugat Pachhai 9331ad23e4 Misc. imports lazy on utils,stage.cache and dvc.* (#5228) 2021-01-07 15:03:34 +05:45
Saugat Pachhai 66fc988ebf Make imports on dvc.repo and dvc.repo.plots lazy (#5227) 2021-01-07 15:02:31 +05:45
Saugat Pachhai 40122cefa5 parsing: make imports lazy (#5225)
* parsing: make imports lazy

* Locally declare default params file
2021-01-07 15:00:54 +05:45
Saugat Pachhai a9ccf70439 cmd: imports lazy (#5226)
They are not a major pain point though
2021-01-07 11:45:58 +05:45
Saugat Pachhai a9d620beda utils.serialize: lazy imports (#5224) 2021-01-07 11:44:37 +05:45
Saugat Pachhai af2a12a188 Use 'schema' top-level element in the lockfile for version (#5222) 2021-01-06 20:08:14 +02:00
mbiesekandRuslan Kuprieiev 00461bb5df commit: support granularity (#4297) (#5088)
* commit: support granularity (#4297)

* commit: support granularity

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-06 19:39:04 +02:00
Saugat Pachhai 0db41b94fc parametrization: do not load parser on import (#5217)
A great benefit to this is that, if the dvc.yaml file
is never interpolated, the parser is never built, which
reduces the gap even further between 1.0 and 2.0 files
2021-01-06 21:40:19 +05:45
Saugat Pachhai 2646c0d6bc Make some imports lazy for plots (#5219) 2021-01-06 17:41:30 +02:00
Saugat Pachhai 1cc646614f Misc. imports made lazy: scm,experiments,stage (#5218) 2021-01-06 17:41:12 +02:00
Saugat Pachhai 84e9c1b0de lazy imports on repo command (#5221) 2021-01-06 17:41:01 +02:00
Saugat Pachhai 4f692b0b69 Make repotree lazy (#5220)
Also removed dvcfile related imports on dvc.tree.repo.
This was making imports slower on dvc.main
2021-01-06 17:40:31 +02:00
Batuhan TaskayaandRuslan Kuprieiev 6fb84f641c checkout: fix partial file checkouts in the same directory (#5163)
* Fix partial file checkouts in the same directory

* unittest => pytest

* Try to rationalize changed() to take filter_info

* Use path.read_text() to verify the directory structure

* Try to use specialized hash, fall back to general one if doesn't exist

* Cover subdirectories in hash specialization

* coverage: add a test for empty subdirectories

* Move out the dir_info filtering block

* hash_origin => path_info

* cleanup

* Move hashing functionality after the existence checks

* Simplify it a bit

* Update dvc/cache/base.py

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

* Apply suggestions from code review

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

* Apply suggestions

* simplify tests

* change filenames

* Some visual changes for consistency

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-06 14:21:00 +02:00
Saugat Pachhai 9b73a745b4 Misc imports lazy (#5215) 2021-01-06 17:50:24 +05:45
Saugat Pachhai 4c53e636bc Move misc. imports from command, make them lazy (#5214) 2021-01-06 17:44:02 +05:45
Saugat Pachhai 40bd838c11 config command: lazily import (#5211) 2021-01-06 16:03:06 +05:45
Saugat Pachhai 709e121b31 package: Increment shtab's version (#5212) 2021-01-06 15:48:33 +05:45
Saugat Pachhai 0cc5227f47 Add versioning scheme in the dvc.lock (#5128)
* Add versioning scheme in the dvc.lock

* remove whitespace

* use actual size in tests assumptions
2021-01-06 15:44:19 +05:45
Saugat Pachhai 4f89db8e0d analytics: lazily import (#5210) 2021-01-06 15:32:27 +05:45
Peter Rowlands (변기호) 37f890444a diff: account for deleted workspace data (#5205)
* diff: add test for deleted data in addition to .dvc file

* diff: handle deleted data when checking for missing cache
2021-01-05 17:52:24 +09:00
Ruslan Kuprieiev b779a117ab add: handle long filenames (#5201) 2021-01-05 10:33:07 +02:00
Saugat Pachhai c0fbdb3531 Lazily load git backends (#5203) 2021-01-05 09:46:24 +02:00
Srinivas Reddy Thatiparthy (శ్రీనివాస్ రెడ్డి తాటిపర్తి) a017b87db5 Remove redundant comment (#5199) 2021-01-04 16:35:29 +02:00
Saugat Pachhai c4970a016f debug functions: have a good default value (#5202)
I had to keep providing default value before when profiling
simple functions as an API. I added a default now, and
also made `debugtools` customizable so that you could
edit the code to, for example, have `pdb` enabled by default.
2021-01-04 20:16:42 +05:45
Peter Rowlands (변기호) cffc019e1f experiments: fix logging bugs (#5195)
* experiments: use relpaths when tracking changed files

* experiments: suppress SCM hints in tempdir executors

* experiments: move multiprocessing error logging into child

* exp run: clarify help text for --temp
2021-01-04 19:25:13 +09:00
Saugat Pachhai 8010005364 Reraise exception after pdb.post_mortem() (#5197)
Otherwise, `continue` etc. would fail
2021-01-04 15:34:04 +05:45
Saugat Pachhai 261ddc947b Add pyinstrument: a statistical profiler (#5196)
* Add pyinstrument: a statistical profiler

It is easier than cprofile, in that it can show stacktrace
of where things are slow.

Also refactored/moved debugging related utils to a separate
module

* use ipdb first, then fallback to pdb

* use ipdb first, then fallback to pdb
2021-01-04 15:23:40 +05:45
Paweł RedzyńskiandRuslan Kuprieiev 862ab8de62 live logs initial (#4902)
* plots: allow dir plots

* first scenario

* separate dvclive tests

* rename index to step

* another test

* refactor

* its ok to have step in data

* sort plots

* introduce --logs

* add logs to run commands

* variable name refactoring

* fix logs option

* fixup

* cmd: run: fix unit tests

* plots: modify: fix tests

* improve dir handling

* fixup

* dvclive: logs summary

* api: introduce logs summarization method

* handle no repo case

* fix granular dir plots

* api: dvclive: simplify

* dvclive: roll back logs flag

* cmd: introduce logs

* api: adjust to logs

* test: dvclive fix

* rename logs to dvclive

* live: command: add uninitialized

* outs: dvclive type

* dvclive: support providing config via evn variables

* dvclive: treat as metrics

* dvclive integration: add support for summary on/off

* dvc integration: add temporary test for configuration export

* remove requirements

* cmd: introduce logs

* rename logs to dvclive

* cmd: introduce logs

* remove logs leftowvers

* dvclive: env: adjust to chekpoints env variables

* command: live: remove unnecessary help

* fix linter suggestions

* type hinting

* review refactor

* replace dvclive with live

* stage: loading: move live loading to separate method

* stage: serialize: allow only single live output

* live: add show/diff

* stage: serialize: roll back single live enforcing

* fixup

* rebase fixups

* output: live: force single instance

* live: command: add unit tests

* Update dvc/output/base.py

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

* Update dvc/stage/__init__.py

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

* Update dvc/stage/serialize.py

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

* review v2

* live: fix summary tracking by experiments

* naming fixups

* api: live: add tests

* tests: api: separate live tests

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2021-01-04 11:29:41 +02:00
Jorge Orpinel 19606ba211 dvc: std/update --tragets options (#5193)
to match iterative/dvc.org/pull/2062
2021-01-04 09:16:12 +02:00
Ruslan Kuprieiev 7df53e5e26 dvc: handle multiline command as list (#5194)
Implements an approach proposed by @skshetry.

Fixes #4373
2021-01-04 09:11:57 +02:00
Michael StegemanandOLOLO ALALA 764960fbfc cli: config: show merged config with --show-origin (#5188)
* cli: config: show merged config with --show-origin

Builds on #5126 and #5184 by showing the full merged config when
listing or getting config options.

References #5126
References #5184
References iterative/dvc.org#2028
Fixes #5119

* Add additional tests.

* Add test for merged config.

* config: rename methods

Co-authored-by: OLOLO ALALA <[email protected]>
2021-01-04 06:43:31 +02:00
Ruslan Kuprieiev 948633782e run-cache: skip unhashable stages (#5185)
Fixes #5182
2020-12-31 06:28:07 +02:00
Ruslan Kuprieiev ceaba68f62 config: temporarily forbid --show-origin without level (#5184) 2020-12-31 00:54:52 +02:00
Ruslan Kuprieiev 8162bb159c config: read merged config by default (#5179)
* config: read merged config by default

* tests: move root test out of config tests

* tests: refactor config tests

* cli: fix cache dir command
2020-12-31 00:37:39 +02:00
Paweł Redzyński c28e671f47 tests: plots: fix plots call (#5183) 2020-12-30 22:59:32 +02:00
Michael StegemanandRuslan Kuprieiev 34ed545d64 cli: config: add support for --show-origin (#5126)
* cli: config: add support for --show-origin

Support the --show-origin option for config, which, similar to git,
prefixes each config option with the source file it originated from.

Fixes #5119

* Reformat with black.

* Simplify logic for local config files.

* Add test for --show-config output.

* Update tests/func/test_config.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-30 21:56:20 +02:00
Pavel Ditenbir 23811e028c cmd: implement '--show-md' flag for 'dvc metrics show' (#4770) (#5161) 2020-12-30 02:17:41 +02:00
Ruslan Kuprieiev 6fcafc9121 erepo: make repo configuration explicit (#5174) 2020-12-29 14:32:29 +02:00
Peter Rowlands (변기호) db555ec66b experiments: fix checkout error for unexisting outputs (#5175)
* stage: support explicit allow_missing in checkout

* exp: explicitly clear EXEC_BRANCH and checkout with allow_missing on
starting experiment run

* git: wrap stash apply errors with SCMError

* git: add `git status` to backends

* exp: reset staged git changes before workspace exp runs to avoid merge
conflicts

* test untracked (and staged) pipeline file and deps for both workspace
and tmpdir runs

* experiments: explicitly track pipeline files and uncached deps for exp
commmits

* fix exp typing errors
2020-12-29 18:07:21 +09:00
Dmitry Kropachev c64b4ce78f dvc/utils/stream: make IterStream seekable and peekable (#5084)
fixes #5021
2020-12-28 23:34:24 +02:00
Saugat Pachhai 01a4473c20 parametrization: fix error when the file in vars is a directory (#5171)
* Fix error when the file in `vars` is a directory

* Fix on windows

* mock tree only
2020-12-28 23:03:09 +02:00
Saugat Pachhai 77ff24260e Remove Sourcerer's hall of fame (#5168)
Sourcerer is gone: https://github.com/sourcerer-io/sourcerer-app/issues/632
2020-12-28 16:42:05 +02:00
Peter Rowlands (변기호) 7a92ff5e5d experiments: commit with --no-verify in executors (#5078)
* git: add no_verify flag for commit()

* experiments: commit with --no-verify in executors

* git: wrap hook exceptions with SCMError

* update hook file perms

* update dulwich to 0.20.15

* update pygit2 commit def

* skip hook tests on windows
2020-12-28 17:35:35 +09:00
Ioana Grigoropol fa4bf40eb4 api: add globbing option for pushing (#5033)
* api: add globbing option for pushing

Related: #4816

Signed-off-by: Ioana Grigoropol <[email protected]>

* api: use utility function for push command

Signed-off-by: Ioana Grigoropol <[email protected]>
2020-12-25 23:50:36 +02:00
Jorge Orpinel 11382cc891 cmd: help output updates (per docs) (#4999)
* cmd: matching help output to docs
per https://github.com/iterative/dvc.org/pull/1971#pullrequestreview-538749492
and https://github.com/iterative/dvc.org/pull/1978#pullrequestreview-540445147

* fetch: simplify help text
per https://github.com/iterative/dvc.org/pull/1978#pullrequestreview-540447055

* commit: update help output to match iterative/dvc.org/pull/1978

* fetch: update help output to match iterative/dvc.org/pull/1978

* commmit: help output update (again)

* remove: simplify help output
per https://github.com/iterative/dvc.org/pull/1971#pullrequestreview-538749492

* remove: simplify help output
per https://github.com/iterative/dvc.org/pull/1971#pullrequestreview-538749492

* commit: update -h output
per https://github.com/iterative/dvc.org/pull/1989#pullrequestreview-542464568
2020-12-25 06:05:42 +02:00
Dmitry KropachevandRuslan Kuprieiev eb8e03a465 dvc/tree/local: fix it for no repo/cache case (#5118)
* dvc/tree/local: fix it for no repo/cache case

fixes #5069

* Update dvc/output/base.py

* Update dvc/output/base.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-25 05:56:04 +02:00
devramx 0bb3f40be8 remove: delete .gitignore file if empty (#5148)
* remove: delete .gitignore file if empty

modified behavior to unlink empty .gitignore files.
added corresponding unit & func test cases.
updated failing test cases.

Fixes #4962

* fix failing lint checks

* integrating feedback from the code review
2020-12-23 23:04:19 +02:00
Peter Rowlands (변기호) afe96974c3 lock: fix AttributeError in dvc.lock.HardlinkLock (#5152) 2020-12-23 16:03:59 +09:00
Ruslan Kuprieiev a6151eaf9e erepo: get rid of get_checksum (#5145) 2020-12-22 15:07:32 +02:00
Peter Rowlands (변기호) 76cc70a86b experiments: cleanup minor UI issues (#5147)
* exp apply: show passed exp name in CLI output instead of resolved git sha

* exp res: warn that --name will be ignored when resuming without
modifications

* exp gc: don't re-use gc parameter check message

* exp: update help strings

* exp: warn if exp dir contains untracked files after tmpdir execution
2020-12-22 16:08:20 +09:00
Paweł Redzyński 4d36bc4de8 params: introduce --targets (#5122)
* params: add targets option

* fixup

* remove unnecessary changes

* fix isort

* params: diff: add test for --targets

* tests: roll back metric/plots unification, introduce collect test

* metrics/plots: refactoring

* missing plots test added
2020-12-22 04:19:13 +02:00
Peter Rowlands (변기호) af26d54524 git: show progress during dulwich push/fetch (#5143) 2020-12-21 17:12:50 +09:00
Saugat Pachhai 4b30b8bea6 parametrization: move comment, fix exception error msg (#5142) 2020-12-21 13:04:55 +05:45
Ruslan Kuprieiev 5db7a4cab6 tree: local: remove misc redundant logic (#5139)
Non-lazy logging, fspath, redundant exist check, etc.
2020-12-20 17:56:13 +02:00
dcea83e75b [WIP] dvc: preserve exec bit for tracked files (#5061)
* #4578 added is_user_executable to dvc file

* Addressed PR feedback and introduced test_add_executable

Also fixed all tests in test_add.py

* Added a test to checkout executable file

* Pop isexec in dvc/output/__init__.py:loadd_from

* Implement test that file is executable after checkout

* Address PR feedback

* Address PR feedback

* Changed BaseTree.isexec to use path_info as an argument

* Update tests/func/test_add.py

* Update tests/func/test_add.py

* Update tests/func/test_add.py

* Update tests/func/test_add.py

* fix formatting

* add: preserve exec

* checkout: fix setting exec

* tests: refactor checkout exec test

* tests: fix windows cases

* tests: add: fix exec test on windows

* Update dvc/tree/local.py

* Update dvc/output/base.py

* Update dvc/output/base.py

* Update dvc/cache/base.py

* Update tests/func/test_add.py

* tests: add: skip whole exec test on windows

* save isexec to lockfiles

* fix formatting

Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-20 02:31:06 +02:00
Ioana GrigoropolandRuslan Kuprieiev f51bef040a pull: add glob option (#5032)
* api: add glob option for pull command

Related: #4816

Signed-off-by: Ioana Grigoropol <[email protected]>

* api: add globbing utility function

Related: #4816

Signed-off-by: Ioana Grigoropol <[email protected]>

* api: use utility function for pull command

Signed-off-by: Ioana Grigoropol <[email protected]>

* Update dvc/utils/__init__.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-19 21:38:40 +02:00
Ruslan Kuprieiev 2ce617c642 git: add pygit2 skeleton (#5138)
Implements stuff we need for the tree, but this is more to start filling
up pygit2 with something useful.
2020-12-19 02:44:02 +02:00
Peter Rowlands (변기호) f5d46c2fdf experiments: run single experiments in user's workspace by default (#5124)
* experiments: move executor into a module

* experiments: separate executor into base/local

* rename LocalExecutor to TempDirExecutor, run in .dvc/tmp by default

* git: restore HEAD using reset not checkout in detached_head()

* experiments: support calling BaseExecutor.reproduce directly for
workspace runs

* experiments: run single experiments in local workspace by default

* update experiments tests for workspace changes

* experiments: update checkpoints for workspace runs

* update checkpoints tests

* update test_show

* add force param for apply

* exp apply: add --force option

* exp run/res: add --temp option to force tempdir execution for single
experiments

* force apply in non-apply specific tests
2020-12-18 18:55:26 +09:00
Saugat Pachhai 7e2520c02a Cleanup debug log messages when loading stages (#5125)
* Cleanup log messages when loading stages

1. Simplified callback stage debug message
2. Reduced no lock entry message to trace
   If lockfile does not have some entry, it will simply complain
   that lockfile is not uptodate once.
   If there is no lockfile or empty lockfile, it will complain that
   lockfile not found.
3. Annoying parametrization related wdir-interpolated debug message
   removed.

* Update dvc/stage/__init__.py

* black
2020-12-18 10:38:24 +02:00
Saugat Pachhai 723477ed5a Load from only two revisions in params/metrics (#5121) 2020-12-18 10:33:10 +02:00
Saugat Pachhai 76c6c54278 Fix backport branch 2020-12-18 12:02:13 +05:45
Saugat Pachhai dcce3a31a9 params diff: skip params dependency for vars (#5123)
* Skip paramdependency for vars

Previously, `configs` which was a list of PathInfo was being used
to check if the `vars` should be skipped or not.

But, the file is stored as a string that made that check redundant
which might have caused #5117 as they were being stored twice,
once as a expanded paramdependency and next as a flattened dict
from vars, which later during `flatten`, it might have panicked
from that duplication.

* Use defaultdict

* change add_vars to collect_vars
2020-12-18 12:00:06 +05:45
Ruslan Kuprieiev 64dfaffd7c [WIP] repro: simplify the logic for frozen stages (#5114)
Fixes #5082
2020-12-16 14:28:00 +02:00
Ruslan Kuprieiev aec8d65791 repro: accept multiple targets (#5111)
Currently, we iterate over targets manually in CLI, which results in a
complicated code and a DAG collection/check overhead. With this PR we
collect the DAG once, figure out all the needed targets within it and
just reproduce them in one pass.
2020-12-16 03:25:18 +02:00
Ruslan Kuprieiev 8a38611e26 config: adjust help per docs
Per https://github.com/iterative/dvc.org/pull/2022
2020-12-15 22:13:29 +02:00
Peter Rowlands (변기호) 6b9d842b26 experiments: handle exp run from within subdirs/subrepos (#5093)
* experiments: add test cases for stage in subdir & subrepo

* git: fix relpath handling in dulwich add()

* repro: move exp file git staging from stage.run into repro

* experiments: support running experiments from outside DVC root

* experiments: setup logger inside multiprocessing (executor) context

* git: fix dulwich add in submodules
2020-12-15 19:54:04 +02:00
João Rafael 84a3a59bc7 Remove version restriction from networkx (#5110) 2020-12-15 19:43:32 +02:00
Sandeep MistryandRuslan Kuprieiev 31502aee0a diff: add support for --targets (#4938)
* wip: add target support (back) to dvc diff

* Change diff param to targets and update repo.diff accordingly

* Update dvc diff --targets with PR review feedback

* Tests for dvc diff --targets

* Update dvc diff --targets with PR review feedback

* Update dvc diff --targets with PR review feedback and additional tests

* split out new diff target tests as per PR comment

* remove unnecessary cache dir removal from setup_targets_test

* Update dvc/command/diff.py

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-15 16:19:26 +02:00
Saugat Pachhai 2512df7ca1 Prevent loading non-dict vars (#5107) 2020-12-15 19:21:31 +05:45
Saugat Pachhai 11c94b10f2 disallow overriding item and key in the foreach loop (#5104)
* Allow reserving keys in the context to prevent overriding

* Use nullcontext from funcy (3.6 Python)

* Display path in error message
2020-12-15 19:06:39 +05:45
Saugat Pachhai 6cad3c95dd Remove key/value magic strings (#5106) 2020-12-15 19:06:23 +05:45
Saugat Pachhai aa4ec8acd5 Skip building graph for dependencies without path_info (#5103)
RepoDependency for example don't have any path_info

See: https://github.com/iterative/dvc/pull/4938#issuecomment-743709176
Related: #5035
2020-12-15 16:34:39 +05:45
Ruslan Kuprieiev b0b04d44b1 dvcignore: don't look forward (#5098)
Prevents us from duplicating the work by walking into directories
searching for subrepos. Saves around ~1sec (5.8 -> 4.8) in
`dvc metrics diff` in a big git-only repo.

Related to https://github.com/iterative/dvc/pull/4284#issuecomment-664033700
2020-12-14 23:32:59 +02:00
Ruslan Kuprieiev fa76c79ee9 Update mergify.yml 2020-12-14 22:21:53 +02:00
Ruslan Kuprieiev 78e0434dd3 Update mergify.yml 2020-12-14 21:01:06 +02:00
6e92352001 fix making too many ssh connections when pushing run cache (#4732) (#4733)
* fix making too many ssh connections when pushing run cache (#4732)

* reformat

* generator should exit on GeneratorExit exception

* Update dvc/tree/ssh/__init__.py

* pool: close on Exception and not BaseException

Co-authored-by: Tomasz Łakota <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-14 16:43:20 +02:00
Ruslan Kuprieiev 89c7f984ad Create mergify.yml 2020-12-14 16:40:54 +02:00
Saugat Pachhai 620d082b0b refactor resolver and optimize (#5077)
minor refactors, code comments

Rename interim to definitions

provide location of entry during init for definition

small fixups to set_temporarily

Refactor loop to use dict comprehension

Provide same interface for definitions

Make it work for composite lists

Check for sequence rather than a list

refactor tests

Fixes #4955
Fixes #4957
Fixes #4963
2020-12-14 19:22:15 +05:45
Ruslan Kuprieiev 62afee1e2d Delete backport.yaml
Doesn't work well because of https://github.com/tibdex/backport/issues/33
2020-12-14 12:43:20 +02:00
Ruslan Kuprieiev c5496870e5 cli: config: check name format (#5090)
Currently we don't handle bad name well:

```
(dvc-3.8.3) ➜  dvc git:(config-bug) ✗ dvc config section
ERROR: unexpected error - not enough values to unpack (expected 2, got 1)
```

With this PR, we check name to conform to either `remote.name.option` or
`section.option`, so we'll give a more friendly:

```
(dvc-3.8.3) ➜  dvc git:(config-bug) ✗ dvc config section
ERROR: argument name: name argument should look like remote.name.option or section.option
usage: dvc config [-h] [--global | --system | --local] [-q | -v] [-u] [-l] [name] [value]

Get or set config options.
Documentation: <https://man.dvc.org/config>

positional arguments:
  name           Option name (remote.name.option or section.option).
  value          Option value.

optional arguments:
  -h, --help     show this help message and exit
  --global       Use global config.
  --system       Use system config.
  --local        Use local config.
  -q, --quiet    Be quiet.
  -v, --verbose  Be verbose.
  -u, --unset    Unset option.
  -l, --list     List all defined config values.
```
2020-12-14 12:25:46 +02:00
Ruslan Kuprieiev a217e4a18b gha: add backport bot 2020-12-14 12:24:30 +02:00
Batuhan Taskaya 526f615647 Update config.py (#5087) 2020-12-13 23:11:03 +02:00
Ruslan Kuprieiev 1dafbd8361 system: _reflink_darwin: use fsencode for path (#5085)
Fixes #5083
2020-12-13 02:29:26 +02:00
Batuhan Taskaya d3acbfe2b1 Implement dvc config --list (#5080)
* Implement dvc config --list

* Add test for successful CLI exit on --list

* Use dvc.utils.flatten, remove parser groups

* Move the test
2020-12-12 13:33:38 +02:00
GaoandSaugat Pachhai 96a97ec0e4 New option jobs for dvc import (#4977)
* fix #4838

`jobs` option for `dvc import`

* option `jobs` effected

* Pass the unit tests parse

* Adding a functional test.

* Pass `jobs` to `run` and remove some pylint

* Update dvc/stage/__init__.py

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

* remove unused param

* Solve test fail on windows

Co-authored-by: Saugat Pachhai <[email protected]>
2020-12-12 01:32:21 +02:00
Peter Rowlands (변기호) 7f52170465 fix remaining typing errors (#5079) 2020-12-11 16:40:19 +09:00
Ruslan Kuprieiev 1331651fe1 git: don't list all commits to check for an empty repo (#5075)
Saves us around 2sec (from ~7.5 to ~5.5) when parsing gitlab repo.
2020-12-10 14:40:27 +02:00
Saugat Pachhai 189e88f1db Run graph checks on collect/find_outs_by_path (#5035)
We try to optimize `tree.exists` calls and probably few others
in that they either look directly into the workspace or,
to the cache without running graph checks. It does not seem
to be possible just to run graph checks on `find_outs_by_path`
due to those optimizations.

So, that's why, the `collect` also does a graph check for this
reason.

Fixes #5027
Fixes #4010
2020-12-10 12:20:45 +02:00
Peter Rowlands (변기호) 9967d52885 git: fix mypy errors (#5073) 2020-12-10 18:51:26 +09:00
Peter Rowlands (변기호) e0884df99c exp show: fix --exclude-... (#5071)
* exp show: fix --exclude-...

* update exp show tests

* fix windows table divider
2020-12-10 18:11:00 +09:00
Peter Rowlands (변기호) 09cec69429 experiments: fix mypy errors (#5072) 2020-12-10 18:08:36 +09:00
Ruslan Kuprieiev 3b23dfebae git: use backend-provided trie in GitTree (#5044) 2020-12-09 23:51:23 +02:00
Johnny Chen 13bb56aa8e dvc info: follow the symlink when deciding filesystem type (#5067) 2020-12-09 16:38:52 +02:00
Peter Rowlands (변기호) 7bf9a0e805 experiments: support experiments cache collection for exp push/pull/gc (#5064)
* experiments: add utils.exp_commits for iterating over experiment commits

* repo: add support for all_experiments in brancher and used_cache

* git: make all_commits return commits in standard refs instead of --all

* gc: add --all-experiments to preserve cache for experiments

* update gc tests

* repo: support pushing and fetching specific revs internally

* exp push: push cache for experiment by default

* exp pull: pull cache for experiment by default

* fix empty git repo bug
2020-12-09 22:35:06 +09:00
Saugat Pachhai a7d665157c parametrization: disable set keyword (#5063) 2020-12-09 14:26:47 +05:45
Ruslan Kuprieiev 475fe544f0 tests: fix windows tests (#5054)
* [WIP] git: use dulwich for add/commit

* dulwich: add is_tracked implementation

* experiments: try to workaround windows cleanup() bug on py < 3.8

* tests: experiments: convert CRLF to LF before comparing params
2020-12-08 23:41:09 +02:00
Saugat Pachhai 995287bbba Pin numpy for windows build (#5055) 2020-12-08 18:07:14 +02:00
Peter Rowlands (변기호) 51fa0f5c38 experiments: add dvc exp push/pull/list (#5042)
* git: add support for on_diverged in push_refspec

* experiments: update executor for push-refspec changes

* experiments: implement push()

* tests: add tests for experiments.push

* git: add iter_remote_refs

* roughly equivalent to `git ls-remote --refs`

* experiments: add experiments.list for listing local and remote
experiments

* refactor git upstream/downstream in tests

* experiments: add experiments.pull

* experiments: add CLI commands for push/pull/list

* exp list: drop short options

* experiments: s/list/ls/

* dulwich: catch not repo errors when creating remote client

* fix remote rev resolution for list

* fix command test

* add tests for push/pull with ambiguous experiment names
2020-12-08 21:20:03 +09:00
metalglove a41e23a453 Improve OverlappingOutputsError exception message. (#5015)
* trie: updated message for OverlappingOutputsError

the message given by the exception was not verbose enough why this exception is thrown.
now gives the user more information as to why the overlapping paths should be changed.

* test_run_single_stage: fixed test TestShouldRaiseOnOverlappingOutputPaths

the test is now updated to reflect the exception message changes.

Signed-off-by: metalglove <[email protected]>
2020-12-08 17:28:53 +05:45
Peter Rowlands (변기호) 821ec0a934 git: drop scm requirement from backends (#5049)
* git: remove scm from backends, make backends configurable

* git: implement get/set/remove ref in gitpython

* git: support reflog --rewrite for stash

* tests: support testing individual backends in unit/scm/test_git

* stash: create reflog if it does not exist for gitpython backend
2020-12-08 20:37:16 +09:00
OrangeCubeandGary Yuan 6c7eccf5fd remote: fix pull error for large file with aliyun-oss (#5053)
Fixes #5051

Co-authored-by: Gary Yuan <[email protected]>
2020-12-08 13:00:20 +02:00
Peter Rowlands (변기호) b072370efb experiments: handle state where tmp args file was committed to git (#5052)
* git: fix Stash.push args typing

* experiments: handle case where args file was inadvertently committed to
git

* add test for args file being committed to git
2020-12-08 18:10:53 +09:00
Ruslan Kuprieiev c4f92314db repro: remove deprecated --cwd (#5048)
Per https://github.com/iterative/dvc/issues/2988
2020-12-08 03:26:51 +02:00
Victor f09c27e74c update issue template (#4991)
* update issue template

* try to add comments
2020-12-07 13:17:50 +02:00
Saugat Pachhai 529073967e run/repro: show command on dry-run, add ">" to script output (#5041)
* run/repro: show command on dry-run, add ">" to script output

This rollbacks the earlier commit to not show command during
the dry-run, and also prepends ">" in the command script
instead of "$" sign.

* fix tests
2020-12-07 13:16:15 +02:00
Peter Rowlands (변기호) 69eb86a06f experiments: ignore untracked files (#5029)
* git: add reset()

* experiments: exclude untracked files

* experiments: add test for untracked & staged files

* git: support checkout/reset for specific paths to HEAD state

* stage: track changed files when calling checkpoint callback

* experiments: ignore untracked files when generating exp commits

* add tests for dirty lock files

* stage: track uncached outs when generating checkpoints

* experiments: update checkpoints scm handling

* update checkpoints tests
2020-12-04 17:04:45 +09:00
Peter Rowlands (변기호) 9a9f46b2b8 scm/git: add support for resolving experiment names (#5026)
* experiments: move exp ref iterators into .utils

* git: support resolving experiment short names

* update tests

* remove change unrelated to this PR
2020-12-04 15:23:08 +09:00
Ruslan Kuprieiev 5152866634 scm: move logic to gitpython backend (#5022) 2020-12-03 20:25:20 +02:00
Saugat Pachhai f49feddb7f provide a callback to handle error during the stage collection (#5020)
also provide a callable to properly handle error messages
add tests
2020-12-03 19:05:56 +05:45
Clément WalterandRuslan Kuprieiev 94f2a1d3be Allow user to pass a list of commands for one single stage (#4934)
* Allow list type in STAGE_DEFINITION scheme and join(cmd) if list

* Remove join in Stage.__init__ and update test

* Loop over commands in cmd_run

* Improve logging of stage.run with detailed by command

* Fix test based on log message

* Update command logging with >

* Add tests for multi lines command

* Update > to $ in stage command log to prevent confusion with > operator

* Fix PR review about test: single quote and read_text

* tests: unify/clarify tests

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-03 12:49:30 +02:00
Peter Rowlands (변기호) 8252ae5f3c experiments: enable by default, support no_scm (#5019)
* experiments: enable by default, support no_scm

* mark experiments option obsolete
2020-12-03 10:21:19 +02:00
Saugat Pachhai bb84f5d9e2 Enable parametrization by default (#5018)
* Enable parametrization by default

* Remove a way to disable parametrization

* Add a comment for the feature flag
2020-12-03 10:20:29 +02:00
jwokatyandRuslan Kuprieiev f50f12ad61 hdfs: Replace deprecated pyarrow.hdfs.connect (#4973)
* hdfs: Replace deprecated pyarrow.hdfs.connect

Replace with pyarrow.fs.HadoopFileSystem, which has a different API.

Fixes #4905

* hdfs: fix copy/walk

* hdfs: Remove setting CLASSPATH

* Revert "hdfs: Remove setting CLASSPATH"

This reverts commit 8925a5d1d16ddbfc5e80c9b7974d96abdd4620a5.

Co-authored-by: Ruslan Kuprieiev <[email protected]>
2020-12-02 21:46:35 +02:00
alex lustenberg 7e4966fbe5 increase log level of messages to trace (#5017)
* cache/base - check start, stop, assumed unchanged if RO
* command/base - assumed default
* state - fetched
* utils - assumed default
* utils/fs - path inode
2020-12-02 20:59:04 +02:00
Indhu Kumarandindh8717 2e9fac5fb3 Organize metrics as table (#4706)
* WIP fixes #4621

* changes to all_branch if condition

* flatten_dict changes

* fix one test

* minor changes

* minor changes

* add precision

* tests and added precision to arguments

Co-authored-by: indh8717 <[email protected]>
2020-12-02 19:46:48 +02:00
387 changed files with 20552 additions and 10839 deletions
+45 -2
View File
@@ -3,9 +3,50 @@ name: "\U0001F41B Bug Report"
about: Create a bug report to help us improve DVC
---
## Bug Report
# Bug Report
### Please provide information about your setup
<!--
## Issue name
Issue names must follow the pattern `command: description` where the command is the dvc command that you are trying to run. The description should describe the consequence of the bug.
Example: `repro: doesn't detect input changes`
-->
## Description
<!--
A clear and concise description of what the bug is.
-->
### Reproduce
<!--
Step list of how to reproduce the bug
-->
<!--
Example:
1. dvc init
2. Copy dataset.zip to the directory
3. dvc add dataset.zip
4. dvc run -d dataset.zip -o model ./train.sh
5. modify dataset.zip
6. dvc repro
-->
### Expected
<!--
A clear and concise description of what you expect to happen.
-->
### Environment information
<!--
This is required to ensure that we can reproduce the bug.
-->
**Output of `dvc version`:**
@@ -15,4 +56,6 @@ $ dvc version
**Additional Information (if any):**
<!--
If applicable, please also provide a `--verbose` output of the command, eg: `dvc add --verbose`.
-->
+9
View File
@@ -0,0 +1,9 @@
pull_request_rules:
- name: backport patches to 1.11
conditions:
- base=master
- label=backport-1.11
actions:
backport:
branches:
- "1.11"
+9 -9
View File
@@ -4,7 +4,7 @@ on:
branches:
- master
release:
types: [released]
types: [released, prereleased]
jobs:
linux:
runs-on: ubuntu-16.04
@@ -110,22 +110,22 @@ jobs:
- name: Build the package
uses: snapcore/action-build@v1
id: snapcraft
- name: Test the package
run: |
sudo snap install --dangerous --classic ${{ steps.snapcraft.outputs.snap }}
dvc version
sudo snap remove dvc
# - name: Test the package
# run: |
# sudo snap install --dangerous --classic ${{ steps.snapcraft.outputs.snap }}
# dvc version
# sudo snap remove dvc
- name: Publish to the edge channel
uses: snapcore/action-publish@v1
if: github.event_name == 'push'
with:
store_login: ${{ secrets.STORE_LOGIN }}
snap: ${{ steps.snapcraft.outputs.snap }}
release: edge
release: edge,v2/edge
- name: Publish to the stable channel
uses: snapcore/action-publish@v1
if: github.event_name == 'release'
if: ${{ github.event_name == 'release' && github.event.release.released }}
with:
store_login: ${{ secrets.STORE_LOGIN }}
snap: ${{ steps.snapcraft.outputs.snap }}
release: stable
release: stable,v2/stable
+27 -6
View File
@@ -28,10 +28,13 @@ jobs:
python-version: 3.8
- name: Install requirements
run: pip install ".[all,tests]" Pygments collective.checkdocs pre-commit
- name: install temporary dependencies
run: |
pip install git+https://github.com/isidentical/gdrivefs@service-account
pip install git+https://github.com/isidentical/pydata-google-auth@service-account
- name: Check README
run: python setup.py checkdocs
- name: Check formatting
run: pre-commit run --all-files
- uses: pre-commit/[email protected]
tests:
runs-on: ${{ matrix.os }}
strategy:
@@ -39,9 +42,10 @@ jobs:
matrix:
os: [ubuntu-18.04, windows-2019, macos-10.15]
pyv: ["3.6", "3.7", "3.8", "3.9"]
include:
- os: "macos-11.0"
pyv: "3.9"
# macos 11.0 workers take too long to start up
# include:
# - os: "macos-11.0"
# pyv: "3.9"
exclude:
- os: macos-10.15
pyv: "3.9"
@@ -52,10 +56,27 @@ jobs:
uses: actions/[email protected]
with:
python-version: ${{ matrix.pyv }}
- name: get pip cache dir
id: pip-cache-dir
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: set pip cache
id: pip-cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache-dir.outputs.dir }}
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
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]"
- name: install temporary dependencies
run: |
pip install git+https://github.com/isidentical/gdrivefs@service-account
pip install git+https://github.com/isidentical/pydata-google-auth@service-account
- name: setup git
run: |
git config --global user.email "[email protected]"
@@ -67,7 +88,7 @@ jobs:
mkdir -p scripts/ci
echo "$GS_CREDS_JSON" > scripts/ci/gcp-creds.json
- name: run tests
run: python -m tests --all --cov-report=xml --cov-report=term --tap-combined
run: python -m tests --all --cov-report=xml --cov-report=term --tap-combined -n=4
- name: upload coverage report
uses: codecov/[email protected]
with:
+6
View File
@@ -19,6 +19,12 @@ repos:
- flake8-string-format
repo: https://gitlab.com/pycqa/flake8
rev: 3.8.4
- hooks:
- id: mypy
language_version: python3
files: ^dvc/
repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.790'
- repo: local
hooks:
- id: pylint
+4 -3
View File
@@ -3,7 +3,7 @@
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-whitelist=
extension-pkg-whitelist=pygit2
# Add files or directories to the blacklist. They should be base names, not
# paths.
@@ -184,7 +184,8 @@ disable=format,
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
enable=c-extension-no-member,
no-else-return,
[REPORTS]
@@ -292,7 +293,7 @@ ignored-classes=optparse.Values,thread._local,_thread._local,Dvcfile
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
ignored-modules=azure
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
-32
View File
@@ -201,38 +201,6 @@ Contributing
Contributions are welcome! Please see our `Contributing Guide <https://dvc.org/doc/user-guide/contributing/core>`_ for more
details.
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/0
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/0
:alt: 0
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/1
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/1
:alt: 1
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/2
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/2
:alt: 2
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/3
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/3
:alt: 3
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/4
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/4
:alt: 4
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/5
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/5
:alt: 5
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/6
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/6
:alt: 6
.. image:: https://sourcerer.io/fame/efiop/iterative/dvc/images/7
:target: https://sourcerer.io/fame/efiop/iterative/dvc/links/7
:alt: 7
Mailing List
============
+91
View File
@@ -0,0 +1,91 @@
from contextlib import ExitStack, contextmanager
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from argparse import Namespace
@contextmanager
def instrument(html_output=False):
"""Run a statistical profiler"""
try:
from pyinstrument import Profiler # pylint: disable=import-error
except ImportError:
print("Failed to run profiler, pyinstrument is not installed")
yield
return
profiler = Profiler()
profiler.start()
yield
profiler.stop()
if html_output:
profiler.open_in_browser()
return
print(profiler.output_text(unicode=True, color=True))
@contextmanager
def profile(dump_path: str = None):
"""Run a cprofile"""
import cProfile
prof = cProfile.Profile()
prof.enable()
yield
prof.disable()
if not dump_path:
prof.print_stats(sort="cumtime")
return
prof.dump_stats(dump_path)
@contextmanager
def debug():
try:
yield
except Exception: # pylint: disable=broad-except
try:
import ipdb as pdb # noqa: T100, pylint: disable=import-error
except ImportError:
import pdb # noqa: T100
pdb.post_mortem()
raise # prevent from jumping ahead
@contextmanager
def debugtools(args: "Namespace" = None, **kwargs):
kw = vars(args) if args else {}
kw.update(kwargs)
with ExitStack() as stack:
if kw.get("pdb"):
stack.enter_context(debug())
if kw.get("cprofile") or kw.get("cprofile_dump"):
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)))
yield
def add_debugging_flags(parser):
from argparse import SUPPRESS
parser.add_argument(
"--cprofile", 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,
)
parser.add_argument(
"--instrument", action="store_true", default=False, help=SUPPRESS,
)
parser.add_argument(
"--instrument-open", action="store_true", default=False, help=SUPPRESS,
)
+28 -18
View File
@@ -1,24 +1,6 @@
import json
import logging
import os
import platform
import sys
import tempfile
import uuid
import distro
import requests
from dvc import __version__
from dvc.config import Config, to_bool
from dvc.daemon import daemon
from dvc.exceptions import NotDvcRepoError
from dvc.lock import Lock, LockError
from dvc.repo import Repo
from dvc.scm import SCM, NoSCM
from dvc.scm.base import SCMError
from dvc.utils import env2bool, is_binary
from dvc.utils.fs import makedirs
logger = logging.getLogger(__name__)
@@ -35,6 +17,10 @@ def collect_and_send_report(args=None, return_code=None):
report as a JSON, where the _collector_ generates it and the _sender_
removes it after sending it.
"""
import tempfile
from dvc.daemon import daemon
report = {}
# Include command execution information on the report only when available.
@@ -50,6 +36,9 @@ def collect_and_send_report(args=None, return_code=None):
def is_enabled():
from dvc.config import Config, to_bool
from dvc.utils import env2bool
if env2bool("DVC_TEST"):
return False
@@ -69,6 +58,8 @@ def send(path):
`collect_and_send_report`. Sending happens on another process,
thus, the need of removing such file afterwards.
"""
import requests
url = "https://analytics.dvc.org"
headers = {"content-type": "application/json"}
@@ -86,6 +77,11 @@ def send(path):
def _scm_in_use():
from dvc.exceptions import NotDvcRepoError
from dvc.repo import Repo
from dvc.scm import SCM, NoSCM
from dvc.scm.base import SCMError
try:
scm = SCM(root_dir=Repo.find_root())
return type(scm).__name__
@@ -99,6 +95,9 @@ def _runtime_info():
"""
Gather information from the environment where DVC runs to fill a report.
"""
from dvc import __version__
from dvc.utils import is_binary
return {
"dvc_version": __version__,
"is_binary": is_binary(),
@@ -109,6 +108,11 @@ def _runtime_info():
def _system_info():
import platform
import sys
import distro
system = platform.system()
if system == "Windows":
@@ -147,6 +151,12 @@ def _find_or_create_user_id():
IDs are generated randomly with UUID.
"""
import uuid
from dvc.config import Config
from dvc.lock import Lock, LockError
from dvc.utils.fs import makedirs
config_dir = Config.get_dir("global")
fname = os.path.join(config_dir, "user_id")
lockfile = os.path.join(config_dir, "user_id.lock")
+9 -27
View File
@@ -1,15 +1,10 @@
import os
from contextlib import _GeneratorContextManager as GCM
from contextlib import contextmanager
from funcy import reraise
from dvc.exceptions import (
NotDvcRepoError,
OutputNotFoundError,
PathMissingError,
)
from dvc.external_repo import external_repo
from dvc.exceptions import OutputNotFoundError, PathMissingError
from dvc.objects.stage import get_hash
from dvc.path_info import PathInfo
from dvc.repo import Repo
@@ -25,16 +20,16 @@ def get_url(path, repo=None, rev=None, remote=None):
NOTE: This function does not check for the actual existence of the file or
directory in the remote storage.
"""
with _make_repo(repo, rev=rev) as _repo:
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
path_info = PathInfo(_repo.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, repo)):
metadata = _repo.repo_tree.metadata(path_info)
metadata = _repo.repo_fs.metadata(path_info)
if not metadata.is_dvc:
raise OutputNotFoundError(path, repo)
cloud = metadata.repo.cloud
hash_info = _repo.repo_tree.get_hash(path_info)
hash_info = get_hash(path_info, _repo.repo_fs, "md5", _repo.odb)
return cloud.get_url_for(remote, checksum=hash_info.value)
@@ -78,7 +73,7 @@ class _OpenContextManager(GCM):
def _open(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
with _make_repo(repo, rev=rev) as _repo:
with Repo.open(repo, rev=rev, subrepos=True, uninitialized=True) as _repo:
with _repo.open_by_relpath(
path, remote=remote, mode=mode, encoding=encoding
) as fd:
@@ -97,19 +92,6 @@ def read(path, repo=None, rev=None, remote=None, mode="r", encoding=None):
return fd.read()
@contextmanager
def _make_repo(repo_url=None, rev=None):
repo_url = repo_url or os.getcwd()
if rev is None and os.path.exists(repo_url):
try:
yield Repo(repo_url, subrepos=True)
return
except NotDvcRepoError:
pass # fallthrough to external_repo
with external_repo(url=repo_url, rev=rev) as repo:
yield repo
def make_checkpoint():
"""
Signal DVC to create a checkpoint experiment.
@@ -122,14 +104,14 @@ def make_checkpoint():
from time import sleep
from dvc.env import DVC_CHECKPOINT, DVC_ROOT
from dvc.stage.run import CHECKPOINT_SIGNAL_FILE
from dvc.stage.monitor import CheckpointTask
if os.getenv(DVC_CHECKPOINT) is None:
return
root_dir = os.getenv(DVC_ROOT, Repo.find_root())
signal_file = os.path.join(
root_dir, Repo.DVC_DIR, "tmp", CHECKPOINT_SIGNAL_FILE
root_dir, Repo.DVC_DIR, "tmp", CheckpointTask.SIGNAL_FILE
)
with builtins.open(signal_file, "w") as fobj:
@@ -139,4 +121,4 @@ def make_checkpoint():
fobj.flush()
os.fsync(fobj.fileno())
while os.path.exists(signal_file):
sleep(1)
sleep(0.1)
-937
View File
@@ -1,937 +0,0 @@
import itertools
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from typing import Optional
from funcy import decorator
from shortuuid import uuid
import dvc.prompt as prompt
from dvc.dir_info import DirInfo
from dvc.exceptions import (
CacheLinkError,
CheckoutError,
ConfirmRemoveError,
DvcException,
)
from dvc.progress import Tqdm
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
slow_link_guard,
)
from ..tree.base import RemoteActionNotImplemented
logger = logging.getLogger(__name__)
class DirCacheError(DvcException):
def __init__(self, hash_):
super().__init__(
f"Failed to load dir cache for hash value: '{hash_}'."
)
@decorator
def use_state(call):
tree = call._args[0].tree # pylint: disable=protected-access
with tree.state:
return call()
class CloudCache:
"""Cloud cache class."""
DEFAULT_CACHE_TYPES = ["copy"]
CACHE_MODE: Optional[int] = None
def __init__(self, tree):
self.tree = tree
self.repo = tree.repo
self.cache_types = tree.config.get("type") or copy(
self.DEFAULT_CACHE_TYPES
)
self.cache_type_confirmed = False
self._dir_info = {}
def get_dir_cache(self, hash_info):
assert hash_info
dir_info = self._dir_info.get(hash_info.value)
if dir_info:
return dir_info
try:
dir_info = self.load_dir_cache(hash_info)
except DirCacheError:
dir_info = DirInfo()
self._dir_info[hash_info.value] = dir_info
return dir_info
def load_dir_cache(self, hash_info):
path_info = self.tree.hash_to_path_info(hash_info.value)
try:
with self.tree.open(path_info, "r") as fobj:
d = json.load(fobj)
except (ValueError, FileNotFoundError) as exc:
raise DirCacheError(hash_info) from exc
if not isinstance(d, list):
logger.error(
"dir cache file format error '%s' [skipping the file]",
path_info,
)
d = []
return DirInfo.from_list(d)
def changed(self, path_info, hash_info):
"""Checks if data has changed.
A file is considered changed if:
- It doesn't exist on the working directory (was unlinked)
- Hash value is not computed (saving a new file)
- The hash value stored is different from the given one
- There's no file in the cache
Args:
path_info: dict with path information.
hash: expected hash value for this data.
Returns:
bool: True if data has changed, False otherwise.
"""
logger.debug(
"checking if '%s'('%s') has changed.", path_info, hash_info
)
if not self.tree.exists(path_info):
logger.debug("'%s' doesn't exist.", path_info)
return True
if not hash_info:
logger.debug("hash value for '%s' is missing.", path_info)
return True
if self.changed_cache(hash_info):
logger.debug(
"cache for '%s'('%s') has changed.", path_info, hash_info
)
return True
actual = self.tree.get_hash(path_info)
if hash_info != actual:
logger.debug(
"hash value '%s' for '%s' has changed (actual '%s').",
hash_info,
actual,
path_info,
)
return True
logger.debug("'%s' hasn't changed.", path_info)
return False
def link(self, from_info, to_info):
self._link(from_info, to_info, self.cache_types)
def _link(self, from_info, to_info, link_types):
assert self.tree.isfile(from_info)
self.tree.makedirs(to_info.parent)
self._try_links(from_info, to_info, link_types)
def _verify_link(self, path_info, link_type):
if self.cache_type_confirmed:
return
is_link = getattr(self.tree, f"is_{link_type}", None)
if is_link and not is_link(path_info):
self.tree.remove(path_info)
raise DvcException(f"failed to verify {link_type}")
self.cache_type_confirmed = True
@slow_link_guard
def _try_links(self, from_info, to_info, link_types):
while link_types:
link_method = getattr(self.tree, link_types[0])
try:
self._do_link(from_info, to_info, link_method)
self._verify_link(to_info, link_types[0])
return
except DvcException as exc:
logger.debug(
"Cache type '%s' is not supported: %s", link_types[0], exc
)
del link_types[0]
raise CacheLinkError([to_info])
def _do_link(self, from_info, to_info, link_method):
if self.tree.exists(to_info):
raise DvcException(f"Link '{to_info}' already exists!")
link_method(from_info, to_info)
logger.debug(
"Created '%s': %s -> %s", self.cache_types[0], from_info, to_info,
)
def _save_file(self, path_info, tree, hash_info, save_link=True, **kwargs):
assert hash_info
cache_info = self.tree.hash_to_path_info(hash_info.value)
if tree == self.tree:
if self.changed_cache(hash_info):
self.tree.move(path_info, cache_info, mode=self.CACHE_MODE)
self.link(cache_info, path_info)
elif self.tree.iscopy(path_info) and self._cache_is_copy(
path_info
):
# Default relink procedure involves unneeded copy
self.tree.unprotect(path_info)
else:
self.tree.remove(path_info)
self.link(cache_info, path_info)
if save_link:
self.tree.state.save_link(path_info)
# we need to update path and cache, since in case of reflink,
# or copy cache type moving original file results in updates on
# next executed command, which causes md5 recalculation
self.tree.state.save(path_info, hash_info)
else:
if self.changed_cache(hash_info):
with tree.open(path_info, mode="rb") as fobj:
# if tree has fetch enabled, DVC out will be fetched on
# open and we do not need to read/copy any data
if not (
tree.isdvc(path_info, strict=False) and tree.fetch
):
self.tree.copy_fobj(fobj, cache_info)
callback = kwargs.get("download_callback")
if callback:
callback(1)
self.tree.state.save(cache_info, hash_info)
def _cache_is_copy(self, path_info):
"""Checks whether cache uses copies."""
if self.cache_type_confirmed:
return self.cache_types[0] == "copy"
if set(self.cache_types) <= {"copy"}:
return True
workspace_file = path_info.with_name("." + uuid())
test_cache_file = self.tree.path_info / ".cache_type_test_file"
if not self.tree.exists(test_cache_file):
with self.tree.open(test_cache_file, "wb") as fobj:
fobj.write(bytes(1))
try:
self.link(test_cache_file, workspace_file)
finally:
self.tree.remove(workspace_file)
self.tree.remove(test_cache_file)
self.cache_type_confirmed = True
return self.cache_types[0] == "copy"
def _get_dir_info_hash(self, dir_info):
import tempfile
from dvc.path_info import PathInfo
from dvc.utils import tmp_fname
tmp = tempfile.NamedTemporaryFile(delete=False).name
with open(tmp, "w+") as fobj:
json.dump(dir_info.to_list(), fobj, sort_keys=True)
from_info = PathInfo(tmp)
to_info = self.tree.path_info / tmp_fname("")
self.tree.upload(from_info, to_info, no_progress_bar=True)
hash_info = self.tree.get_file_hash(to_info)
hash_info.value += self.tree.CHECKSUM_DIR_SUFFIX
hash_info.dir_info = dir_info
hash_info.nfiles = dir_info.nfiles
return hash_info, to_info
@use_state
def save_dir_info(self, dir_info, hash_info=None):
if (
hash_info
and hash_info.name == self.tree.PARAM_CHECKSUM
and not self.changed_cache_file(hash_info)
):
return hash_info
hi, tmp_info = self._get_dir_info_hash(dir_info)
new_info = self.tree.hash_to_path_info(hi.value)
if self.changed_cache_file(hi):
self.tree.makedirs(new_info.parent)
self.tree.move(tmp_info, new_info, mode=self.CACHE_MODE)
self.tree.state.save(new_info, hi)
return hi
def _save_dir(self, path_info, tree, hash_info, save_link=True, **kwargs):
if not hash_info.dir_info:
hash_info.dir_info = tree.cache.get_dir_cache(hash_info)
hi = self.save_dir_info(hash_info.dir_info, hash_info)
for entry_info, entry_hash in Tqdm(
hi.dir_info.items(path_info),
desc="Saving " + path_info.name,
unit="file",
):
self._save_file(
entry_info, tree, entry_hash, save_link=False, **kwargs
)
if save_link:
self.tree.state.save_link(path_info)
if self.tree.exists(path_info):
self.tree.state.save(path_info, hi)
cache_info = self.tree.hash_to_path_info(hi.value)
self.tree.state.save(cache_info, hi)
@use_state
def save(self, path_info, tree, hash_info, save_link=True, **kwargs):
if path_info.scheme != self.tree.scheme:
raise RemoteActionNotImplemented(
f"save {path_info.scheme} -> {self.tree.scheme}",
self.tree.scheme,
)
self._save(path_info, tree, hash_info, save_link, **kwargs)
def _save(self, path_info, tree, hash_info, save_link=True, **kwargs):
to_info = self.tree.hash_to_path_info(hash_info.value)
logger.debug("Saving '%s' to '%s'.", path_info, to_info)
if tree.isdir(path_info):
self._save_dir(path_info, tree, hash_info, save_link, **kwargs)
else:
self._save_file(path_info, tree, hash_info, save_link, **kwargs)
# Override to return path as a string instead of PathInfo for clouds
# which support string paths (see local)
def hash_to_path(self, hash_):
return self.tree.hash_to_path_info(hash_)
def changed_cache_file(self, hash_info):
"""Compare the given hash with the (corresponding) actual one.
- Use `State` as a cache for computed hashes
+ The entries are invalidated by taking into account the following:
* mtime
* inode
* size
* hash
- Remove the file from cache if it doesn't match the actual hash
"""
# Prefer string path over PathInfo when possible due to performance
cache_info = self.hash_to_path(hash_info.value)
if self.tree.is_protected(cache_info):
logger.debug(
"Assuming '%s' is unchanged since it is read-only", cache_info
)
return False
actual = self.tree.get_hash(cache_info)
logger.debug(
"cache '%s' expected '%s' actual '%s'",
cache_info,
hash_info,
actual,
)
if not hash_info or not actual:
return True
if actual.value.split(".")[0] == hash_info.value.split(".")[0]:
# making cache file read-only so we don't need to check it
# next time
self.tree.protect(cache_info)
return False
if self.tree.exists(cache_info):
logger.warning("corrupted cache file '%s'.", cache_info)
self.tree.remove(cache_info)
return True
def _changed_dir_cache(self, hash_info, path_info=None, filter_info=None):
if self.changed_cache_file(hash_info):
return True
dir_info = self.get_dir_cache(hash_info)
for entry_info, entry_hash in dir_info.items(path_info):
if path_info and filter_info:
if not entry_info.isin_or_eq(filter_info):
continue
if self.changed_cache_file(entry_hash):
return True
return False
def changed_cache(self, hash_info, path_info=None, filter_info=None):
if hash_info.isdir:
return self._changed_dir_cache(
hash_info, path_info=path_info, filter_info=filter_info
)
return self.changed_cache_file(hash_info)
def already_cached(self, path_info):
current = self.tree.get_hash(path_info)
if not current:
return False
return not self.changed_cache(current)
def safe_remove(self, path_info, force=False):
if not self.tree.exists(path_info):
return
if not force and not self.already_cached(path_info):
msg = (
"file '{}' is going to be removed."
" Are you sure you want to proceed?".format(str(path_info))
)
if not prompt.confirm(msg):
raise ConfirmRemoveError(str(path_info))
self.tree.remove(path_info)
def _checkout_file(
self, path_info, hash_info, force, progress_callback=None, relink=False
):
"""The file is changed we need to checkout a new copy"""
added, modified = True, False
cache_info = self.tree.hash_to_path_info(hash_info.value)
if self.tree.exists(path_info):
logger.debug("data '%s' will be replaced.", path_info)
self.safe_remove(path_info, force=force)
added, modified = False, True
self.link(cache_info, path_info)
self.tree.state.save_link(path_info)
self.tree.state.save(path_info, hash_info)
if progress_callback:
progress_callback(str(path_info))
return added, modified and not relink
def _checkout_dir(
self,
path_info,
hash_info,
force,
progress_callback=None,
relink=False,
filter_info=None,
):
added, modified = False, False
# Create dir separately so that dir is created
# even if there are no files in it
if not self.tree.exists(path_info):
added = True
self.tree.makedirs(path_info)
dir_info = self.get_dir_cache(hash_info)
logger.debug("Linking directory '%s'.", path_info)
for entry_info, entry_hash_info in dir_info.items(path_info):
entry_cache_info = self.tree.hash_to_path_info(
entry_hash_info.value
)
if filter_info and not entry_info.isin_or_eq(filter_info):
continue
if relink or self.changed(entry_info, entry_hash_info):
modified = True
self.safe_remove(entry_info, force=force)
self.link(entry_cache_info, entry_info)
self.tree.state.save(entry_info, entry_hash_info)
if progress_callback:
progress_callback(str(entry_info))
modified = (
self._remove_redundant_files(path_info, dir_info, force)
or modified
)
self.tree.state.save_link(path_info)
self.tree.state.save(path_info, hash_info)
# relink is not modified, assume it as nochange
return added, not added and modified and not relink
def _remove_redundant_files(self, path_info, dir_info, force):
existing_files = set(self.tree.walk_files(path_info))
needed_files = {info for info, _ in dir_info.items(path_info)}
redundant_files = existing_files - needed_files
for path in redundant_files:
self.safe_remove(path, force)
return bool(redundant_files)
@use_state
def checkout(
self,
path_info,
hash_info,
force=False,
progress_callback=None,
relink=False,
filter_info=None,
quiet=False,
):
if path_info.scheme not in ["local", self.tree.scheme]:
raise NotImplementedError
failed = None
skip = False
if not hash_info:
if not quiet:
logger.warning(
"No file hash info found for '%s'. It won't be created.",
path_info,
)
self.safe_remove(path_info, force=force)
failed = path_info
elif not relink and not self.changed(path_info, hash_info):
logger.debug("Data '%s' didn't change.", path_info)
skip = True
elif self.changed_cache(
hash_info, path_info=path_info, filter_info=filter_info
):
if not quiet:
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
hash_info,
path_info,
)
self.safe_remove(path_info, force=force)
failed = path_info
if failed or skip:
if progress_callback:
progress_callback(
str(path_info),
self.get_files_number(
self.tree.path_info, hash_info, filter_info
),
)
if failed:
raise CheckoutError([failed])
return
logger.debug(
"Checking out '%s' with cache '%s'.", path_info, hash_info
)
return self._checkout(
path_info,
hash_info,
force,
progress_callback,
relink,
filter_info,
)
def _checkout(
self,
path_info,
hash_info,
force=False,
progress_callback=None,
relink=False,
filter_info=None,
):
if not hash_info.isdir:
return self._checkout_file(
path_info, hash_info, force, progress_callback, relink
)
return self._checkout_dir(
path_info, hash_info, force, progress_callback, relink, filter_info
)
def get_files_number(self, path_info, hash_info, filter_info):
from funcy.py3 import ilen
if not hash_info:
return 0
if not hash_info.isdir:
return 1
if not filter_info:
return self.get_dir_cache(hash_info).nfiles
return ilen(
filter_info.isin_or_eq(path_info / relpath)
for relpath, _ in self.get_dir_cache(hash_info).items()
)
def _get_dir_size(self, dir_info):
try:
return sum(
self.tree.getsize(self.tree.hash_to_path_info(hi.value))
for _, hi in dir_info.items()
)
except FileNotFoundError:
return None
def merge(self, ancestor_info, our_info, their_info):
assert our_info
assert their_info
if ancestor_info:
ancestor = self.get_dir_cache(ancestor_info)
else:
ancestor = DirInfo()
our = self.get_dir_cache(our_info)
their = self.get_dir_cache(their_info)
merged = our.merge(ancestor, their)
hash_info = self.save_dir_info(merged)
hash_info.size = self._get_dir_size(merged)
return hash_info
@use_state
def get_hash(self, tree, path_info):
hash_info = tree.get_hash(path_info)
if not hash_info.isdir:
assert hash_info.name == self.tree.PARAM_CHECKSUM
return hash_info
hi = self.save_dir_info(hash_info.dir_info, hash_info)
hi.size = hash_info.size
return hi
def set_dir_info(self, hash_info):
assert hash_info.isdir
hash_info.dir_info = self.get_dir_cache(hash_info)
hash_info.nfiles = hash_info.dir_info.nfiles
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
if len(prefix) > 2:
path_info = self.tree.path_info / prefix[:2] / prefix[2:]
else:
path_info = self.tree.path_info / prefix[:2]
prefix = True
else:
path_info = self.tree.path_info
prefix = False
if progress_callback:
for file_info in self.tree.walk_files(path_info, prefix=prefix):
progress_callback()
yield file_info.path
else:
yield from self.tree.walk_files(path_info, prefix=prefix)
def _path_to_hash(self, path):
parts = self.tree.PATH_CLS(path).parts[-2:]
if not (len(parts) == 2 and parts[0] and len(parts[0]) == 2):
raise ValueError(f"Bad cache file path '{path}'")
return "".join(parts)
def list_hashes(self, prefix=None, progress_callback=None):
"""Iterate over hashes in this tree.
If `prefix` is specified, only hashes which begin with `prefix`
will be returned.
"""
for path in self._list_paths(prefix, progress_callback):
try:
yield self._path_to_hash(path)
except ValueError:
logger.debug(
"'%s' doesn't look like a cache file, skipping", path
)
def _hashes_with_limit(self, limit, prefix=None, progress_callback=None):
count = 0
for hash_ in self.list_hashes(prefix, progress_callback):
yield hash_
count += 1
if count > limit:
logger.debug(
"`list_hashes()` returned max '{}' hashes, "
"skipping remaining results".format(limit)
)
return
def _max_estimation_size(self, hashes):
# Max remote size allowed for us to use traverse method
return max(
self.tree.TRAVERSE_THRESHOLD_SIZE,
len(hashes)
/ self.tree.TRAVERSE_WEIGHT_MULTIPLIER
* self.tree.LIST_OBJECT_PAGE_SIZE,
)
def _estimate_remote_size(self, hashes=None, name=None):
"""Estimate tree size based on number of entries beginning with
"00..." prefix.
"""
prefix = "0" * self.tree.TRAVERSE_PREFIX_LEN
total_prefixes = pow(16, self.tree.TRAVERSE_PREFIX_LEN)
if hashes:
max_hashes = self._max_estimation_size(hashes)
else:
max_hashes = None
with Tqdm(
desc="Estimating size of "
+ (f"cache in '{name}'" if name else "remote cache"),
unit="file",
) as pbar:
def update(n=1):
pbar.update(n * total_prefixes)
if max_hashes:
hashes = self._hashes_with_limit(
max_hashes / total_prefixes, prefix, update
)
else:
hashes = self.list_hashes(prefix, update)
remote_hashes = set(hashes)
if remote_hashes:
remote_size = total_prefixes * len(remote_hashes)
else:
remote_size = total_prefixes
logger.debug(f"Estimated remote size: {remote_size} files")
return remote_size, remote_hashes
def list_hashes_traverse(
self, remote_size, remote_hashes, jobs=None, name=None
):
"""Iterate over all hashes found in this tree.
Hashes are fetched in parallel according to prefix, except in
cases where the remote size is very small.
All hashes from the remote (including any from the size
estimation step passed via the `remote_hashes` argument) will be
returned.
NOTE: For large remotes the list of hashes will be very
big(e.g. 100M entries, md5 for each is 32 bytes, so ~3200Mb list)
and we don't really need all of it at the same time, so it makes
sense to use a generator to gradually iterate over it, without
keeping all of it in memory.
"""
num_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
if num_pages < 256 / self.tree.JOBS:
# Fetching prefixes in parallel requires at least 255 more
# requests, for small enough remotes it will be faster to fetch
# entire cache without splitting it into prefixes.
#
# NOTE: this ends up re-fetching hashes that were already
# fetched during remote size estimation
traverse_prefixes = [None]
initial = 0
else:
yield from remote_hashes
initial = len(remote_hashes)
traverse_prefixes = [f"{i:02x}" for i in range(1, 256)]
if self.tree.TRAVERSE_PREFIX_LEN > 2:
traverse_prefixes += [
"{0:0{1}x}".format(i, self.tree.TRAVERSE_PREFIX_LEN)
for i in range(
1, pow(16, self.tree.TRAVERSE_PREFIX_LEN - 2)
)
]
with Tqdm(
desc="Querying "
+ (f"cache in '{name}'" if name else "remote cache"),
total=remote_size,
initial=initial,
unit="file",
) as pbar:
def list_with_update(prefix):
return list(
self.list_hashes(
prefix=prefix, progress_callback=pbar.update
)
)
with ThreadPoolExecutor(
max_workers=jobs or self.tree.JOBS
) as executor:
in_remote = executor.map(list_with_update, traverse_prefixes,)
yield from itertools.chain.from_iterable(in_remote)
def all(self, jobs=None, name=None):
"""Iterate over all hashes in this tree.
Hashes will be fetched in parallel threads according to prefix
(except for small remotes) and a progress bar will be displayed.
"""
logger.debug(
"Fetching all hashes from '{}'".format(
name if name else "remote cache"
)
)
if not self.tree.CAN_TRAVERSE:
return self.list_hashes()
remote_size, remote_hashes = self._estimate_remote_size(name=name)
return self.list_hashes_traverse(
remote_size, remote_hashes, jobs, name
)
def _remove_unpacked_dir(self, hash_):
pass
def gc(self, used, jobs=None):
removed = False
# hashes must be sorted to ensure we always remove .dir files first
for hash_ in sorted(
self.all(jobs, str(self.tree.path_info)),
key=self.tree.is_dir_hash,
reverse=True,
):
if hash_ in used:
continue
path_info = self.tree.hash_to_path_info(hash_)
if self.tree.is_dir_hash(hash_):
# backward compatibility
# pylint: disable=protected-access
self._remove_unpacked_dir(hash_)
self.tree.remove(path_info)
removed = True
return removed
def list_hashes_exists(self, hashes, jobs=None, name=None):
"""Return list of the specified hashes which exist in this tree.
Hashes will be queried individually.
"""
logger.debug(
"Querying {} hashes via object_exists".format(len(hashes))
)
with Tqdm(
desc="Querying "
+ ("cache in " + name if name else "remote cache"),
total=len(hashes),
unit="file",
) as pbar:
def exists_with_progress(path_info):
ret = self.tree.exists(path_info)
pbar.update_msg(str(path_info))
return ret
with ThreadPoolExecutor(
max_workers=jobs or self.tree.JOBS
) as executor:
path_infos = map(self.tree.hash_to_path_info, hashes)
in_remote = executor.map(exists_with_progress, path_infos)
ret = list(itertools.compress(hashes, in_remote))
return ret
def hashes_exist(self, hashes, jobs=None, name=None):
"""Check if the given hashes are stored in the remote.
There are two ways of performing this check:
- Traverse method: Get a list of all the files in the remote
(traversing the cache directory) and compare it with
the given hashes. Cache entries will be retrieved in parallel
threads according to prefix (i.e. entries starting with, "00...",
"01...", and so on) and a progress bar will be displayed.
- Exists method: For each given hash, run the `exists`
method and filter the hashes that aren't on the remote.
This is done in parallel threads.
It also shows a progress bar when performing the check.
The reason for such an odd logic is that most of the remotes
take much shorter time to just retrieve everything they have under
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
check if particular file exists much quicker, use their own
implementation of hashes_exist (see ssh, local).
Which method to use will be automatically determined after estimating
the size of the remote cache, and comparing the estimated size with
len(hashes). To estimate the size of the remote cache, we fetch
a small subset of cache entries (i.e. entries starting with "00...").
Based on the number of entries in that subset, the size of the full
cache can be estimated, since the cache is evenly distributed according
to hash.
Returns:
A list with hashes that were found in the remote
"""
# Remotes which do not use traverse prefix should override
# hashes_exist() (see ssh, local)
assert self.tree.TRAVERSE_PREFIX_LEN >= 2
hashes = set(hashes)
if len(hashes) == 1 or not self.tree.CAN_TRAVERSE:
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
return remote_hashes
# Max remote size allowed for us to use traverse method
remote_size, remote_hashes = self._estimate_remote_size(hashes, name)
traverse_pages = remote_size / self.tree.LIST_OBJECT_PAGE_SIZE
# For sufficiently large remotes, traverse must be weighted to account
# for performance overhead from large lists/sets.
# From testing with S3, for remotes with 1M+ files, object_exists is
# faster until len(hashes) is at least 10k~100k
if remote_size > self.tree.TRAVERSE_THRESHOLD_SIZE:
traverse_weight = (
traverse_pages * self.tree.TRAVERSE_WEIGHT_MULTIPLIER
)
else:
traverse_weight = traverse_pages
if len(hashes) < traverse_weight:
logger.debug(
"Large remote ('{}' hashes < '{}' traverse weight), "
"using object_exists for remaining hashes".format(
len(hashes), traverse_weight
)
)
return list(hashes & remote_hashes) + self.list_hashes_exists(
hashes - remote_hashes, jobs, name
)
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
remote_hashes = set(
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
)
return list(hashes & set(remote_hashes))
-91
View File
@@ -1,91 +0,0 @@
import logging
import os
from funcy import cached_property
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from dvc.progress import Tqdm
from ..tree.local import LocalTree
from ..utils.fs import walk_files
from .base import CloudCache
logger = logging.getLogger(__name__)
class LocalCache(CloudCache):
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
CACHE_MODE = LocalTree.CACHE_MODE
UNPACKED_DIR_SUFFIX = ".unpacked"
def __init__(self, tree):
super().__init__(tree)
self.cache_dir = tree.config.get("url")
@property
def cache_dir(self):
return self.tree.path_info.fspath if self.tree.path_info else None
@cache_dir.setter
def cache_dir(self, value):
self.tree.path_info = PathInfo(value) if value else None
@cached_property
def cache_path(self):
return os.path.abspath(self.cache_dir)
def hash_to_path(self, hash_):
# NOTE: `self.cache_path` is already normalized so we can simply use
# `os.sep` instead of `os.path.join`. This results in this helper
# being ~5.5 times faster.
return f"{self.cache_path}{os.sep}{hash_[0:2]}{os.sep}{hash_[2:]}"
def hashes_exist(
self, hashes, jobs=None, name=None
): # pylint: disable=unused-argument
return [
hash_
for hash_ in Tqdm(
hashes,
unit="file",
desc="Querying "
+ ("cache in " + name if name else "local cache"),
)
if not self.changed_cache_file(
HashInfo(self.tree.PARAM_CHECKSUM, hash_)
)
]
def already_cached(self, path_info):
assert path_info.scheme in ["", "local"]
return super().already_cached(path_info)
def _verify_link(self, path_info, link_type):
if link_type == "hardlink" and self.tree.getsize(path_info) == 0:
return
super()._verify_link(path_info, link_type)
def _list_paths(self, prefix=None, progress_callback=None):
assert self.tree.path_info is not None
if prefix:
path_info = self.tree.path_info / prefix[:2]
if not self.tree.exists(path_info):
return
else:
path_info = self.tree.path_info
# NOTE: use utils.fs walk_files since tree.walk_files will not follow
# symlinks
if progress_callback:
for path in walk_files(path_info):
progress_callback()
yield path
else:
yield from walk_files(path_info)
def _remove_unpacked_dir(self, hash_):
info = self.tree.hash_to_path_info(hash_)
path_info = info.with_name(info.name + self.UNPACKED_DIR_SUFFIX)
self.tree.remove(path_info)
+302
View File
@@ -0,0 +1,302 @@
import logging
from shortuuid import uuid
import dvc.prompt as prompt
from dvc.exceptions import (
CacheLinkError,
CheckoutError,
ConfirmRemoveError,
DvcException,
)
from dvc.objects import ObjectFormatError, check, load
from dvc.objects.stage import get_hash
from dvc.remote.slow_link_detection import ( # type: ignore[attr-defined]
slow_link_guard,
)
logger = logging.getLogger(__name__)
def _changed(path_info, fs, obj, cache):
logger.trace("checking if '%s'('%s') has changed.", path_info, obj)
try:
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
logger.debug(
"cache for '%s'('%s') has changed.", path_info, obj.hash_info
)
return True
try:
actual = get_hash(path_info, fs, obj.hash_info.name, cache)
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):
if not fs.exists(path_info):
return
if force:
fs.remove(path_info)
return
current = get_hash(path_info, fs, fs.PARAM_CHECKSUM, cache)
try:
obj = load(cache, current)
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
msg = (
f"file/directory '{path_info}' is going to be removed. "
"Are you sure you want to proceed?"
)
if not prompt.confirm(msg):
raise ConfirmRemoveError(str(path_info))
fs.remove(path_info)
def _verify_link(cache, path_info, link_type):
if link_type == "hardlink" and cache.fs.getsize(path_info) == 0:
return
if cache.cache_type_confirmed:
return
is_link = getattr(cache.fs, f"is_{link_type}", None)
if is_link and not is_link(path_info):
cache.fs.remove(path_info)
raise DvcException(f"failed to verify {link_type}")
cache.cache_type_confirmed = True
def _do_link(cache, from_info, to_info, link_method):
if cache.fs.exists(to_info):
raise DvcException(f"Link '{to_info}' already exists!")
link_method(from_info, to_info)
logger.debug(
"Created '%s': %s -> %s", cache.cache_types[0], from_info, to_info,
)
@slow_link_guard
def _try_links(cache, from_info, to_info, link_types):
while link_types:
link_method = getattr(cache.fs, link_types[0])
try:
_do_link(cache, from_info, to_info, link_method)
_verify_link(cache, to_info, link_types[0])
return
except DvcException as exc:
logger.debug(
"Cache type '%s' is not supported: %s", link_types[0], exc
)
del link_types[0]
raise CacheLinkError([to_info])
def _link(cache, from_info, to_info):
assert cache.fs.isfile(from_info)
cache.makedirs(to_info.parent)
_try_links(cache, from_info, to_info, cache.cache_types)
def _cache_is_copy(cache, path_info):
"""Checks whether cache uses copies."""
if cache.cache_type_confirmed:
return cache.cache_types[0] == "copy"
if set(cache.cache_types) <= {"copy"}:
return True
workspace_file = path_info.with_name("." + uuid())
test_cache_file = cache.fs.path_info / ".cache_type_test_file"
if not cache.fs.exists(test_cache_file):
cache.makedirs(test_cache_file.parent)
with cache.fs.open(test_cache_file, "wb") as fobj:
fobj.write(bytes(1))
try:
_link(cache, test_cache_file, workspace_file)
finally:
cache.fs.remove(workspace_file)
cache.fs.remove(test_cache_file)
cache.cache_type_confirmed = True
return cache.cache_types[0] == "copy"
def _checkout_file(
path_info, fs, obj, cache, force, progress_callback=None, relink=False,
):
"""The file is changed we need to checkout a new copy"""
modified = False
cache_info = cache.hash_to_path_info(obj.hash_info.value)
if fs.exists(path_info):
if not relink and _changed(path_info, fs, obj, cache):
modified = True
_remove(path_info, fs, cache, force=force)
_link(cache, cache_info, path_info)
else:
if fs.iscopy(path_info) and _cache_is_copy(cache, path_info):
cache.unprotect(path_info)
else:
_remove(path_info, fs, cache, force=force)
_link(cache, cache_info, path_info)
else:
_link(cache, cache_info, path_info)
modified = True
fs.repo.state.save(path_info, fs, obj.hash_info)
if progress_callback:
progress_callback(str(path_info))
return modified
def _remove_redundant_files(path_info, fs, dir_info, cache, force):
existing_files = set(fs.walk_files(path_info))
needed_files = {path_info.joinpath(*key) for key, _ in dir_info.items()}
redundant_files = existing_files - needed_files
for path in redundant_files:
_remove(path, fs, cache, force)
return bool(redundant_files)
def _checkout_dir(
path_info, fs, obj, cache, force, progress_callback=None, relink=False,
):
modified = False, False
# Create dir separately so that dir is created
# even if there are no files in it
if not fs.exists(path_info):
modified = True
fs.makedirs(path_info)
logger.debug("Linking directory '%s'.", path_info)
for entry_key, entry_hash_info in obj.hash_info.dir_info.items():
entry_modified = _checkout_file(
path_info.joinpath(*entry_key),
fs,
cache.get(entry_hash_info),
cache,
force,
progress_callback,
relink,
)
if entry_modified:
modified = True
modified = (
_remove_redundant_files(
path_info, fs, obj.hash_info.dir_info, cache, force
)
or modified
)
fs.repo.state.save(path_info, fs, obj.hash_info)
# relink is not modified, assume it as nochange
return modified and not relink
def _checkout(
path_info,
fs,
obj,
cache,
force=False,
progress_callback=None,
relink=False,
):
if not obj.hash_info.isdir:
ret = _checkout_file(
path_info, fs, obj, cache, force, progress_callback, relink
)
else:
ret = _checkout_dir(
path_info, fs, obj, cache, force, progress_callback, relink,
)
fs.repo.state.save_link(path_info, fs)
return ret
def checkout(
path_info,
fs,
obj,
cache,
force=False,
progress_callback=None,
relink=False,
quiet=False,
):
if path_info.scheme not in ["local", cache.fs.scheme]:
raise NotImplementedError
failed = None
skip = False
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):
logger.trace("Data '%s' didn't change.", path_info)
skip = True
else:
try:
check(cache, obj)
except (FileNotFoundError, ObjectFormatError):
if not quiet:
logger.warning(
"Cache '%s' not found. File '%s' won't be created.",
obj.hash_info,
path_info,
)
_remove(path_info, fs, cache, force=force)
failed = path_info
if failed or skip:
if progress_callback and obj:
progress_callback(
str(path_info), len(obj),
)
if 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,
)
+6 -13
View File
@@ -4,6 +4,7 @@ import logging
import os
import sys
from ._debug import add_debugging_flags
from .command import (
add,
cache,
@@ -27,6 +28,7 @@ from .command import (
imp_url,
init,
install,
live,
ls,
metrics,
move,
@@ -37,6 +39,7 @@ from .command import (
repro,
root,
run,
stage,
unprotect,
update,
version,
@@ -80,8 +83,10 @@ COMMANDS = [
update,
git_hook,
plots,
stage,
experiments,
check_ignore,
live,
]
@@ -137,19 +142,7 @@ def get_parent_parser():
in order to prevent some weird behavior.
"""
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument(
"--cprofile",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
parent_parser.add_argument("--cprofile-dump", help=argparse.SUPPRESS)
parent_parser.add_argument(
"--pdb", action="store_true", default=False, help=argparse.SUPPRESS,
)
add_debugging_flags(parent_parser)
log_level_group = parent_parser.add_mutually_exclusive_group()
log_level_group.add_argument(
"-q", "--quiet", action="count", default=0, help="Be quiet."
+39 -2
View File
@@ -3,13 +3,17 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException, RecursiveAddingWhileUsingFilename
logger = logging.getLogger(__name__)
class CmdAdd(CmdBase):
def run(self):
from dvc.exceptions import (
DvcException,
RecursiveAddingWhileUsingFilename,
)
try:
if len(self.args.targets) > 1 and self.args.file:
raise RecursiveAddingWhileUsingFilename()
@@ -22,6 +26,10 @@ class CmdAdd(CmdBase):
external=self.args.external,
glob=self.args.glob,
desc=self.args.desc,
out=self.args.out,
remote=self.args.remote,
to_remote=self.args.to_remote,
jobs=self.args.jobs,
)
except DvcException:
@@ -67,9 +75,38 @@ def add_parser(subparsers, parent_parser):
)
parser.add_argument(
"--file",
help="Specify name of the DVC-file this command will generate.",
help="Specify name of the .dvc file this command will generate.",
metavar="<filename>",
)
parser.add_argument(
"-o",
"--out",
help="Destination path to put files to.",
metavar="<path>",
)
parser.add_argument(
"--to-remote",
action="store_true",
default=False,
help="Download it directly to the remote",
)
parser.add_argument(
"-r",
"--remote",
help="Remote storage to download to",
metavar="<name>",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
parser.add_argument(
"--desc",
type=str,
-8
View File
@@ -45,14 +45,6 @@ class CmdBase(ABC):
updater = Updater(self.repo.tmp_dir, hardlink_lock=hardlink_lock)
updater.check()
@property
def default_targets(self):
"""Default targets for `dvc repro`."""
from dvc.dvcfile import PIPELINE_FILE
logger.debug(f"assuming default target '{PIPELINE_FILE}'.")
return [PIPELINE_FILE]
@abstractmethod
def run(self):
pass
+14 -3
View File
@@ -11,10 +11,21 @@ logger = logging.getLogger(__name__)
class CmdCacheDir(CmdConfig):
def run(self):
if self.args.value is None and not self.args.unset:
logger.info(self.config["cache"]["dir"])
if self.args.level:
conf = self.config.read(level=self.args.level)
else:
# Use merged config with default values
conf = self.config
self._check(conf, False, "cache", "dir")
logger.info(conf["cache"]["dir"])
return 0
with self.config.edit(level=self.args.level) as edit:
edit["cache"]["dir"] = self.args.value
with self.config.edit(level=self.args.level) as conf:
if self.args.unset:
self._check(conf, False, "cache", "dir")
del conf["cache"]["dir"]
else:
self._check(conf, False, "cache")
conf["cache"]["dir"] = self.args.value
return 0
+3 -2
View File
@@ -3,7 +3,6 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
@@ -11,7 +10,7 @@ logger = logging.getLogger(__name__)
class CmdCheckIgnore(CmdBase):
def __init__(self, args):
super().__init__(args)
self.ignore_filter = self.repo.tree.dvcignore
self.ignore_filter = self.repo.fs.dvcignore
def _show_results(self, result):
if not result.match and not self.args.non_matching:
@@ -55,6 +54,8 @@ class CmdCheckIgnore(CmdBase):
return ret
def _check_args(self):
from dvc.exceptions import DvcException
if not self.args.stdin and not self.args.targets:
raise DvcException("`targets` or `--stdin` needed")
+2 -1
View File
@@ -7,7 +7,6 @@ import colorama
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import CheckoutError
from dvc.utils.humanize import get_summary
logger = logging.getLogger(__name__)
@@ -39,6 +38,8 @@ def log_changes(stats):
class CmdCheckout(CmdBase):
def run(self):
from dvc.utils.humanize import get_summary
stats, exc = None, None
try:
stats = self.repo.checkout(
+8 -4
View File
@@ -3,13 +3,14 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
class CmdCommit(CmdBase):
def run(self):
from dvc.exceptions import DvcException
if not self.args.targets:
self.args.targets = [None]
@@ -32,7 +33,10 @@ class CmdCommit(CmdBase):
def add_parser(subparsers, parent_parser):
COMMIT_HELP = "Save changed data to cache and update DVC-files."
COMMIT_HELP = (
"Record changes to files or directories tracked by DVC"
" by storing the current versions in the cache."
)
commit_parser = subparsers.add_parser(
"commit",
@@ -65,7 +69,7 @@ def add_parser(subparsers, parent_parser):
commit_parser.add_argument(
"targets",
nargs="*",
help="DVC-files to commit. Optional. "
"(Finds all DVC-files in the workspace by default.)",
help="stages or .dvc files to commit. Optional. "
"(Finds all DVC files in the workspace by default.)",
).complete = completion.DVC_FILE
commit_parser.set_defaults(func=CmdCommit)
+68 -6
View File
@@ -8,10 +8,7 @@ from dvc.command.base import CmdBaseNoRepo, append_doc_link
logger = logging.getLogger(__name__)
FILE = shtab.FILE
DIR = shtab.DIRECTORY
DVC_FILE = {
"bash": "_dvc_compgen_DVCFiles",
"zsh": "_files -g '(*?.dvc|Dvcfile|dvc.yaml)'",
}
PREAMBLE = {
"bash": """
# $1=COMP_WORDS[1]
@@ -21,8 +18,73 @@ _dvc_compgen_DVCFiles() {
compgen -f -X '!*Dvcfile' -- $1
compgen -f -X '!*dvc.yaml' -- $1
}
""",
"zsh": "",
_dvc_compgen_stages() {
local _dvc_stages=($(dvc stage list -q --names-only))
compgen -W "${_dvc_stages[*]}" -- $1
}
_dvc_compgen_stages_and_files() {
_dvc_compgen_DVCFiles $1
_dvc_compgen_stages $1
}
_dvc_compgen_exps() {
local _dvc_exps=($(dvc exp list -q --all --names-only))
compgen -W "${_dvc_exps[*]}" -- $1
}
""",
"zsh": """
_dvc_compadd_DVCFiles() {
_files -g '(*?.dvc|Dvcfile|dvc.yaml)'
}
_dvc_compadd_stages() {
# this will also show up the description of the stages
_describe 'stages' "($(_dvc_stages_output))"
}
_dvc_stages_output() {
dvc stage list -q | awk '{
# escape possible `:` on the stage name
sub(/:/, "\\\\\\\\:", $1);
# read all of the columns except the first
# reading `out` from $2, so as not to have a leading whitespace
out=$2; for(i=3;i<=NF;i++){out=out" "$i};
# print key, ":" and then single-quote the description
# colon is a delimiter used by `_describe` to separate field/description
print $1":""\\047"out"\\047"
# single quote -> \\047
}'
}
_dvc_compadd_stages_and_files() {
_dvc_compadd_DVCFiles
_dvc_compadd_stages
}
_dvc_compadd_exps() {
_describe 'experiments' "($(dvc exp list -q -a --names-only))"
}
""",
}
DVC_FILE = {
"bash": "_dvc_compgen_DVCFiles",
"zsh": "_dvc_compadd_DVCFiles",
}
STAGE = {
"bash": "_dvc_compgen_stages",
"zsh": "_dvc_compadd_stages",
}
DVCFILES_AND_STAGE = {
"bash": "_dvc_compgen_stages_and_files",
"zsh": "_dvc_compadd_stages_and_files",
}
EXPERIMENT = {
"bash": "_dvc_compgen_exps",
"zsh": "_dvc_compadd_exps",
}
+148 -16
View File
@@ -1,33 +1,115 @@
import argparse
import logging
import os
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.config import Config, ConfigError
logger = logging.getLogger(__name__)
NAME_REGEX = r"^(?P<remote>remote\.)?(?P<section>[^\.]*)\.(?P<option>[^\.]*)$"
def _name_type(value):
import re
match = re.match(NAME_REGEX, value)
if not match:
raise argparse.ArgumentTypeError(
"name argument should look like "
"remote.name.option or "
"section.option"
)
return (
bool(match.group("remote")),
match.group("section").lower(),
match.group("option").lower(),
)
class CmdConfig(CmdBaseNoRepo):
def __init__(self, args):
from dvc.config import Config
super().__init__(args)
self.config = Config(validate=False)
def run(self):
section, opt = self.args.name.lower().strip().split(".", 1)
if self.args.show_origin:
if any((self.args.value, self.args.unset)):
logger.error(
"--show-origin can't be used together with any of these "
"options: -u/--unset, value"
)
return 1
if self.args.list:
return self._list()
if self.args.name is None:
logger.error("name argument is required")
return 1
remote, section, opt = self.args.name
if self.args.value is None and not self.args.unset:
conf = self.config.load_one(self.args.level)
self._check(conf, section, opt)
logger.info(conf[section][opt])
return 0
return self._get(remote, section, opt)
return self._set(remote, section, opt)
def _list(self):
if any((self.args.name, self.args.value, self.args.unset)):
logger.error(
"-l/--list can't be used together with any of these "
"options: -u/--unset, name, value"
)
return 1
levels = [self.args.level] if self.args.level else self.config.LEVELS
for level in levels:
conf = self.config.read(level)
prefix = self._config_file_prefix(
self.args.show_origin, self.config, level
)
logger.info("\n".join(self._format_config(conf, prefix)))
return 0
def _get(self, remote, section, opt):
from dvc.config import ConfigError
levels = (
[self.args.level] if self.args.level else self.config.LEVELS[::-1]
)
for level in levels:
conf = self.config.read(level)
if remote:
conf = conf["remote"]
try:
self._check(conf, remote, section, opt)
except ConfigError:
if self.args.level:
raise
else:
prefix = self._config_file_prefix(
self.args.show_origin, self.config, level
)
logger.info("{}{}".format(prefix, conf[section][opt]))
break
return 0
def _set(self, remote, section, opt):
with self.config.edit(self.args.level) as conf:
if remote:
conf = conf["remote"]
if self.args.unset:
self._check(conf, section, opt)
self._check(conf, remote, section, opt)
del conf[section][opt]
else:
self._check(conf, section)
self._check(conf, remote, section)
conf[section][opt] = self.args.value
if self.args.name == "cache.type":
@@ -39,14 +121,39 @@ class CmdConfig(CmdBaseNoRepo):
return 0
def _check(self, conf, section, opt=None):
def _check(self, conf, remote, section, opt=None):
from dvc.config import ConfigError
name = "remote" if remote else "section"
if section not in conf:
msg = "section {} doesn't exist"
raise ConfigError(msg.format(self.args.name))
raise ConfigError(f"{name} '{section}' doesn't exist")
if opt and opt not in conf[section]:
msg = "option {} doesn't exist"
raise ConfigError(msg.format(self.args.name))
raise ConfigError(
f"option '{opt}' doesn't exist in {name} '{section}'"
)
@staticmethod
def _format_config(config, prefix=""):
from dvc.utils.flatten import flatten
for key, value in flatten(config).items():
yield f"{prefix}{key}={value}"
@staticmethod
def _config_file_prefix(show_origin, config, level):
from dvc.repo import Repo
if not show_origin:
return ""
level = level or "repo"
fname = config.files[level]
if level in ["local", "repo"]:
fname = os.path.relpath(fname, start=Repo.find_root())
return fname + "\t"
parent_config_parser = argparse.ArgumentParser(add_help=False)
@@ -65,14 +172,21 @@ level_group.add_argument(
const="system",
help="Use system config.",
)
level_group.add_argument(
"--project",
dest="level",
action="store_const",
const="repo",
help="Use project config (.dvc/config).",
)
level_group.add_argument(
"--local",
dest="level",
action="store_const",
const="local",
help="Use local config.",
help="Use local config (.dvc/config.local).",
)
parent_config_parser.set_defaults(level="repo")
parent_config_parser.set_defaults(level=None)
def add_parser(subparsers, parent_parser):
@@ -92,6 +206,24 @@ def add_parser(subparsers, parent_parser):
action="store_true",
help="Unset option.",
)
config_parser.add_argument("name", help="Option name.")
config_parser.add_argument(
"name",
nargs="?",
type=_name_type,
help="Option name (section.option or remote.name.option).",
)
config_parser.add_argument("value", nargs="?", help="Option value.")
config_parser.add_argument(
"-l",
"--list",
default=False,
action="store_true",
help="List all defined config values.",
)
config_parser.add_argument(
"--show-origin",
default=False,
action="store_true",
help="Show the source file containing each config value.",
)
config_parser.set_defaults(func=CmdConfig)
+2 -1
View File
@@ -2,7 +2,6 @@ import argparse
import logging
from dvc.command.base import CmdBase, append_doc_link
from dvc.stage import Stage
logger = logging.getLogger(__name__)
@@ -53,6 +52,8 @@ def _collect_targets(repo, target, outs):
def _transform(repo, outs):
import networkx as nx
from dvc.stage import Stage
def _relabel(node) -> str:
return node.addressing if isinstance(node, Stage) else str(node)
+27 -4
View File
@@ -3,25 +3,28 @@ import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.command.checkout import log_changes
from dvc.exceptions import CheckoutError, DvcException
from dvc.utils.humanize import get_summary
logger = logging.getLogger(__name__)
class CmdDataBase(CmdBase):
def log_summary(self, stats):
from dvc.utils.humanize import get_summary
default_msg = "Everything is up to date."
logger.info(get_summary(stats.items()) or default_msg)
class CmdDataPull(CmdDataBase):
def log_summary(self, stats):
from dvc.command.checkout import log_changes
log_changes(stats)
super().log_summary(stats)
def run(self):
from dvc.exceptions import CheckoutError, DvcException
try:
stats = self.repo.pull(
targets=self.args.targets,
@@ -34,6 +37,7 @@ class CmdDataPull(CmdDataBase):
force=self.args.force,
recursive=self.args.recursive,
run_cache=self.args.run_cache,
glob=self.args.glob,
)
self.log_summary(stats)
except (CheckoutError, DvcException) as exc:
@@ -46,6 +50,8 @@ class CmdDataPull(CmdDataBase):
class CmdDataPush(CmdDataBase):
def run(self):
from dvc.exceptions import DvcException
try:
processed_files_count = self.repo.push(
targets=self.args.targets,
@@ -57,6 +63,7 @@ class CmdDataPush(CmdDataBase):
with_deps=self.args.with_deps,
recursive=self.args.recursive,
run_cache=self.args.run_cache,
glob=self.args.glob,
)
self.log_summary({"pushed": processed_files_count})
except DvcException:
@@ -67,6 +74,8 @@ class CmdDataPush(CmdDataBase):
class CmdDataFetch(CmdDataBase):
def run(self):
from dvc.exceptions import DvcException
try:
processed_files_count = self.repo.fetch(
targets=self.args.targets,
@@ -179,6 +188,12 @@ def add_parser(subparsers, _parent_parser):
default=False,
help="Fetch run history for all stages.",
)
pull_parser.add_argument(
"--glob",
action="store_true",
default=False,
help="Pull cache for targets matching shell-style wildcards.",
)
pull_parser.set_defaults(func=CmdDataPull)
# Push
@@ -234,10 +249,18 @@ def add_parser(subparsers, _parent_parser):
default=False,
help="Push run history for all stages.",
)
push_parser.add_argument(
"--glob",
action="store_true",
default=False,
help="Allows targets containing shell-style wildcards.",
)
push_parser.set_defaults(func=CmdDataPush)
# Fetch
FETCH_HELP = "Download cached files or directories from remote storage."
FETCH_HELP = (
"Download files or directories from remote storage to the cache."
)
fetch_parser = subparsers.add_parser(
"fetch",
+3 -2
View File
@@ -3,13 +3,14 @@ import logging
import dvc.prompt as prompt
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
class CmdDestroy(CmdBase):
def run(self):
from dvc.exceptions import DvcException
try:
statement = (
"This will destroy all information about your pipelines,"
@@ -32,7 +33,7 @@ class CmdDestroy(CmdBase):
def add_parser(subparsers, parent_parser):
DESTROY_HELP = "Remove DVC-files, local DVC config and data cache."
DESTROY_HELP = "Remove DVC files, local DVC config and data cache."
destroy_parser = subparsers.add_parser(
"destroy",
+30 -7
View File
@@ -5,8 +5,8 @@ import os
import colorama
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ def _show_md(diff, show_hash=False, hide_missing=False):
header = ["Status", "Hash", "Path"] if show_hash else ["Status", "Path"]
rows = []
statuses = ["added", "deleted", "modified"]
statuses = ["added", "deleted", "renamed", "modified"]
if not hide_missing:
statuses.append("not in cache")
for status in statuses:
@@ -31,6 +31,8 @@ def _show_md(diff, show_hash=False, hide_missing=False):
continue
for entry in entries:
path = entry["path"]
if isinstance(path, dict):
path = f"{path['old']} -> {path['new']}"
if show_hash:
check_sum = _digest(entry.get("hash", ""))
rows.append([status, check_sum, path])
@@ -72,13 +74,14 @@ class CmdDiff(CmdBase):
"added": colorama.Fore.GREEN,
"modified": colorama.Fore.YELLOW,
"deleted": colorama.Fore.RED,
"renamed": colorama.Fore.GREEN,
"not in cache": colorama.Fore.YELLOW,
}
summary = {}
groups = []
states = ["added", "deleted", "modified"]
states = ["added", "deleted", "renamed", "modified"]
if not hide_missing:
states.append("not in cache")
for state in states:
@@ -92,6 +95,8 @@ class CmdDiff(CmdBase):
for entry in entries:
path = entry["path"]
if isinstance(path, dict):
path = f"{path['old']} -> {path['new']}"
checksum = entry.get("hash")
summary[state] += 1 if not path.endswith(os.sep) else 0
content.append(
@@ -99,7 +104,7 @@ class CmdDiff(CmdBase):
space=" ",
checksum=_digest(checksum) if checksum else "",
separator=" " if checksum else "",
path=entry["path"],
path=path,
)
)
@@ -117,7 +122,7 @@ class CmdDiff(CmdBase):
fmt = (
"files summary: {added} added, {deleted} deleted,"
" {modified} modified"
" {renamed} renamed, {modified} modified"
)
if not hide_missing:
fmt += ", {not in cache} not in cache"
@@ -126,15 +131,24 @@ class CmdDiff(CmdBase):
return "\n\n".join(groups)
def run(self):
from dvc.exceptions import DvcException
try:
diff = self.repo.diff(self.args.a_rev, self.args.b_rev)
diff = self.repo.diff(
self.args.a_rev, self.args.b_rev, self.args.targets
)
show_hash = self.args.show_hash
hide_missing = self.args.b_rev or self.args.hide_missing
if hide_missing:
del diff["not in cache"]
for key, entries in diff.items():
entries = sorted(entries, key=lambda entry: entry["path"])
entries = sorted(
entries,
key=lambda entry: entry["path"]["old"]
if isinstance(entry["path"], dict)
else entry["path"],
)
if not show_hash:
for entry in entries:
del entry["hash"]
@@ -167,6 +181,15 @@ def add_parser(subparsers, parent_parser):
help=DIFF_DESCRIPTION,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
diff_parser.add_argument(
"--targets",
nargs="*",
help=(
"Specific DVC-tracked files to compare. "
"Accepts one or more file paths."
),
metavar="<paths>",
).complete = completion.FILE
diff_parser.add_argument(
"a_rev",
help="Old Git commit to compare (defaults to HEAD)",
+501 -175
View File
@@ -1,28 +1,64 @@
import argparse
import io
import logging
import os
from collections import OrderedDict
from collections import Counter, OrderedDict, defaultdict
from collections.abc import Mapping
from datetime import date, datetime
from itertools import groupby
from typing import Iterable, Optional
from typing import Dict, Iterable, Optional
import dvc.prompt as prompt
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.command.metrics import DEFAULT_PRECISION
from dvc.command.repro import CmdRepro
from dvc.command.repro import add_arguments as add_repro_arguments
from dvc.exceptions import DvcException, InvalidArgumentError
from dvc.repo.experiments import Experiments
from dvc.scm.git import Git
from dvc.utils.flatten import flatten
logger = logging.getLogger(__name__)
SHOW_MAX_WIDTH = 1024
def _filter_name(names, label, filter_strs):
ret = defaultdict(dict)
path_filters = defaultdict(list)
for filter_s in filter_strs:
path, _, name = filter_s.rpartition(":")
path_filters[path].append(tuple(name.split(".")))
for path, filters in path_filters.items():
if path:
match_paths = [path]
else:
match_paths = names.keys()
for length, groups in groupby(filters, len):
for group in groups:
for match_path in match_paths:
possible_names = [
tuple(name.split(".")) for name in names[match_path]
]
matches = [
name
for name in possible_names
if name[:length] == group
]
if not matches:
name = ".".join(group)
raise InvalidArgumentError(
f"'{name}' does not match any known {label}"
)
ret[match_path].update(
{".".join(match): None for match in matches}
)
return ret
def _filter_names(
names: Iterable,
names: Dict[str, Dict[str, None]],
label: str,
include: Optional[Iterable],
exclude: Optional[Iterable],
@@ -36,44 +72,32 @@ def _filter_names(
f" --exclude-{label}"
)
names = [tuple(name.split(".")) for name in names]
def _filter(filters, update_func):
filters = [tuple(name.split(".")) for name in filters]
for length, groups in groupby(filters, len):
for group in groups:
matches = [name for name in names if name[:length] == group]
if not matches:
name = ".".join(group)
raise InvalidArgumentError(
f"'{name}' does not match any known {label}"
)
update_func({match: None for match in matches})
if include:
ret: OrderedDict = OrderedDict()
_filter(include, ret.update)
ret = _filter_name(names, label, include)
else:
ret = OrderedDict({name: None for name in names})
ret = names
if exclude:
_filter(exclude, ret.difference_update) # type: ignore[attr-defined]
to_remove = _filter_name(names, label, exclude)
for path in to_remove:
if path in ret:
for key in to_remove[path]:
if key in ret[path]:
del ret[path][key]
return [".".join(name) for name in ret]
return ret
def _update_names(names, items):
for name, item in items:
if isinstance(item, dict):
item = flatten(item)
names.update(item.keys())
else:
names[name] = None
names[name].update({key: None for key in item})
def _collect_names(all_experiments, **kwargs):
metric_names = set()
param_names = set()
metric_names = defaultdict(dict)
param_names = defaultdict(dict)
for _, experiments in all_experiments.items():
for exp in experiments.values():
@@ -81,13 +105,13 @@ def _collect_names(all_experiments, **kwargs):
_update_names(param_names, exp.get("params", {}).items())
metric_names = _filter_names(
sorted(metric_names),
metric_names,
"metrics",
kwargs.get("include_metrics"),
kwargs.get("exclude_metrics"),
)
param_names = _filter_names(
sorted(param_names),
(param_names),
"params",
kwargs.get("include_params"),
kwargs.get("exclude_params"),
@@ -106,15 +130,16 @@ def _collect_rows(
sort_by=None,
sort_order=None,
):
from dvc.scm.git import Git
if sort_by:
if sort_by in metric_names:
sort_type = "metrics"
elif sort_by in param_names:
sort_type = "params"
else:
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
sort_path, sort_name, sort_type = _sort_column(
sort_by, metric_names, param_names
)
reverse = sort_order == "desc"
experiments = _sort_exp(experiments, sort_by, sort_type, reverse)
experiments = _sort_exp(
experiments, sort_path, sort_name, sort_type, reverse
)
new_checkpoint = True
for i, (rev, exp) in enumerate(experiments.items()):
@@ -170,22 +195,45 @@ def _collect_rows(
yield row, style
def _sort_exp(experiments, sort_by, typ, reverse):
def _sort_column(sort_by, metric_names, param_names):
path, _, sort_name = sort_by.rpartition(":")
matches = set()
if path:
if path in metric_names and sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
if path in param_names and sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
else:
for path in metric_names:
if sort_name in metric_names[path]:
matches.add((path, sort_name, "metrics"))
for path in param_names:
if sort_name in param_names[path]:
matches.add((path, sort_name, "params"))
if len(matches) == 1:
return matches.pop()
if len(matches) > 1:
raise InvalidArgumentError(
"Ambiguous sort column '{}' matched '{}'".format(
sort_by,
", ".join([f"{path}:{name}" for path, name, _ in matches]),
)
)
raise InvalidArgumentError(f"Unknown sort column '{sort_by}'")
def _sort_exp(experiments, sort_path, sort_name, typ, reverse):
def _sort(item):
rev, exp = item
tip = exp.get("checkpoint_tip")
if tip and tip != rev:
# Sort checkpoint experiments by tip commit
return _sort((tip, experiments[tip]))
for fname, item in exp.get(typ, {}).items():
if isinstance(item, dict):
item = flatten(item)
else:
item = {fname: item}
if sort_by in item:
val = item[sort_by]
return (val is None, val)
return (True, None)
data = exp.get(typ, {}).get(sort_path, {})
val = flatten(data).get(sort_name)
return (val is None, val)
ret = OrderedDict()
if "baseline" in experiments:
@@ -209,9 +257,9 @@ def _format_field(val, precision=DEFAULT_PRECISION):
if isinstance(val, float):
fmt = f"{{:.{precision}g}}"
return fmt.format(val)
elif isinstance(val, Mapping):
if isinstance(val, Mapping):
return {k: _format_field(v) for k, v in val.items()}
elif isinstance(val, list):
if isinstance(val, list):
return [_format_field(x) for x in val]
return str(val)
@@ -228,7 +276,7 @@ def _extend_row(row, names, items, precision):
item = flatten(item)
else:
item = {fname: item}
for name in names:
for name in names[fname]:
if name in item:
value = item[name]
if value is None:
@@ -243,24 +291,24 @@ def _extend_row(row, names, items, precision):
row.append("-")
def _parse_list(param_list):
def _parse_filter_list(param_list):
ret = []
for param_str in param_list:
# we don't care about filename prefixes for show, silently
# ignore it if provided to keep usage consistent with other
# metric/param list command options
_, _, param_str = param_str.rpartition(":")
ret.extend(param_str.split(","))
path, _, param_str = param_str.rpartition(":")
if path:
ret.extend(f"{path}:{param}" for param in param_str.split(","))
else:
ret.extend(param_str.split(","))
return ret
def _show_experiments(all_experiments, console, **kwargs):
from rich.table import Table
def _experiments_table(all_experiments, **kwargs):
from dvc.utils.table import Table
include_metrics = _parse_list(kwargs.pop("include_metrics", []))
exclude_metrics = _parse_list(kwargs.pop("exclude_metrics", []))
include_params = _parse_list(kwargs.pop("include_params", []))
exclude_params = _parse_list(kwargs.pop("exclude_params", []))
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", []))
exclude_params = _parse_filter_list(kwargs.pop("exclude_params", []))
metric_names, param_names = _collect_names(
all_experiments,
@@ -271,13 +319,21 @@ def _show_experiments(all_experiments, console, **kwargs):
)
table = Table()
table.add_column("Experiment", no_wrap=True)
table.add_column(
"Experiment", no_wrap=True, header_style="black on grey93"
)
if not kwargs.get("no_timestamp", False):
table.add_column("Created")
for name in metric_names:
table.add_column(name, justify="right", no_wrap=True)
for name in param_names:
table.add_column(name, justify="left")
table.add_column("Created", header_style="black on grey93")
_add_data_columns(
table,
metric_names,
justify="right",
no_wrap=True,
header_style="black on cornsilk1",
)
_add_data_columns(
table, param_names, justify="left", header_style="black on light_cyan1"
)
for base_rev, experiments in all_experiments.items():
for row, _, in _collect_rows(
@@ -285,7 +341,20 @@ def _show_experiments(all_experiments, console, **kwargs):
):
table.add_row(*row)
console.print(table)
return table
def _add_data_columns(table, names, **kwargs):
count = Counter(
name for path in names for name in names[path] for path in names
)
first = True
for path in names:
for name in names[path]:
col_name = name if count[name] == 1 else f"{path}:{name}"
kwargs["collapse"] = False if first else True
table.add_column(col_name, **kwargs)
first = False
def _format_json(item):
@@ -298,17 +367,13 @@ class CmdExperimentsShow(CmdBase):
def run(self):
from rich.console import Console
from dvc.utils.pager import pager
if not self.repo.experiments:
return 0
try:
all_experiments = self.repo.experiments.show(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
sha_only=self.args.sha,
num=self.args.num,
)
if self.args.show_json:
@@ -317,23 +382,13 @@ class CmdExperimentsShow(CmdBase):
logger.info(json.dumps(all_experiments, default=_format_json))
return 0
if self.args.no_pager:
console = Console()
else:
# Note: rich does not currently include a native way to force
# infinite width for use with a pager
console = Console(
file=io.StringIO(), force_terminal=True, width=9999
)
if self.args.precision is None:
precision = DEFAULT_PRECISION
else:
precision = self.args.precision
_show_experiments(
table = _experiments_table(
all_experiments,
console,
include_metrics=self.args.include_metrics,
exclude_metrics=self.args.exclude_metrics,
include_params=self.args.include_params,
@@ -344,8 +399,22 @@ class CmdExperimentsShow(CmdBase):
precision=precision,
)
if not self.args.no_pager:
pager(console.file.getvalue())
console = Console()
if self.args.no_pager:
console.print(table)
else:
from dvc.utils.pager import DvcPager
# NOTE: rich does not have native support for unlimited width
# via pager. we override rich table compression by setting
# console width to the full width of the table
measurement = table.__rich_measure__(console, SHOW_MAX_WIDTH)
console._width = ( # pylint: disable=protected-access
measurement.maximum
)
with console.pager(pager=DvcPager(), styles=True):
console.print(table)
except DvcException:
logger.exception("failed to show experiments")
return 1
@@ -355,10 +424,10 @@ class CmdExperimentsShow(CmdBase):
class CmdExperimentsApply(CmdBase):
def run(self):
if not self.repo.experiments:
return 0
self.repo.experiments.apply(self.args.experiment)
self.repo.experiments.apply(
self.args.experiment, force=self.args.force
)
return 0
@@ -402,8 +471,6 @@ def _show_diff(
class CmdExperimentsDiff(CmdBase):
def run(self):
if not self.repo.experiments:
return 0
try:
diff = self.repo.experiments.diff(
@@ -445,47 +512,41 @@ class CmdExperimentsDiff(CmdBase):
class CmdExperimentsRun(CmdRepro):
def run(self):
if not self.repo.experiments:
return 0
from dvc.command.metrics import _show_metrics
saved_dir = os.path.realpath(os.curdir)
os.chdir(self.args.cwd)
if self.args.reset:
logger.info("Any existing checkpoints will be reset and re-run.")
# Dirty hack so the for loop below can at least enter once
if self.args.all_pipelines:
self.args.targets = [None]
elif not self.args.targets:
self.args.targets = self.default_targets
results = self.repo.experiments.run(
name=self.args.name,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.set_param,
checkpoint_resume=self.args.checkpoint_resume,
reset=self.args.reset,
tmp_dir=self.args.tmp_dir,
**self._repro_kwargs,
)
ret = 0
for target in self.args.targets:
try:
self.repo.experiments.run(
target,
name=self.args.name,
queue=self.args.queue,
run_all=self.args.run_all,
jobs=self.args.jobs,
params=self.args.params,
checkpoint_resume=self.args.checkpoint_resume,
**self._repro_kwargs,
)
except DvcException:
logger.exception("")
ret = 1
break
if self.args.metrics and results:
metrics = self.repo.metrics.show(revs=list(results))
metrics.pop("workspace", None)
logger.info(_show_metrics(metrics))
os.chdir(saved_dir)
return ret
return 0
def _raise_error_if_all_disabled(**kwargs):
if not any(kwargs.values()):
raise InvalidArgumentError(
"Either of `-w|--workspace`, `-a|--all-branches`, `-T|--all-tags` "
"or `--all-commits` needs to be set."
)
class CmdExperimentsGC(CmdRepro):
def run(self):
from dvc.repo.gc import _raise_error_if_all_disabled
if not self.repo.experiments:
return 0
_raise_error_if_all_disabled(
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
@@ -536,23 +597,110 @@ class CmdExperimentsGC(CmdRepro):
class CmdExperimentsBranch(CmdBase):
def run(self):
if not self.repo.experiments:
return 0
self.repo.experiments.branch(self.args.experiment, self.args.branch)
return 0
class CmdExperimentsList(CmdBase):
def run(self):
names_only = self.args.names_only
exps = self.repo.experiments.ls(
rev=self.args.rev,
git_remote=self.args.git_remote,
all_=self.args.all,
)
for baseline in exps:
tag = self.repo.scm.describe(baseline)
if not tag:
branch = self.repo.scm.describe(baseline, base="refs/heads")
if branch:
tag = branch.split("/")[-1]
name = tag if tag else baseline[:7]
if not names_only:
print(f"{name}:")
for exp_name in exps[baseline]:
indent = "" if names_only else "\t"
print(f"{indent}{exp_name}")
return 0
class CmdExperimentsPush(CmdBase):
def run(self):
self.repo.experiments.push(
self.args.git_remote,
self.args.experiment,
force=self.args.force,
push_cache=self.args.push_cache,
dvc_remote=self.args.dvc_remote,
jobs=self.args.jobs,
run_cache=self.args.run_cache,
)
logger.info(
"Pushed experiment '%s' to Git remote '%s'.",
self.args.experiment,
self.args.git_remote,
)
if not self.args.push_cache:
logger.info(
"To push cached outputs for this experiment to DVC remote "
"storage, re-run this command without '--no-cache'."
)
return 0
class CmdExperimentsPull(CmdBase):
def run(self):
self.repo.experiments.pull(
self.args.git_remote,
self.args.experiment,
force=self.args.force,
pull_cache=self.args.pull_cache,
dvc_remote=self.args.dvc_remote,
jobs=self.args.jobs,
run_cache=self.args.run_cache,
)
logger.info(
"Pulled experiment '%s' from Git remote '%s'. ",
self.args.experiment,
self.args.git_remote,
)
if not self.args.pull_cache:
logger.info(
"To pull cached outputs for this experiment from DVC remote "
"storage, re-run this command without '--no-cache'."
)
return 0
class CmdExperimentsRemove(CmdBase):
def run(self):
self.repo.experiments.remove(
exp_names=self.args.experiment, queue=self.args.queue,
)
return 0
def add_parser(subparsers, parent_parser):
EXPERIMENTS_HELP = "Commands to display and compare experiments."
EXPERIMENTS_HELP = "Commands to run and compare experiments."
experiments_parser = subparsers.add_parser(
"experiments",
parents=[parent_parser],
aliases=["exp"],
description=append_doc_link(EXPERIMENTS_HELP, "experiments"),
description=append_doc_link(EXPERIMENTS_HELP, "exp"),
formatter_class=argparse.RawDescriptionHelpFormatter,
help=EXPERIMENTS_HELP,
)
experiments_subparsers = experiments_parser.add_subparsers(
@@ -567,7 +715,7 @@ def add_parser(subparsers, parent_parser):
experiments_show_parser = experiments_subparsers.add_parser(
"show",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_SHOW_HELP, "experiments/show"),
description=append_doc_link(EXPERIMENTS_SHOW_HELP, "exp/show"),
help=EXPERIMENTS_SHOW_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -576,20 +724,30 @@ def add_parser(subparsers, parent_parser):
"--all-branches",
action="store_true",
default=False,
help="Show metrics for all branches.",
help="Show experiments derived from the tip of all Git branches.",
)
experiments_show_parser.add_argument(
"-T",
"--all-tags",
action="store_true",
default=False,
help="Show metrics for all tags.",
help="Show experiments derived from all Git tags.",
)
experiments_show_parser.add_argument(
"-A",
"--all-commits",
action="store_true",
default=False,
help="Show metrics for all commits.",
help="Show experiments derived from all Git commits.",
)
experiments_show_parser.add_argument(
"-n",
"--num",
type=int,
default=1,
dest="num",
metavar="<num>",
help="Show the last `num` commits from HEAD.",
)
experiments_show_parser.add_argument(
"--no-pager",
@@ -671,15 +829,19 @@ def add_parser(subparsers, parent_parser):
experiments_apply_parser = experiments_subparsers.add_parser(
"apply",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_APPLY_HELP, "experiments/apply"
),
description=append_doc_link(EXPERIMENTS_APPLY_HELP, "exp/apply"),
help=EXPERIMENTS_APPLY_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_apply_parser.add_argument(
"experiment", help="Experiment to be applied.",
"--no-force",
action="store_false",
dest="force",
help="Fail if this command would overwrite conflicting changes.",
)
experiments_apply_parser.add_argument(
"experiment", help="Experiment to be applied.",
).complete = completion.EXPERIMENT
experiments_apply_parser.set_defaults(func=CmdExperimentsApply)
EXPERIMENTS_DIFF_HELP = (
@@ -688,18 +850,18 @@ def add_parser(subparsers, parent_parser):
experiments_diff_parser = experiments_subparsers.add_parser(
"diff",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_DIFF_HELP, "experiments/diff"),
description=append_doc_link(EXPERIMENTS_DIFF_HELP, "exp/diff"),
help=EXPERIMENTS_DIFF_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_diff_parser.add_argument(
"a_rev", nargs="?", help="Old experiment to compare (defaults to HEAD)"
)
).complete = completion.EXPERIMENT
experiments_diff_parser.add_argument(
"b_rev",
nargs="?",
help="New experiment to compare (defaults to the current workspace)",
)
).complete = completion.EXPERIMENT
experiments_diff_parser.add_argument(
"--all",
action="store_true",
@@ -747,42 +909,29 @@ def add_parser(subparsers, parent_parser):
experiments_run_parser = experiments_subparsers.add_parser(
"run",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_RUN_HELP, "experiments/run"),
description=append_doc_link(EXPERIMENTS_RUN_HELP, "exp/run"),
help=EXPERIMENTS_RUN_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_common(experiments_run_parser)
experiments_run_parser.add_argument(
"--checkpoint-resume", type=str, default=None, help=argparse.SUPPRESS,
)
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
EXPERIMENTS_RESUME_HELP = "Resume checkpoint experiments."
experiments_resume_parser = experiments_subparsers.add_parser(
"resume",
parents=[parent_parser],
aliases=["res"],
description=append_doc_link(
EXPERIMENTS_RESUME_HELP, "experiments/resume"
),
help=EXPERIMENTS_RESUME_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
_add_run_common(experiments_resume_parser)
experiments_resume_parser.add_argument(
"-r",
"--rev",
type=str,
default=Experiments.LAST_CHECKPOINT,
dest="checkpoint_resume",
help=(
"Continue the specified checkpoint experiment. "
"If no experiment revision is provided, "
"the most recently run checkpoint experiment will be used."
"(Only required for explicitly resuming checkpoints in queued "
"or temp dir runs.)"
),
metavar="<experiment_rev>",
).complete = completion.EXPERIMENT
experiments_run_parser.add_argument(
"--reset",
action="store_true",
help="Reset existing checkpoints and restart the experiment.",
)
experiments_resume_parser.set_defaults(func=CmdExperimentsRun)
experiments_run_parser.set_defaults(func=CmdExperimentsRun)
EXPERIMENTS_GC_HELP = "Garbage collect unneeded experiments."
EXPERIMENTS_GC_DESCRIPTION = (
@@ -792,9 +941,7 @@ def add_parser(subparsers, parent_parser):
experiments_gc_parser = experiments_subparsers.add_parser(
"gc",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_GC_DESCRIPTION, "experiments/gc"
),
description=append_doc_link(EXPERIMENTS_GC_DESCRIPTION, "exp/gc"),
help=EXPERIMENTS_GC_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -847,9 +994,7 @@ def add_parser(subparsers, parent_parser):
experiments_branch_parser = experiments_subparsers.add_parser(
"branch",
parents=[parent_parser],
description=append_doc_link(
EXPERIMENTS_BRANCH_HELP, "experiments/branch"
),
description=append_doc_link(EXPERIMENTS_BRANCH_HELP, "exp/branch"),
help=EXPERIMENTS_BRANCH_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
@@ -861,6 +1006,176 @@ def add_parser(subparsers, parent_parser):
)
experiments_branch_parser.set_defaults(func=CmdExperimentsBranch)
EXPERIMENTS_LIST_HELP = "List local and remote experiments."
experiments_list_parser = experiments_subparsers.add_parser(
"list",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_LIST_HELP, "exp/list"),
help=EXPERIMENTS_LIST_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_list_parser.add_argument(
"--rev",
type=str,
default=None,
help=(
"List experiments derived from the specified revision. "
"Defaults to HEAD if neither `--rev` nor `--all` are specified."
),
metavar="<rev>",
)
experiments_list_parser.add_argument(
"--all", action="store_true", help="List all experiments.",
)
experiments_list_parser.add_argument(
"--names-only",
action="store_true",
help="Only output experiment names (without parent commits).",
)
experiments_list_parser.add_argument(
"git_remote",
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."
),
metavar="[<git_remote>]",
)
experiments_list_parser.set_defaults(func=CmdExperimentsList)
EXPERIMENTS_PUSH_HELP = "Push a local experiment to a Git remote."
experiments_push_parser = experiments_subparsers.add_parser(
"push",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_PUSH_HELP, "exp/push"),
help=EXPERIMENTS_PUSH_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_push_parser.add_argument(
"-f",
"--force",
action="store_true",
help="Replace experiment in the Git remote if it already exists.",
)
experiments_push_parser.add_argument(
"--no-cache",
action="store_false",
dest="push_cache",
help=(
"Do not push cached outputs for this experiment to DVC remote "
"storage."
),
)
experiments_push_parser.add_argument(
"-r",
"--remote",
dest="dvc_remote",
metavar="<name>",
help="Name of the DVC remote to use when pushing cached outputs.",
)
experiments_push_parser.add_argument(
"-j",
"--jobs",
type=int,
metavar="<number>",
help=(
"Number of jobs to run simultaneously when pushing to DVC remote "
"storage."
),
)
experiments_push_parser.add_argument(
"--run-cache",
action="store_true",
default=False,
help="Push run history for all stages.",
)
experiments_push_parser.add_argument(
"git_remote",
help="Git remote name or Git URL.",
metavar="<git_remote>",
)
experiments_push_parser.add_argument(
"experiment", help="Experiment to push.", metavar="<experiment>",
).complete = completion.EXPERIMENT
experiments_push_parser.set_defaults(func=CmdExperimentsPush)
EXPERIMENTS_PULL_HELP = "Pull an experiment from a Git remote."
experiments_pull_parser = experiments_subparsers.add_parser(
"pull",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_PULL_HELP, "exp/pull"),
help=EXPERIMENTS_PULL_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_pull_parser.add_argument(
"-f",
"--force",
action="store_true",
help="Replace local experiment already exists.",
)
experiments_pull_parser.add_argument(
"--no-cache",
action="store_false",
dest="pull_cache",
help=(
"Do not pull cached outputs for this experiment from DVC remote "
"storage."
),
)
experiments_pull_parser.add_argument(
"-r",
"--remote",
dest="dvc_remote",
metavar="<name>",
help="Name of the DVC remote to use when pulling cached outputs.",
)
experiments_pull_parser.add_argument(
"-j",
"--jobs",
type=int,
metavar="<number>",
help=(
"Number of jobs to run simultaneously when pulling from DVC "
"remote storage."
),
)
experiments_pull_parser.add_argument(
"--run-cache",
action="store_true",
default=False,
help="Pull run history for all stages.",
)
experiments_pull_parser.add_argument(
"git_remote",
help="Git remote name or Git URL.",
metavar="<git_remote>",
)
experiments_pull_parser.add_argument(
"experiment", help="Experiment to pull.", metavar="<experiment>",
)
experiments_pull_parser.set_defaults(func=CmdExperimentsPull)
EXPERIMENTS_REMOVE_HELP = "Remove local experiments."
experiments_remove_parser = experiments_subparsers.add_parser(
"remove",
parents=[parent_parser],
description=append_doc_link(EXPERIMENTS_REMOVE_HELP, "exp/remove"),
help=EXPERIMENTS_REMOVE_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
experiments_remove_parser.add_argument(
"--queue", action="store_true", help="Remove all queued experiments.",
)
experiments_remove_parser.add_argument(
"experiment",
nargs="*",
help="Experiments to remove.",
metavar="<experiment>",
)
experiments_remove_parser.set_defaults(func=CmdExperimentsRemove)
def _add_run_common(parser):
"""Add common args for 'exp run' and 'exp resume'."""
@@ -874,13 +1189,15 @@ def _add_run_common(parser):
"Human-readable experiment name. If not specified, a name will "
"be auto-generated."
),
metavar="<name>",
)
parser.add_argument(
"--params",
"-S",
"--set-param",
action="append",
default=[],
help="Use the specified param values when reproducing pipelines.",
metavar="[<filename>:]<params_list>",
help="Use the specified param value when reproducing pipelines.",
metavar="[<filename>:]<param_name>=<param_value>",
)
parser.add_argument(
"--queue",
@@ -892,7 +1209,7 @@ def _add_run_common(parser):
"--run-all",
action="store_true",
default=False,
help="Execute all experiments in the run queue.",
help="Execute all experiments in the run queue. Implies --temp.",
)
parser.add_argument(
"-j",
@@ -901,3 +1218,12 @@ def _add_run_common(parser):
help="Run the specified number of experiments at a time in parallel.",
metavar="<number>",
)
parser.add_argument(
"--temp",
action="store_true",
dest="tmp_dir",
help=(
"Run this experiment in a separate temporary directory instead of "
"your workspace."
),
)
+9
View File
@@ -30,6 +30,8 @@ class CmdGC(CmdBase):
msg += " and all git branches"
elif self.args.all_tags:
msg += " and all git tags"
elif self.args.all_experiments:
msg += " and all experiments"
if self.args.repos:
msg += " of the current and the following repos:"
@@ -49,6 +51,7 @@ class CmdGC(CmdBase):
all_branches=self.args.all_branches,
all_tags=self.args.all_tags,
all_commits=self.args.all_commits,
all_experiments=self.args.all_experiments,
cloud=self.args.cloud,
remote=self.args.remote,
force=self.args.force,
@@ -99,6 +102,12 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Keep data files for all Git commits.",
)
gc_parser.add_argument(
"--all-experiments",
action="store_true",
default=False,
help="Keep data files for all experiments.",
)
gc_parser.add_argument(
"-c",
"--cloud",
+12
View File
@@ -39,6 +39,7 @@ class CmdGet(CmdBaseNoRepo):
path=self.args.path,
out=self.args.out,
rev=self.args.rev,
jobs=self.args.jobs,
)
return 0
except DvcException:
@@ -84,4 +85,15 @@ def add_parser(subparsers, parent_parser):
help="Print the storage location (URL) the target data would be "
"downloaded from, and exit.",
)
get_parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
get_parser.set_defaults(func=CmdGet)
+12 -1
View File
@@ -14,7 +14,7 @@ class CmdGetUrl(CmdBaseNoRepo):
from dvc.repo import Repo
try:
Repo.get_url(self.args.url, out=self.args.out)
Repo.get_url(self.args.url, out=self.args.out, jobs=self.args.jobs)
return 0
except DvcException:
logger.exception(f"failed to get '{self.args.url}'")
@@ -36,4 +36,15 @@ def add_parser(subparsers, parent_parser):
get_parser.add_argument(
"out", nargs="?", help="Destination path to put data to.",
).complete = completion.DIR
get_parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
get_parser.set_defaults(func=CmdGetUrl)
+5 -6
View File
@@ -69,14 +69,13 @@ class CmdMergeDriver(CmdHookBase):
dvc = Repo()
try:
with dvc.state:
ancestor = Dvcfile(dvc, self.args.ancestor, verify=False)
our = Dvcfile(dvc, self.args.our, verify=False)
their = Dvcfile(dvc, self.args.their, verify=False)
ancestor = Dvcfile(dvc, self.args.ancestor, verify=False)
our = Dvcfile(dvc, self.args.our, verify=False)
their = Dvcfile(dvc, self.args.their, verify=False)
our.merge(ancestor, their)
our.merge(ancestor, their)
return 0
return 0
finally:
dvc.close()
+14 -2
View File
@@ -19,6 +19,7 @@ class CmdImport(CmdBase):
rev=self.args.rev,
no_exec=self.args.no_exec,
desc=self.args.desc,
jobs=self.args.jobs,
)
except DvcException:
logger.exception(
@@ -64,14 +65,14 @@ def add_parser(subparsers, parent_parser):
)
import_parser.add_argument(
"--file",
help="Specify name of the DVC-file this command will generate.",
help="Specify name of the .dvc file this command will generate.",
metavar="<filename>",
)
import_parser.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create DVC-file without actually downloading it.",
help="Only create .dvc file without actually downloading it.",
)
import_parser.add_argument(
"--desc",
@@ -82,4 +83,15 @@ def add_parser(subparsers, parent_parser):
"This doesn't affect any DVC operations."
),
)
import_parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
import_parser.set_defaults(func=CmdImport)
+28 -2
View File
@@ -16,7 +16,10 @@ class CmdImportUrl(CmdBase):
out=self.args.out,
fname=self.args.file,
no_exec=self.args.no_exec,
remote=self.args.remote,
to_remote=self.args.to_remote,
desc=self.args.desc,
jobs=self.args.jobs,
)
except DvcException:
logger.exception(
@@ -59,14 +62,37 @@ def add_parser(subparsers, parent_parser):
).complete = completion.DIR
import_parser.add_argument(
"--file",
help="Specify name of the DVC-file this command will generate.",
help="Specify name of the .dvc file this command will generate.",
metavar="<filename>",
).complete = completion.DIR
import_parser.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create DVC-file without actually downloading it.",
help="Only create .dvc file without actually downloading it.",
)
import_parser.add_argument(
"--to-remote",
action="store_true",
default=False,
help="Download it directly to the remote",
)
import_parser.add_argument(
"-r",
"--remote",
help="Remote storage to download to",
metavar="<name>",
)
import_parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
import_parser.add_argument(
"--desc",
+94
View File
@@ -0,0 +1,94 @@
import argparse
import logging
import os
from dvc.command import completion
from dvc.command.base import CmdBase, fix_subparsers
from dvc.utils.html import write
logger = logging.getLogger(__name__)
class CmdLive(CmdBase):
UNINITIALIZED = True
def _run(self, target, revs=None):
metrics, plots = self.repo.live.show(target=target, revs=revs)
html_path = self.args.target + ".html"
write(html_path, plots, metrics)
logger.info(f"\nfile://{os.path.abspath(html_path)}")
return 0
class CmdLiveShow(CmdLive):
def run(self):
return self._run(self.args.target)
class CmdLiveDiff(CmdLive):
def run(self):
return self._run(self.args.target, self.args.revs)
def shared_parent_parser():
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument(
"target", help="Logs dir to produce summary from",
).complete = completion.DIR
parent_parser.add_argument(
"-o",
"--out",
default=None,
help="Destination path to save plots to",
metavar="<path>",
).complete = completion.DIR
return parent_parser
def add_parser(subparsers, parent_parser):
LIVE_DESCRIPTION = (
"Commands to visualize and compare dvclive-produced logs."
)
live_parser = subparsers.add_parser(
"live",
parents=[parent_parser],
formatter_class=argparse.RawDescriptionHelpFormatter,
description=LIVE_DESCRIPTION,
)
live_subparsers = live_parser.add_subparsers(
dest="cmd",
help="Use `dvc live CMD --help` to display command-specific help.",
)
fix_subparsers(live_subparsers)
SHOW_HELP = "Visualize dvclive directory content."
live_show_parser = live_subparsers.add_parser(
"show",
parents=[parent_parser, shared_parent_parser()],
help=SHOW_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
live_show_parser.set_defaults(func=CmdLiveShow)
DIFF_HELP = (
"Show multiple versions of dvclive data, "
"by plotting it in single view."
)
live_diff_parser = live_subparsers.add_parser(
"diff",
parents=[parent_parser, shared_parent_parser()],
help=DIFF_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
live_diff_parser.add_argument(
"--revs",
nargs="*",
default=None,
help="Git revision (e.g. SHA, branch, tag)",
metavar="<commit>",
)
live_diff_parser.set_defaults(func=CmdLiveDiff)
+62 -15
View File
@@ -12,33 +12,61 @@ DEFAULT_PRECISION = 5
def _show_metrics(
metrics, all_branches=False, all_tags=False, all_commits=False
metrics,
markdown=False,
all_branches=False,
all_tags=False,
all_commits=False,
precision=None,
):
from dvc.utils.diff import format_dict
from dvc.utils.diff import format_dict, table
from dvc.utils.flatten import flatten
# When `metrics` contains a `None` key, it means that some files
# specified as `targets` in `repo.metrics.show` didn't contain any metrics.
missing = metrics.pop(None, None)
with_rev = any([all_branches, all_tags, all_commits])
header_set = set()
rows = []
lines = []
for branch, val in metrics.items():
if all_branches or all_tags or all_commits:
lines.append(f"{branch}:")
if precision is None:
precision = DEFAULT_PRECISION
for fname, metric in val.items():
def _round(val):
if isinstance(val, float):
return round(val, precision)
return val
for _branch, val in metrics.items():
for _fname, metric in val.items():
if not isinstance(metric, dict):
lines.append("\t{}: {}".format(fname, str(metric)))
header_set.add("")
continue
for key, _val in flatten(format_dict(metric)).items():
header_set.add(key)
header = sorted(header_set)
for branch, val in metrics.items():
for fname, metric in val.items():
row = []
if with_rev:
row.append(branch)
row.append(fname)
if not isinstance(metric, dict):
row.append(str(metric))
rows.append(row)
continue
flattened_val = flatten(format_dict(metric))
lines.append(f"\t{fname}:")
for key, value in flatten(format_dict(metric)).items():
lines.append(f"\t\t{key}: {value}")
for i in header:
row.append(_round(flattened_val.get(i)))
rows.append(row)
header.insert(0, "Path")
if with_rev:
header.insert(0, "Revision")
if missing:
raise BadMetricError(missing)
return "\n".join(lines)
return table(header, rows, markdown)
class CmdMetricsBase(CmdBase):
@@ -63,6 +91,7 @@ class CmdMetricsShow(CmdMetricsBase):
else:
table = _show_metrics(
metrics,
self.args.show_md,
self.args.all_branches,
self.args.all_tags,
self.args.all_commits,
@@ -201,6 +230,12 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Show output in JSON format.",
)
metrics_show_parser.add_argument(
"--show-md",
action="store_true",
default=False,
help="Show tabulated output in the Markdown format (GFM).",
)
metrics_show_parser.add_argument(
"-R",
"--recursive",
@@ -211,6 +246,15 @@ def add_parser(subparsers, parent_parser):
"metrics files."
),
)
metrics_show_parser.add_argument(
"--precision",
type=int,
help=(
"Round metrics to `n` digits precision after the decimal point. "
f"Rounds to {DEFAULT_PRECISION} digits by default."
),
metavar="<n>",
)
metrics_show_parser.set_defaults(func=CmdMetricsShow)
METRICS_DIFF_HELP = (
@@ -236,8 +280,11 @@ def add_parser(subparsers, parent_parser):
"--targets",
nargs="*",
help=(
"Limit command scope to these metrics files. Using -R, "
"directories to search metrics files in can also be given."
"Specific metrics file(s) to compare "
"(even if not found as `metrics` in `dvc.yaml`). "
"Using -R, directories to search metrics files in "
"can also be given."
"Shows all tracked metrics by default."
),
metavar="<paths>",
).complete = completion.FILE
+1 -1
View File
@@ -25,7 +25,7 @@ def add_parser(subparsers, parent_parser):
MOVE_HELP = "Rename or move a DVC controlled data file or a directory."
MOVE_DESCRIPTION = (
"Rename or move a DVC controlled data file or a directory.\n"
"It renames and modifies the corresponding DVC-file to reflect the"
"It renames and modifies the corresponding .dvc file to reflect the"
" changes."
)
+14
View File
@@ -2,6 +2,7 @@ import argparse
import logging
from collections import OrderedDict
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.exceptions import DvcException
@@ -30,11 +31,14 @@ def _show_diff(diff, markdown=False, no_path=False):
class CmdParamsDiff(CmdBase):
UNINITIALIZED = True
def run(self):
try:
diff = self.repo.params.diff(
a_rev=self.args.a_rev,
b_rev=self.args.b_rev,
targets=self.args.targets,
all=self.args.all,
)
@@ -91,6 +95,16 @@ def add_parser(subparsers, parent_parser):
nargs="?",
help=("New Git commit to compare (defaults to the current workspace)"),
)
params_diff_parser.add_argument(
"--targets",
nargs="*",
help=(
"Specific params file(s) to compare "
"(even if not found as `params` in `dvc.yaml`). "
"Shows all tracked params by default."
),
metavar="<paths>",
).complete = completion.FILE
params_diff_parser.add_argument(
"--all",
action="store_true",
+11 -32
View File
@@ -5,36 +5,19 @@ import os
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.exceptions import DvcException
from dvc.schema import PLOT_PROPS
from dvc.utils import format_link
from dvc.utils.html import write
logger = logging.getLogger(__name__)
PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
<title>DVC Plot</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
</head>
<body>
{divs}
</body>
</html>"""
DIV_HTML = """<div id = "{id}"></div>
<script type = "text/javascript">
var spec = {vega_json};
vegaEmbed('#{id}', spec);
</script>"""
class CmdPlots(CmdBase):
def _func(self, *args, **kwargs):
raise NotImplementedError
def _props(self):
from dvc.schema import PLOT_PROPS
# Pass only props specified by user, to not shadow ones from plot def
props = {p: getattr(self.args, p) for p in PLOT_PROPS}
return {k: v for k, v in props.items() if v is not None}
@@ -58,16 +41,10 @@ class CmdPlots(CmdBase):
logger.info(plots[target])
return 0
divs = [
DIV_HTML.format(id=f"plot{i}", vega_json=plot)
for i, plot in enumerate(plots.values())
]
html = PAGE_HTML.format(divs="\n".join(divs))
path = self.args.out or "plots.html"
path = os.path.join(os.getcwd(), path)
with open(path, "w") as fobj:
fobj.write(html)
write(path, plots)
logger.info(f"file://{path}")
@@ -158,10 +135,12 @@ def add_parser(subparsers, parent_parser):
plots_diff_parser.add_argument(
"--targets",
nargs="*",
help="Files to visualize (supports any file, "
"even when not found as `plots` in `dvc.yaml`). "
"Shows all plots by default.",
metavar="<path>",
help=(
"Specific plots file(s) to visualize "
"(even if not found as `plots` in `dvc.yaml`). "
"Shows all tracked plots by default."
),
metavar="<paths>",
).complete = completion.FILE
plots_diff_parser.add_argument(
"-e",
+15 -5
View File
@@ -3,7 +3,6 @@ import logging
from dvc.command.base import append_doc_link, fix_subparsers
from dvc.command.config import CmdConfig
from dvc.config import ConfigError, merge
from dvc.utils import format_link
logger = logging.getLogger(__name__)
@@ -17,12 +16,16 @@ class CmdRemote(CmdConfig):
self.args.name = self.args.name.lower()
def _check_exists(self, conf):
from dvc.config import ConfigError
if self.args.name not in conf["remote"]:
raise ConfigError(f"remote '{self.args.name}' doesn't exist.")
class CmdRemoteAdd(CmdRemote):
def run(self):
from dvc.config import ConfigError
if self.args.default:
logger.info(f"Setting '{self.args.name}' as a default remote.")
@@ -46,13 +49,14 @@ class CmdRemoteRemove(CmdRemote):
self._check_exists(conf)
del conf["remote"][self.args.name]
up_to_level = self.args.level or "repo"
# Remove core.remote refs to this remote in any shadowing configs
for level in reversed(self.config.LEVELS):
with self.config.edit(level) as conf:
if conf["core"].get("remote") == self.args.name:
del conf["core"]["remote"]
if level == self.args.level:
if level == up_to_level:
break
return 0
@@ -60,6 +64,8 @@ class CmdRemoteRemove(CmdRemote):
class CmdRemoteModify(CmdRemote):
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)
@@ -77,9 +83,10 @@ class CmdRemoteModify(CmdRemote):
class CmdRemoteDefault(CmdRemote):
def run(self):
from dvc.config import ConfigError
if self.args.name is None and not self.args.unset:
conf = self.config.load_one(self.args.level)
conf = self.config.read(self.args.level)
try:
print(conf["core"]["remote"])
except KeyError:
@@ -107,7 +114,7 @@ class CmdRemoteDefault(CmdRemote):
class CmdRemoteList(CmdRemote):
def run(self):
conf = self.config.load_one(self.args.level)
conf = self.config.read(self.args.level)
for name, conf in conf["remote"].items():
logger.info("{}\t{}".format(name, conf["url"]))
return 0
@@ -119,6 +126,8 @@ class CmdRemoteRename(CmdRemote):
conf["core"]["remote"] = self.args.new
def run(self):
from dvc.config import ConfigError
all_config = self.config.load_config_to_level(None)
if self.args.new in all_config.get("remote", {}):
raise ConfigError(
@@ -133,8 +142,9 @@ class CmdRemoteRename(CmdRemote):
del conf["remote"][self.args.name]
self._rename_default(conf)
up_to_level = self.args.level or "repo"
for level in reversed(self.config.LEVELS):
if level == self.args.level:
if level == up_to_level:
break
with self.config.edit(level) as level_conf:
self._rename_default(level_conf)
+6 -4
View File
@@ -14,15 +14,15 @@ class CmdRemove(CmdBase):
try:
self.repo.remove(target, outs=self.args.outs)
except DvcException:
logger.exception(f"failed to remove '{target}'")
logger.exception("")
return 1
return 0
def add_parser(subparsers, parent_parser):
REMOVE_HELP = (
"Remove stages or .dvc files, unprotect their outputs, and"
" erase .gitignore entries."
"Remove stages from dvc.yaml and/or"
" stop tracking files or directories."
)
remove_parser = subparsers.add_parser(
"remove",
@@ -38,6 +38,8 @@ def add_parser(subparsers, parent_parser):
help="Remove outputs as well.",
)
remove_parser.add_argument(
"targets", nargs="+", help="DVC-files to remove.",
"targets",
nargs="+",
help=".dvc files or stages from dvc.yaml to remove.",
).complete = completion.DVC_FILE
remove_parser.set_defaults(func=CmdRemove)
+16 -46
View File
@@ -1,56 +1,34 @@
import argparse
import logging
import os
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.command.metrics import _show_metrics
from dvc.command.status import CmdDataStatus
from dvc.dvcfile import PIPELINE_FILE
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
class CmdRepro(CmdBase):
def run(self):
saved_dir = os.path.realpath(os.curdir)
os.chdir(self.args.cwd)
stages = self.repo.reproduce(**self._repro_kwargs)
if len(stages) == 0:
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
else:
logger.info(
"Use `dvc push` to send your updates to " "remote storage."
)
# Dirty hack so the for loop below can at least enter once
if self.args.all_pipelines:
self.args.targets = [None]
elif not self.args.targets:
self.args.targets = self.default_targets
if self.args.metrics:
metrics = self.repo.metrics.show()
logger.info(_show_metrics(metrics))
ret = 0
for target in self.args.targets:
try:
stages = self.repo.reproduce(target, **self._repro_kwargs)
if len(stages) == 0:
logger.info(CmdDataStatus.UP_TO_DATE_MSG)
else:
logger.info(
"Use `dvc push` to send your updates to "
"remote storage."
)
if self.args.metrics:
metrics = self.repo.metrics.show()
logger.info(_show_metrics(metrics))
except DvcException:
logger.exception("")
ret = 1
break
os.chdir(saved_dir)
return ret
return 0
@property
def _repro_kwargs(self):
return {
"targets": self.args.targets,
"single_item": self.args.single_item,
"force": self.args.force,
"dry": self.args.dry,
@@ -71,8 +49,8 @@ def add_arguments(repro_parser):
repro_parser.add_argument(
"targets",
nargs="*",
help=f"Stages to reproduce. '{PIPELINE_FILE}' by default.",
).complete = completion.DVC_FILE
help="Stages to reproduce. 'dvc.yaml' by default.",
).complete = completion.DVCFILES_AND_STAGE
repro_parser.add_argument(
"-f",
"--force",
@@ -88,14 +66,6 @@ def add_arguments(repro_parser):
help="Reproduce only single data item without recursive dependencies "
"check.",
)
repro_parser.add_argument(
"-c",
"--cwd",
default=os.path.curdir,
help="Directory within your repo to reproduce from. Note: deprecated "
"by `dvc --cd <path>`.",
metavar="<path>",
)
repro_parser.add_argument(
"-m",
"--metrics",
@@ -122,8 +92,8 @@ def add_arguments(repro_parser):
"--pipeline",
action="store_true",
default=False,
help="Reproduce the whole pipeline that the specified stage file "
"belongs to.",
help="Reproduce the whole pipeline that the specified targets "
"belong to.",
)
repro_parser.add_argument(
"-P",
+14 -150
View File
@@ -1,8 +1,8 @@
import argparse
import logging
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link
from dvc.command.stage import parse_cmd
from dvc.exceptions import DvcException
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ class CmdRun(CmdBase):
self.args.outs_persist_no_cache,
self.args.checkpoints,
self.args.params,
self.args.command,
self.args.cmd,
]
): # pragma: no cover
logger.error(
@@ -35,13 +35,17 @@ class CmdRun(CmdBase):
try:
self.repo.run(
cmd=self._parsed_cmd(),
cmd=parse_cmd(self.args.cmd),
outs=self.args.outs,
outs_no_cache=self.args.outs_no_cache,
metrics=self.args.metrics,
metrics_no_cache=self.args.metrics_no_cache,
plots=self.args.plots,
plots_no_cache=self.args.plots_no_cache,
live=self.args.live,
live_no_cache=self.args.live_no_cache,
live_no_summary=self.args.live_no_summary,
live_no_html=self.args.live_no_html,
deps=self.args.deps,
params=self.args.params,
fname=self.args.file,
@@ -65,30 +69,13 @@ class CmdRun(CmdBase):
return 0
def _parsed_cmd(self):
"""
We need to take into account two cases:
- ['python code.py foo bar']: Used mainly with dvc as a library
- ['echo', 'foo bar']: List of arguments received from the CLI
The second case would need quoting, as it was passed through:
dvc run echo "foo bar"
"""
if len(self.args.command) < 2:
return " ".join(self.args.command)
return " ".join(self._quote_argument(arg) for arg in self.args.command)
def _quote_argument(self, argument):
if " " not in argument or '"' in argument:
return argument
return f'"{argument}"'
def add_parser(subparsers, parent_parser):
RUN_HELP = "Generate a stage file from a command and execute the command."
from dvc.command.stage import _add_common_args
RUN_HELP = (
"Generate a dvc.yaml file from a command and execute the command."
)
run_parser = subparsers.add_parser(
"run",
parents=[parent_parser],
@@ -96,93 +83,17 @@ def add_parser(subparsers, parent_parser):
help=RUN_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
run_parser.add_argument(
"-d",
"--deps",
action="append",
default=[],
help="Declare dependencies for reproducible cmd.",
metavar="<path>",
).complete = completion.FILE
run_parser.add_argument(
"-n", "--name", help="Stage name.",
)
run_parser.add_argument(
"-o",
"--outs",
action="append",
default=[],
help="Declare output file or directory.",
metavar="<filename>",
).complete = completion.FILE
run_parser.add_argument(
"-O",
"--outs-no-cache",
action="append",
default=[],
help="Declare output file or directory "
"(do not put into DVC cache).",
metavar="<filename>",
).complete = completion.FILE
run_parser.add_argument(
"-p",
"--params",
action="append",
default=[],
help="Declare parameter to use as additional dependency.",
metavar="[<filename>:]<params_list>",
).complete = completion.FILE
run_parser.add_argument(
"-m",
"--metrics",
action="append",
default=[],
help="Declare output metrics file.",
metavar="<path>",
)
run_parser.add_argument(
"-M",
"--metrics-no-cache",
action="append",
default=[],
help="Declare output metrics file (do not put into DVC cache).",
metavar="<path>",
)
run_parser.add_argument(
"--plots",
action="append",
default=[],
help="Declare output plot file.",
metavar="<path>",
)
run_parser.add_argument(
"--plots-no-cache",
action="append",
default=[],
help="Declare output plot file (do not put into DVC cache).",
metavar="<path>",
)
run_parser.add_argument(
"--file", metavar="<filename>", help=argparse.SUPPRESS,
)
run_parser.add_argument(
"-w",
"--wdir",
help="Directory within your repo to run your command in.",
metavar="<path>",
)
run_parser.add_argument(
"--no-exec",
action="store_true",
default=False,
help="Only create stage file without actually running it.",
)
run_parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite existing stage",
help="Only create dvc.yaml without actually running it.",
)
run_parser.add_argument(
"--no-run-cache",
@@ -199,58 +110,11 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Don't put files/directories into cache.",
)
run_parser.add_argument(
"--outs-persist",
action="append",
default=[],
help="Declare output file or directory that will not be "
"removed upon repro.",
metavar="<filename>",
)
run_parser.add_argument(
"--outs-persist-no-cache",
action="append",
default=[],
help="Declare output file or directory that will not be "
"removed upon repro (do not put into DVC cache).",
metavar="<filename>",
)
run_parser.add_argument(
"-c",
"--checkpoints",
action="append",
default=[],
help=argparse.SUPPRESS,
metavar="<filename>",
).complete = completion.FILE
run_parser.add_argument(
"--always-changed",
action="store_true",
default=False,
help="Always consider this DVC-file as changed.",
)
run_parser.add_argument(
"--single-stage",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
run_parser.add_argument(
"--external",
action="store_true",
default=False,
help="Allow outputs that are outside of the DVC repository.",
)
run_parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the stage (optional). "
"This doesn't affect any DVC operations."
),
)
run_parser.add_argument(
"command", nargs=argparse.REMAINDER, help="Command to execute."
)
_add_common_args(run_parser)
run_parser.set_defaults(func=CmdRun)
+364
View File
@@ -0,0 +1,364 @@
import argparse
import logging
from itertools import chain, filterfalse
from typing import TYPE_CHECKING, Dict, Iterable, List
from dvc.command import completion
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.utils.cli_parse import parse_params
from dvc.utils.humanize import truncate_text
if TYPE_CHECKING:
from dvc.output.base import BaseOutput
from dvc.stage import Stage
logger = logging.getLogger(__name__)
MAX_TEXT_LENGTH = 80
def generate_description(stage: "Stage") -> str:
def part_desc(outs: Iterable["BaseOutput"]) -> str:
return ", ".join(out.def_path for out in outs)
if not stage.deps and not stage.outs:
return "No outputs or dependencies"
if not stage.outs and stage.deps:
return "Depends on " + part_desc(stage.deps)
def is_plot_or_metric(out: "BaseOutput"):
return bool(out.plot) or bool(out.metric)
desc: List[str] = []
outs = list(filterfalse(is_plot_or_metric, stage.outs))
if outs:
desc.append("Outputs " + part_desc(outs))
plots_and_metrics = list(filter(is_plot_or_metric, stage.outs))
if plots_and_metrics:
desc.append("Reports " + part_desc(plots_and_metrics))
return "; ".join(desc)
def prepare_description(
stage: "Stage", max_length: int = MAX_TEXT_LENGTH
) -> str:
desc = stage.short_description() or generate_description(stage)
return truncate_text(desc, max_length)
def prepare_stages_data(
stages: Iterable["Stage"],
description: bool = True,
max_length: int = MAX_TEXT_LENGTH,
) -> Dict[str, str]:
return {
stage.addressing: prepare_description(stage, max_length=max_length)
if description
else ""
for stage in stages
}
class CmdStageList(CmdBase):
def _get_stages(self) -> Iterable["Stage"]:
if self.args.all:
stages: List["Stage"] = self.repo.stages # type: ignore
logger.trace( # type: ignore[attr-defined]
"%d no. of stages found", len(stages)
)
return stages
# removing duplicates while maintaining order
collected = chain.from_iterable(
self.repo.stage.collect(
target=target,
recursive=self.args.recursive,
accept_group=True,
)
for target in self.args.targets
)
return dict.fromkeys(collected).keys()
def run(self):
from dvc.utils.diff import table
def log_error(relpath: str, exc: Exception):
if self.args.fail:
raise exc
logger.debug("Stages from %s failed to load", relpath)
# silence stage collection error by default
self.repo.stage_collection_error_handler = log_error
stages = self._get_stages()
names_only = self.args.names_only
data = prepare_stages_data(stages, description=not names_only)
if data:
print(table(header=(), rows=data.items()))
return 0
def parse_cmd(commands: List[str]) -> str:
"""
We need to take into account two cases:
- ['python code.py foo bar']: Used mainly with dvc as a library
- ['echo', 'foo bar']: List of arguments received from the CLI
The second case would need quoting, as it was passed through:
dvc run echo "foo bar"
"""
def quote_argument(arg: str):
should_quote = " " in arg and '"' not in arg
return f'"{arg}"' if should_quote else arg
if len(commands) < 2:
return " ".join(commands)
return " ".join(map(quote_argument, commands))
class CmdStageAdd(CmdBase):
def run(self):
kwargs = vars(self.args)
kwargs.update(
{
"cmd": parse_cmd(kwargs.pop("cmd")),
"params": parse_params(self.args.params),
}
)
self.repo.stage.add(**kwargs)
return 0
def _add_common_args(parser):
parser.add_argument(
"-d",
"--deps",
action="append",
default=[],
help="Declare dependencies for reproducible cmd.",
metavar="<path>",
).complete = completion.FILE
parser.add_argument(
"-o",
"--outs",
action="append",
default=[],
help="Declare output file or directory.",
metavar="<filename>",
).complete = completion.FILE
parser.add_argument(
"-O",
"--outs-no-cache",
action="append",
default=[],
help="Declare output file or directory "
"(do not put into DVC cache).",
metavar="<filename>",
).complete = completion.FILE
parser.add_argument(
"-p",
"--params",
action="append",
default=[],
help="Declare parameter to use as additional dependency.",
metavar="[<filename>:]<params_list>",
).complete = completion.FILE
parser.add_argument(
"-m",
"--metrics",
action="append",
default=[],
help="Declare output metrics file.",
metavar="<path>",
)
parser.add_argument(
"-M",
"--metrics-no-cache",
action="append",
default=[],
help="Declare output metrics file (do not put into DVC cache).",
metavar="<path>",
)
parser.add_argument(
"--plots",
action="append",
default=[],
help="Declare output plot file.",
metavar="<path>",
)
parser.add_argument(
"--plots-no-cache",
action="append",
default=[],
help="Declare output plot file (do not put into DVC cache).",
metavar="<path>",
)
parser.add_argument(
"--live", help="Declare output as dvclive.", metavar="<path>",
)
parser.add_argument(
"--live-no-cache",
help="Declare output as dvclive (do not put into DVC cache).",
metavar="<path>",
)
parser.add_argument(
"--live-no-summary",
action="store_true",
default=False,
help="Signal dvclive logger to not dump latest metrics file.",
)
parser.add_argument(
"--live-no-html",
action="store_true",
default=False,
help="Signal dvclive logger to not produce training report.",
)
parser.add_argument(
"-w",
"--wdir",
help="Directory within your repo to run your command in.",
metavar="<path>",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
default=False,
help="Overwrite existing stage",
)
parser.add_argument(
"--outs-persist",
action="append",
default=[],
help="Declare output file or directory that will not be "
"removed upon repro.",
metavar="<filename>",
)
parser.add_argument(
"--outs-persist-no-cache",
action="append",
default=[],
help="Declare output file or directory that will not be "
"removed upon repro (do not put into DVC cache).",
metavar="<filename>",
)
parser.add_argument(
"-c",
"--checkpoints",
action="append",
default=[],
help=argparse.SUPPRESS,
metavar="<filename>",
).complete = completion.FILE
parser.add_argument(
"--always-changed",
action="store_true",
default=False,
help="Always consider this DVC-file as changed.",
)
parser.add_argument(
"--external",
action="store_true",
default=False,
help="Allow outputs that are outside of the DVC repository.",
)
parser.add_argument(
"--desc",
type=str,
metavar="<text>",
help=(
"User description of the stage (optional). "
"This doesn't affect any DVC operations."
),
)
parser.add_argument(
"cmd",
nargs=argparse.REMAINDER,
help="Command to execute.",
metavar="command",
)
def add_parser(subparsers, parent_parser):
STAGES_HELP = "Commands to list and create stages."
stage_parser = subparsers.add_parser(
"stage",
parents=[parent_parser],
description=append_doc_link(STAGES_HELP, "stage"),
help=STAGES_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
stage_subparsers = stage_parser.add_subparsers(
dest="cmd",
help="Use `dvc stage CMD --help` to display command-specific help.",
)
fix_subparsers(stage_subparsers)
STAGE_ADD_HELP = "Create stage"
stage_add_parser = stage_subparsers.add_parser(
"add",
parents=[parent_parser],
description=append_doc_link(STAGE_ADD_HELP, "stage/add"),
help=STAGE_ADD_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
stage_add_parser.add_argument(
"-n", "--name", help="Name of the stage to add", required=True
)
_add_common_args(stage_add_parser)
stage_add_parser.set_defaults(func=CmdStageAdd)
STAGE_LIST_HELP = "List stages."
stage_list_parser = stage_subparsers.add_parser(
"list",
parents=[parent_parser],
description=append_doc_link(STAGE_LIST_HELP, "stage/list"),
help=STAGE_LIST_HELP,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
stage_list_parser.add_argument(
"targets",
nargs="*",
default=["dvc.yaml"],
help=(
"Show stages from a dvc.yaml/.dvc file or a directory. "
"'dvc.yaml' by default"
),
)
stage_list_parser.add_argument(
"--all",
action="store_true",
default=False,
help="List all of the stages in the repo.",
)
stage_list_parser.add_argument(
"--fail",
action="store_true",
default=False,
help="Fail immediately, do not suppress any syntax errors.",
)
stage_list_parser.add_argument(
"-R",
"--recursive",
action="store_true",
default=False,
help="List all stages inside the specified directory.",
)
stage_list_parser.add_argument(
"--names-only",
action="store_true",
default=False,
help="List only stage names.",
)
stage_list_parser.set_defaults(func=CmdStageList)
+27 -1
View File
@@ -16,6 +16,9 @@ class CmdUpdate(CmdBase):
targets=self.args.targets,
rev=self.args.rev,
recursive=self.args.recursive,
to_remote=self.args.to_remote,
remote=self.args.remote,
jobs=self.args.jobs,
)
except DvcException:
logger.exception("failed update data")
@@ -33,7 +36,7 @@ def add_parser(subparsers, parent_parser):
formatter_class=argparse.RawDescriptionHelpFormatter,
)
update_parser.add_argument(
"targets", nargs="+", help="DVC-files to update.",
"targets", nargs="+", help=".dvc files to update.",
).complete = completion.DVC_FILE
update_parser.add_argument(
"--rev",
@@ -48,4 +51,27 @@ def add_parser(subparsers, parent_parser):
default=False,
help="Update all stages in the specified directory.",
)
update_parser.add_argument(
"--to-remote",
action="store_true",
default=False,
help="Update data directly on the remote",
)
update_parser.add_argument(
"-r",
"--remote",
help="Remote storage to perform updates to",
metavar="<name>",
)
update_parser.add_argument(
"-j",
"--jobs",
type=int,
help=(
"Number of jobs to run simultaneously. "
"The default value is 4 * cpu_count(). "
"For SSH remotes, the default is 4. "
),
metavar="<number>",
)
update_parser.set_defaults(func=CmdUpdate)
+2 -1
View File
@@ -2,13 +2,14 @@ import argparse
import logging
from dvc.command.base import CmdBaseNoRepo, append_doc_link
from dvc.info import get_dvc_info
logger = logging.getLogger(__name__)
class CmdVersion(CmdBaseNoRepo):
def run(self):
from dvc.info import get_dvc_info
dvc_info = get_dvc_info()
logger.info(dvc_info)
return 0
+61 -234
View File
@@ -4,25 +4,11 @@ import os
import re
from contextlib import contextmanager
from functools import partial
from urllib.parse import urlparse
import configobj
from funcy import cached_property, compact, re_find, walk_values
from voluptuous import (
ALLOW_EXTRA,
All,
Any,
Coerce,
Invalid,
Lower,
Optional,
Range,
Schema,
)
from funcy import cached_property, compact, memoize, re_find
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.path_info import PathInfo
from dvc.utils import relpath
logger = logging.getLogger(__name__)
@@ -38,205 +24,19 @@ class NoRemoteError(ConfigError):
pass
def supported_cache_type(types):
"""Checks if link type config option consists only of valid values.
@memoize
def get_compiled_schema():
from voluptuous import Schema
Args:
types (list/string): type(s) of links that dvc should try out.
"""
if types is None:
return None
if isinstance(types, str):
types = [typ.strip() for typ in types.split(",")]
from .config_schema import SCHEMA
unsupported = set(types) - {"reflink", "hardlink", "symlink", "copy"}
if unsupported:
raise Invalid(
"Unsupported cache type(s): {}".format(", ".join(unsupported))
)
return types
return Schema(SCHEMA)
# Checks that value is either true or false and converts it to bool
to_bool = Bool = All(
Lower,
Any("true", "false"),
lambda v: v == "true",
msg="expected true or false",
)
def to_bool(value):
from .config_schema import Bool
def Choices(*choices):
"""Checks that value belongs to the specified set of values
Args:
*choices: pass allowed values as arguments, or pass a list or
tuple as a single argument
"""
return Any(*choices, msg="expected one of {}".format(", ".join(choices)))
def ByUrl(mapping):
schemas = walk_values(Schema, mapping)
def validate(data):
if "url" not in data:
raise Invalid("expected 'url'")
parsed = urlparse(data["url"])
# Windows absolute paths should really have scheme == "" (local)
if os.name == "nt" and len(parsed.scheme) == 1 and parsed.netloc == "":
return schemas[""](data)
if parsed.scheme not in schemas:
raise Invalid(f"Unsupported URL type {parsed.scheme}://")
return schemas[parsed.scheme](data)
return validate
class RelPath(str):
pass
REMOTE_COMMON = {
"url": str,
"checksum_jobs": All(Coerce(int), Range(1)),
"jobs": All(Coerce(int), Range(1)),
Optional("no_traverse"): Bool, # obsoleted
"verify": Bool,
}
LOCAL_COMMON = {
"type": supported_cache_type,
Optional("protected", default=False): Bool, # obsoleted
"shared": All(Lower, Choices("group")),
Optional("slow_link_warning", default=True): Bool,
}
HTTP_COMMON = {
"auth": All(Lower, Choices("basic", "digest", "custom")),
"custom_auth_header": str,
"user": str,
"password": str,
"ask_password": Bool,
"ssl_verify": Bool,
"method": str,
}
WEBDAV_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"token": str,
"cert_path": str,
"key_path": str,
"timeout": Coerce(int),
}
SCHEMA = {
"core": {
"remote": Lower,
"checksum_jobs": All(Coerce(int), Range(1)),
Optional("interactive", default=False): Bool,
Optional("analytics", default=True): Bool,
Optional("hardlink_lock", default=False): Bool,
Optional("no_scm", default=False): Bool,
Optional("autostage", default=False): Bool,
Optional("experiments", default=False): Bool,
Optional("check_update", default=True): Bool,
},
"cache": {
"local": str,
"s3": str,
"gs": str,
"hdfs": str,
"webhdfs": str,
"ssh": str,
"azure": str,
# This is for default local cache
"dir": str,
**LOCAL_COMMON,
},
"remote": {
str: ByUrl(
{
"": {**LOCAL_COMMON, **REMOTE_COMMON},
"s3": {
"region": str,
"profile": str,
"credentialpath": str,
"endpointurl": str,
"access_key_id": str,
"secret_access_key": str,
Optional("listobjects", default=False): Bool, # obsoleted
Optional("use_ssl", default=True): Bool,
"sse": str,
"sse_kms_key_id": str,
"acl": str,
"grant_read": str,
"grant_read_acp": str,
"grant_write_acp": str,
"grant_full_control": str,
**REMOTE_COMMON,
},
"gs": {
"projectname": str,
"credentialpath": str,
**REMOTE_COMMON,
},
"ssh": {
"type": supported_cache_type,
"port": Coerce(int),
"user": str,
"password": str,
"ask_password": Bool,
"keyfile": str,
"timeout": Coerce(int),
"gss_auth": Bool,
"allow_agent": Bool,
**REMOTE_COMMON,
},
"hdfs": {"user": str, **REMOTE_COMMON},
"webhdfs": {
"hdfscli_config": str,
"webhdfs_token": str,
"user": str,
"webhdfs_alias": str,
**REMOTE_COMMON,
},
"azure": {"connection_string": str, **REMOTE_COMMON},
"oss": {
"oss_key_id": str,
"oss_key_secret": str,
"oss_endpoint": str,
**REMOTE_COMMON,
},
"gdrive": {
"gdrive_use_service_account": Bool,
"gdrive_client_id": str,
"gdrive_client_secret": str,
"gdrive_user_credentials_file": str,
"gdrive_service_account_email": str,
"gdrive_service_account_user_email": str,
"gdrive_service_account_p12_file_path": str,
Optional("gdrive_trash_only", default=False): Bool,
**REMOTE_COMMON,
},
"http": {**HTTP_COMMON, **REMOTE_COMMON},
"https": {**HTTP_COMMON, **REMOTE_COMMON},
"webdav": {**WEBDAV_COMMON, **REMOTE_COMMON},
"webdavs": {**WEBDAV_COMMON, **REMOTE_COMMON},
"remote": {str: object}, # Any of the above options are valid
}
)
},
"state": {
"row_limit": All(Coerce(int), Range(1)),
"row_cleanup_quota": All(Coerce(int), Range(0, 100)),
},
# section for experimental features
"feature": {Optional("parametrization", default=False): Bool},
}
COMPILED_SCHEMA = Schema(SCHEMA)
return Bool(value)
class Config(dict):
@@ -263,9 +63,9 @@ class Config(dict):
CONFIG_LOCAL = "config.local"
def __init__(
self, dvc_dir=None, validate=True, tree=None,
self, dvc_dir=None, validate=True, fs=None, config=None,
): # pylint: disable=super-init-not-called
from dvc.tree.local import LocalTree
from dvc.fs.local import LocalFileSystem
self.dvc_dir = dvc_dir
@@ -279,10 +79,10 @@ class Config(dict):
else:
self.dvc_dir = os.path.abspath(os.path.realpath(dvc_dir))
self.wtree = LocalTree(None, {"url": self.dvc_dir})
self.tree = tree or self.wtree
self.wfs = LocalFileSystem(None, {"url": self.dvc_dir})
self.fs = fs or self.wfs
self.load(validate=validate)
self.load(validate=validate, config=config)
@classmethod
def get_dir(cls, level):
@@ -322,7 +122,7 @@ class Config(dict):
open(config_file, "w+").close()
return Config(dvc_dir)
def load(self, validate=True):
def load(self, validate=True, config=None):
"""Loads config from all the config files.
Raises:
@@ -330,6 +130,9 @@ class Config(dict):
"""
conf = self.load_config_to_level()
if config is not None:
merge(conf, config)
if validate:
conf = self.validate(conf)
@@ -340,32 +143,36 @@ class Config(dict):
if not self["cache"].get("dir") and self.dvc_dir:
self["cache"]["dir"] = os.path.join(self.dvc_dir, "cache")
def _get_tree(self, level):
# NOTE: this might be a GitTree, which doesn't see things outside of
def _get_fs(self, level):
# NOTE: this might be a Gitfs, which doesn't see things outside of
# the repo.
return self.tree if level == "repo" else self.wtree
return self.fs if level == "repo" else self.wfs
def _load_config(self, level):
filename = self.files[level]
tree = self._get_tree(level)
from configobj import ConfigObj
if tree.exists(filename, use_dvcignore=False):
with tree.open(filename) as fobj:
conf_obj = configobj.ConfigObj(fobj)
filename = self.files[level]
fs = self._get_fs(level)
if fs.exists(filename, use_dvcignore=False):
with fs.open(filename) as fobj:
conf_obj = ConfigObj(fobj)
else:
conf_obj = configobj.ConfigObj()
conf_obj = ConfigObj()
return _parse_remotes(_lower_keys(conf_obj.dict()))
def _save_config(self, level, conf_dict):
from configobj import ConfigObj
filename = self.files[level]
tree = self._get_tree(level)
fs = self._get_fs(level)
logger.debug(f"Writing '{filename}'.")
tree.makedirs(os.path.dirname(filename))
fs.makedirs(os.path.dirname(filename))
config = configobj.ConfigObj(_pack_remotes(conf_dict))
with tree.open(filename, "wb") as fobj:
config = ConfigObj(_pack_remotes(conf_dict))
with fs.open(filename, "wb") as fobj:
config.write(fobj)
config.filename = filename
@@ -374,7 +181,7 @@ class Config(dict):
conf = self._load_paths(conf, self.files[level])
# Auto-verify sections
for key in COMPILED_SCHEMA.schema:
for key in get_compiled_schema().schema:
conf.setdefault(key, {})
return conf
@@ -384,6 +191,8 @@ class Config(dict):
abs_conf_dir = os.path.abspath(os.path.dirname(filename))
def resolve(path):
from .config_schema import RelPath
if os.path.isabs(path) or re.match(r"\w+://", path):
return path
@@ -398,6 +207,10 @@ class Config(dict):
@staticmethod
def _to_relpath(conf_dir, path):
from dvc.utils import relpath
from .config_schema import RelPath
if re.match(r"\w+://", path):
return path
@@ -415,13 +228,15 @@ class Config(dict):
@staticmethod
def _map_dirs(conf, func):
from voluptuous import ALLOW_EXTRA, Schema
dirs_schema = {
"cache": {"dir": func},
"remote": {
str: {
"url": func,
"gdrive_user_credentials_file": func,
"gdrive_service_account_p12_file_path": func,
"gdrive_service_account_json_file_path": func,
"credentialpath": func,
"keyfile": func,
"cert_path": func,
@@ -440,8 +255,16 @@ class Config(dict):
merge(merged_conf, self.load_one(merge_level))
return merged_conf
def read(self, level=None):
# NOTE: we read from a merged config by default, same as git config
if level is None:
return self.load_config_to_level()
return self.load_one(level)
@contextmanager
def edit(self, level="repo"):
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:
raise ConfigError("Not inside a DVC repo")
@@ -452,15 +275,19 @@ class Config(dict):
merged_conf = self.load_config_to_level(level)
merge(merged_conf, conf)
self.validate(merged_conf)
if validate:
self.validate(merged_conf)
self._save_config(level, conf)
self.load()
self.load(validate=validate)
@staticmethod
def validate(data):
from voluptuous import Invalid
try:
return COMPILED_SCHEMA(data)
return get_compiled_schema()(data)
except Invalid as exc:
raise ConfigError(str(exc)) from None
+225
View File
@@ -0,0 +1,225 @@
import os
from urllib.parse import urlparse
from funcy import walk_values
from voluptuous import (
All,
Any,
Coerce,
Invalid,
Lower,
Optional,
Range,
Schema,
)
Bool = All(
Lower,
Any("true", "false"),
lambda v: v == "true",
msg="expected true or false",
)
def supported_cache_type(types):
"""Checks if link type config option consists only of valid values.
Args:
types (list/string): type(s) of links that dvc should try out.
"""
if types is None:
return None
if isinstance(types, str):
types = [typ.strip() for typ in types.split(",")]
unsupported = set(types) - {"reflink", "hardlink", "symlink", "copy"}
if unsupported:
raise Invalid(
"Unsupported cache type(s): {}".format(", ".join(unsupported))
)
return types
def Choices(*choices):
"""Checks that value belongs to the specified set of values
Args:
*choices: pass allowed values as arguments, or pass a list or
tuple as a single argument
"""
return Any(*choices, msg="expected one of {}".format(", ".join(choices)))
def ByUrl(mapping):
schemas = walk_values(Schema, mapping)
def validate(data):
if "url" not in data:
raise Invalid("expected 'url'")
parsed = urlparse(data["url"])
# Windows absolute paths should really have scheme == "" (local)
if os.name == "nt" and len(parsed.scheme) == 1 and parsed.netloc == "":
return schemas[""](data)
if parsed.scheme not in schemas:
raise Invalid(f"Unsupported URL type {parsed.scheme}://")
return schemas[parsed.scheme](data)
return validate
class RelPath(str):
pass
REMOTE_COMMON = {
"url": str,
"checksum_jobs": All(Coerce(int), Range(1)),
"jobs": All(Coerce(int), Range(1)),
Optional("no_traverse"): Bool, # obsoleted
"verify": Bool,
}
LOCAL_COMMON = {
"type": supported_cache_type,
Optional("protected", default=False): Bool, # obsoleted
"shared": All(Lower, Choices("group")),
Optional("slow_link_warning", default=True): Bool,
}
HTTP_COMMON = {
"auth": All(Lower, Choices("basic", "digest", "custom")),
"custom_auth_header": str,
"user": str,
"password": str,
"ask_password": Bool,
"ssl_verify": Bool,
"method": str,
}
WEBDAV_COMMON = {
"user": str,
"password": str,
"ask_password": Bool,
"token": str,
"cert_path": str,
"key_path": str,
"timeout": Coerce(int),
}
SCHEMA = {
"core": {
"remote": Lower,
"checksum_jobs": All(Coerce(int), Range(1)),
Optional("interactive", default=False): Bool,
Optional("analytics", default=True): Bool,
Optional("hardlink_lock", default=False): Bool,
Optional("no_scm", default=False): Bool,
Optional("autostage", default=False): Bool,
Optional("experiments"): Bool, # obsoleted
Optional("check_update", default=True): Bool,
},
"cache": {
"local": str,
"s3": str,
"gs": str,
"hdfs": str,
"webhdfs": str,
"ssh": str,
"azure": str,
# This is for default local cache
"dir": str,
**LOCAL_COMMON,
},
"remote": {
str: ByUrl(
{
"": {**LOCAL_COMMON, **REMOTE_COMMON},
"s3": {
"region": str,
"profile": str,
"credentialpath": str,
"configpath": str,
"endpointurl": str,
"access_key_id": str,
"secret_access_key": str,
"session_token": str,
Optional("listobjects", default=False): Bool, # obsoleted
Optional("use_ssl", default=True): Bool,
"sse": str,
"sse_kms_key_id": str,
"acl": str,
"grant_read": str,
"grant_read_acp": str,
"grant_write_acp": str,
"grant_full_control": str,
**REMOTE_COMMON,
},
"gs": {
"projectname": str,
"credentialpath": str,
**REMOTE_COMMON,
},
"ssh": {
"type": supported_cache_type,
"port": Coerce(int),
"user": str,
"password": str,
"ask_password": Bool,
"keyfile": str,
"timeout": Coerce(int),
"gss_auth": Bool,
"allow_agent": Bool,
**REMOTE_COMMON,
},
"hdfs": {"user": str, **REMOTE_COMMON},
"webhdfs": {
"hdfscli_config": str,
"webhdfs_token": str,
"user": str,
"webhdfs_alias": str,
**REMOTE_COMMON,
},
"azure": {
"connection_string": str,
"sas_token": str,
"account_name": str,
"account_key": str,
"tenant_id": str,
"client_id": str,
"client_secret": str,
**REMOTE_COMMON,
},
"oss": {
"oss_key_id": str,
"oss_key_secret": str,
"oss_endpoint": str,
**REMOTE_COMMON,
},
"gdrive": {
"gdrive_use_service_account": Bool,
"gdrive_client_id": str,
"gdrive_client_secret": str,
"gdrive_user_credentials_file": str,
"gdrive_service_account_user_email": str,
"gdrive_service_account_json_file_path": str,
Optional("gdrive_trash_only", default=False): Bool,
**REMOTE_COMMON,
},
"http": {**HTTP_COMMON, **REMOTE_COMMON},
"https": {**HTTP_COMMON, **REMOTE_COMMON},
"webdav": {**WEBDAV_COMMON, **REMOTE_COMMON},
"webdavs": {**WEBDAV_COMMON, **REMOTE_COMMON},
"remote": {str: object}, # Any of the above options are valid
}
)
},
"state": {
"row_limit": All(Coerce(int), Range(1)), # obsoleted
"row_cleanup_quota": All(Coerce(int), Range(0, 100)), # obsoleted
},
# section for experimental features
"feature": {
# enabled by default. It's of no use, kept for backward compatibility.
Optional("parametrization", default=True): Bool
},
}
+26 -7
View File
@@ -2,9 +2,6 @@
import logging
from dvc.config import NoRemoteError
from dvc.remote import get_remote
logger = logging.getLogger(__name__)
@@ -23,6 +20,8 @@ class DataCloud:
self.repo = repo
def get_remote(self, name=None, command="<command>"):
from dvc.config import NoRemoteError
if not name:
name = self.repo.config["core"].get("remote")
@@ -45,6 +44,8 @@ class DataCloud:
raise NoRemoteError(error_msg)
def _init_remote(self, name):
from dvc.remote import get_remote
return get_remote(self.repo, name=name)
def push(
@@ -63,7 +64,7 @@ class DataCloud:
remote = self.get_remote(remote, "push")
return remote.push(
self.repo.cache.local,
self.repo.odb.local,
cache,
jobs=jobs,
show_checksums=show_checksums,
@@ -85,12 +86,30 @@ class DataCloud:
remote = self.get_remote(remote, "pull")
return remote.pull(
self.repo.cache.local,
self.repo.odb.local,
cache,
jobs=jobs,
show_checksums=show_checksums,
)
def transfer(self, source, jobs=None, remote=None, command=None):
"""Transfer data items in a cloud-agnostic way.
Args:
source (str): url for the source location.
jobs (int): number of jobs that can be running simultaneously.
remote (dvc.remote.base.BaseRemote): optional remote to compare
cache to. By default remote from core.remote config option
is used.
command (str): the command which is benefitting from this function
(to be used for reporting better error messages).
"""
from dvc.fs import get_cloud_fs
from_fs = get_cloud_fs(self.repo, url=source)
remote = self.get_remote(remote, command)
return remote.transfer(from_fs, from_fs.path_info, jobs=jobs)
def status(
self,
cache,
@@ -114,7 +133,7 @@ class DataCloud:
"""
remote = self.get_remote(remote, "status")
return remote.status(
self.repo.cache.local,
self.repo.odb.local,
cache,
jobs=jobs,
show_checksums=show_checksums,
@@ -123,4 +142,4 @@ class DataCloud:
def get_url_for(self, remote, checksum):
remote = self.get_remote(remote)
return str(remote.tree.hash_to_path_info(checksum))
return str(remote.odb.hash_to_path_info(checksum))
+5 -21
View File
@@ -17,23 +17,9 @@ from dvc.dependency.webhdfs import WebHDFSDependency
from dvc.output.base import BaseOutput
from dvc.scheme import Schemes
from ..tree import get_cloud_tree
from ..fs import get_cloud_fs
from .repo import RepoDependency
DEPS = [
AzureDependency,
GSDependency,
HDFSDependency,
HTTPDependency,
HTTPSDependency,
S3Dependency,
SSHDependency,
WebDAVDependency,
WebDAVSDependency,
WebHDFSDependency,
# NOTE: LocalDependency is the default choice
]
DEP_MAP = {
Schemes.LOCAL: LocalDependency,
Schemes.SSH: SSHDependency,
@@ -64,8 +50,8 @@ SCHEMA.update(ParamsDependency.PARAM_SCHEMA)
def _get(stage, p, info):
parsed = urlparse(p) if p else None
if parsed and parsed.scheme == "remote":
tree = get_cloud_tree(stage.repo, name=parsed.netloc)
return DEP_MAP[tree.scheme](stage, p, info, tree=tree)
fs = get_cloud_fs(stage.repo, name=parsed.netloc)
return DEP_MAP[fs.scheme](stage, p, info, fs=fs)
if info and info.get(RepoDependency.PARAM_REPO):
repo = info.pop(RepoDependency.PARAM_REPO)
@@ -75,10 +61,8 @@ def _get(stage, p, info):
params = info.pop(ParamsDependency.PARAM_PARAMS)
return ParamsDependency(stage, p, params)
for d in DEPS:
if d.supported(p):
return d(stage, p, info)
return LocalDependency(stage, p, info)
dep_cls = DEP_MAP.get(parsed.scheme, LocalDependency)
return dep_cls(stage, p, info)
def loadd_from(stage, d_list):
+2 -2
View File
@@ -1,8 +1,8 @@
from dvc.dependency.base import BaseDependency
from dvc.output.base import BaseOutput
from ..tree.azure import AzureTree
from ..fs.azure import AzureFileSystem
class AzureDependency(BaseDependency, BaseOutput):
TREE_CLS = AzureTree
FS_CLS = AzureFileSystem
+1 -1
View File
@@ -17,7 +17,7 @@ class DependencyIsNotFileOrDirError(DvcException):
class DependencyIsStageFileError(DvcException):
def __init__(self, path):
super().__init__(f"Stage file '{path}' cannot be a dependency.")
super().__init__(f"DVC file '{path}' cannot be a dependency.")
class BaseDependency:
+5 -3
View File
@@ -1,6 +1,8 @@
from dvc.dependency.base import BaseDependency
from dvc.output.gs import GSOutput
from dvc.output.base import BaseOutput
from ..fs.gs import GSFileSystem
class GSDependency(BaseDependency, GSOutput):
pass
class GSDependency(BaseDependency, BaseOutput):
FS_CLS = GSFileSystem
+2 -2
View File
@@ -1,8 +1,8 @@
from dvc.dependency.base import BaseDependency
from dvc.output.base import BaseOutput
from ..tree.http import HTTPTree
from ..fs.http import HTTPFileSystem
class HTTPDependency(BaseDependency, BaseOutput):
TREE_CLS = HTTPTree
FS_CLS = HTTPFileSystem
+2 -2
View File
@@ -1,6 +1,6 @@
from ..tree.https import HTTPSTree
from ..fs.https import HTTPSFileSystem
from .http import HTTPDependency
class HTTPSDependency(HTTPDependency):
TREE_CLS = HTTPSTree
FS_CLS = HTTPSFileSystem
+1 -1
View File
@@ -91,7 +91,7 @@ class ParamsDependency(LocalDependency):
suffix = self.path_info.suffix.lower()
loader = LOADERS[suffix]
try:
config = loader(self.path_info, tree=self.repo.tree)
config = loader(self.path_info, fs=self.repo.fs)
except ParseError as exc:
raise BadParamFileError(
f"Unable to read parameters from '{self}'"
+32 -11
View File
@@ -1,5 +1,7 @@
from voluptuous import Required
from dvc.path_info import PathInfo
from .local import LocalDependency
@@ -21,7 +23,7 @@ class RepoDependency(LocalDependency):
self.def_repo = def_repo
super().__init__(stage, *args, **kwargs)
def _parse_path(self, tree, path):
def _parse_path(self, fs, path):
return None
@property
@@ -45,10 +47,16 @@ class RepoDependency(LocalDependency):
return external_repo(d["url"], rev=rev, **kwargs)
def _get_hash(self, locked=True):
# we want stream but not fetch, so DVC out directories are
# walked, but dir contents is not fetched
with self._make_repo(locked=locked, fetch=False, stream=True) as repo:
return repo.get_checksum(self.def_path)
from dvc.objects.stage import stage
with self._make_repo(locked=locked) as repo:
path_info = PathInfo(repo.root_dir) / self.def_path
return stage(
self.repo.odb.local,
path_info,
repo.repo_fs,
follow_subrepos=False,
).hash_info
def workspace_status(self):
current = self._get_hash(locked=True)
@@ -68,16 +76,29 @@ class RepoDependency(LocalDependency):
def dumpd(self):
return {self.PARAM_PATH: self.def_path, self.PARAM_REPO: self.def_repo}
def download(self, to):
cache = self.repo.cache.local
def download(self, to, jobs=None):
from dvc.checkout import checkout
from dvc.config import NoRemoteError
from dvc.exceptions import NoOutputOrStageError
from dvc.objects import save
from dvc.objects.stage import stage
with self._make_repo(cache_dir=cache.cache_dir) as repo:
odb = self.repo.odb.local
with self._make_repo(cache_dir=odb.cache_dir) as repo:
if self.def_repo.get(self.PARAM_REV_LOCK) is None:
self.def_repo[self.PARAM_REV_LOCK] = repo.get_rev()
path_info = PathInfo(repo.root_dir) / self.def_path
try:
repo.fetch([path_info.fspath], jobs=jobs, recursive=True)
except (NoOutputOrStageError, NoRemoteError):
pass
obj = stage(
odb, path_info, repo.repo_fs, jobs=jobs, follow_subrepos=False,
)
save(odb, obj, jobs=jobs)
_, _, cache_infos = repo.fetch_external([self.def_path])
cache.checkout(to.path_info, cache_infos[0])
checkout(to.path_info, to.fs, obj, odb)
def update(self, rev=None):
if rev:
+2 -2
View File
@@ -1,8 +1,8 @@
from dvc.dependency.base import BaseDependency
from dvc.output.base import BaseOutput
from ..tree.webdav import WebDAVTree
from ..fs.webdav import WebDAVFileSystem
class WebDAVDependency(BaseDependency, BaseOutput):
TREE_CLS = WebDAVTree
FS_CLS = WebDAVFileSystem
+2 -2
View File
@@ -1,6 +1,6 @@
from ..tree.webdavs import WebDAVSTree
from ..fs.webdavs import WebDAVSFileSystem
from .webdav import WebDAVDependency
class WebDAVSDependency(WebDAVDependency):
TREE_CLS = WebDAVSTree
FS_CLS = WebDAVSFileSystem
+30 -15
View File
@@ -1,6 +1,7 @@
import posixpath
from operator import itemgetter
from funcy import cached_property
from pygtrie import Trie
from .hash_info import HashInfo
@@ -48,28 +49,36 @@ class DirInfo:
PARAM_RELPATH = "relpath"
def __init__(self):
self.trie = Trie()
self._dict = {}
def as_dict(self):
return self._dict.copy()
def add(self, key, hash_info):
self.__dict__.pop("trie", None)
self._dict[key] = hash_info
def get(self, key, default=None):
return self._dict.get(key, default)
@cached_property
def trie(self):
return Trie(self._dict)
@property
def size(self):
try:
return sum(
hash_info.size
for _, hash_info in self.trie.iteritems() # noqa: B301
)
return sum(hash_info.size for _, hash_info in self._dict.items())
except TypeError:
return None
@property
def nfiles(self):
return len(self.trie)
return len(self._dict)
def items(self, path_info=None):
for key, hash_info in self.trie.iteritems(): # noqa: B301
path = posixpath.sep.join(key)
if path_info is not None:
path = path_info / path
yield path, hash_info
def items(self):
for key, hash_info in self._dict.items():
yield key, hash_info
@classmethod
def from_list(cls, lst):
@@ -78,7 +87,8 @@ class DirInfo:
entry = _entry.copy()
relpath = entry.pop(cls.PARAM_RELPATH)
parts = tuple(relpath.split(posixpath.sep))
ret.trie[parts] = HashInfo.from_dict(entry)
hash_info = HashInfo.from_dict(entry)
ret.add(parts, hash_info)
return ret
def to_list(self):
@@ -91,12 +101,17 @@ class DirInfo:
hash_info.name: hash_info.value,
self.PARAM_RELPATH: posixpath.sep.join(parts),
}
for parts, hash_info in self.trie.iteritems() # noqa: B301
for parts, hash_info in self._dict.items() # noqa: B301
),
key=itemgetter(self.PARAM_RELPATH),
)
def merge(self, ancestor, their):
merged_dict = _merge(
ancestor.as_dict(), self.as_dict(), their.as_dict(),
)
merged = DirInfo()
merged.trie = _merge(ancestor.trie, self.trie, their.trie)
for key, hi in merged_dict.items():
merged.add(key, hi)
return merged
+138 -46
View File
@@ -2,10 +2,12 @@ import collections
import contextlib
import logging
import os
from typing import TYPE_CHECKING, Any, Union
from voluptuous import MultipleInvalid
from dvc.exceptions import DvcException
from dvc.parsing.versions import LOCKFILE_VERSION, SCHEMA_KWD
from dvc.stage import serialize
from dvc.stage.exceptions import (
StageFileBadNameError,
@@ -13,17 +15,19 @@ from dvc.stage.exceptions import (
StageFileFormatError,
StageFileIsNotDvcFileError,
)
from dvc.stage.loader import SingleStageLoader, StageLoader
from dvc.types import AnyPath
from dvc.utils import relpath
from dvc.utils.collections import apply_diff
from dvc.utils.serialize import (
dump_yaml,
load_yaml,
modify_yaml,
parse_yaml,
parse_yaml_for_update,
)
if TYPE_CHECKING:
from dvc.repo import Repo
logger = logging.getLogger(__name__)
DVC_FILE = "Dvcfile"
@@ -32,6 +36,11 @@ PIPELINE_FILE = "dvc.yaml"
PIPELINE_LOCK = "dvc.lock"
class FileIsGitIgnored(DvcException):
def __init__(self, path):
super().__init__(f"'{path}' is git-ignored.")
class LockfileCorruptedError(DvcException):
pass
@@ -60,9 +69,9 @@ def is_lock_file(path):
def check_dvc_filename(path):
if not is_valid_filename(path):
raise StageFileBadNameError(
"bad DVC-file name '{}'. DVC-files should be named "
"'Dvcfile' or have a '.dvc' suffix (e.g. '{}.dvc').".format(
relpath(path), os.path.basename(path)
"bad DVC file name '{}'. DVC files should be named "
"'{}' or have a '.dvc' suffix (e.g. '{}.dvc').".format(
relpath(path), PIPELINE_FILE, os.path.basename(path)
)
)
@@ -96,31 +105,48 @@ class FileMixin:
return relpath(self.path)
def exists(self):
return self.repo.tree.exists(self.path)
return self.repo.fs.exists(self.path)
def _is_git_ignored(self):
from dvc.fs.local import LocalFileSystem
return isinstance(
self.repo.fs, LocalFileSystem
) and self.repo.scm.is_ignored(self.path)
def _verify_filename(self):
if self.verify:
check_dvc_filename(self.path)
def _check_gitignored(self):
if self._is_git_ignored():
raise FileIsGitIgnored(self.path)
def _load(self):
# it raises the proper exceptions by priority:
# 1. when the file doesn't exists
# 2. filename is not a DVC-file
# 2. filename is not a DVC file
# 3. path doesn't represent a regular file
# 4. when the file is git ignored
if not self.exists():
raise StageFileDoesNotExistError(self.path)
if self.verify:
check_dvc_filename(self.path)
if not self.repo.tree.isfile(self.path):
is_ignored = self.repo.fs.exists(self.path, use_dvcignore=False)
raise StageFileDoesNotExistError(self.path, dvc_ignored=is_ignored)
self._verify_filename()
if not self.repo.fs.isfile(self.path):
raise StageFileIsNotDvcFileError(self.path)
with self.repo.tree.open(self.path) as fd:
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)
self.validate(d, self.relpath)
return d, stage_text
return self.validate(d, self.relpath), stage_text
@classmethod
def validate(cls, d, fname=None):
assert isinstance(cls.SCHEMA, collections.abc.Callable)
try:
cls.SCHEMA(d) # pylint: disable=not-callable
return cls.SCHEMA(d) # pylint: disable=not-callable
except MultipleInvalid as exc:
raise StageFileFormatError(f"'{fname}' format error: {exc}")
@@ -137,16 +163,17 @@ class FileMixin:
class SingleStageFile(FileMixin):
from dvc.schema import COMPILED_SINGLE_STAGE_SCHEMA as SCHEMA
from dvc.stage.loader import SingleStageLoader as LOADER
@property
def stage(self):
data, raw = self._load()
return SingleStageLoader.load_stage(self, data, raw)
return self.LOADER.load_stage(self, data, raw)
@property
def stages(self):
data, raw = self._load()
return SingleStageLoader(self, data, raw)
return self.LOADER(self, data, raw)
def dump(self, stage, **kwargs):
"""Dumps given stage appropriately in the dvcfile."""
@@ -177,6 +204,7 @@ class PipelineFile(FileMixin):
"""Abstraction for pipelines file, .yaml + .lock combined."""
from dvc.schema import COMPILED_MULTI_STAGE_SCHEMA as SCHEMA
from dvc.stage.loader import StageLoader as LOADER
@property
def _lockfile(self):
@@ -210,7 +238,7 @@ class PipelineFile(FileMixin):
self._check_if_parametrized(stage)
stage_data = serialize.to_pipeline_file(stage)
with modify_yaml(self.path, tree=self.repo.tree) as data:
with modify_yaml(self.path, fs=self.repo.fs) as data:
if not data:
logger.info("Creating '%s'", self.relpath)
@@ -239,7 +267,7 @@ class PipelineFile(FileMixin):
def stages(self):
data, _ = self._load()
lockfile_data = self._lockfile.load()
return StageLoader(self, data, lockfile_data)
return self.LOADER(self, data, lockfile_data)
def remove(self, force=False):
if not force:
@@ -254,7 +282,7 @@ class PipelineFile(FileMixin):
if not self.exists():
return
with open(self.path, "r") as f:
with open(self.path, "r", encoding="utf-8") as f:
d = parse_yaml_for_update(f.read(), self.path)
self.validate(d, self.path)
@@ -273,35 +301,90 @@ class PipelineFile(FileMixin):
raise NotImplementedError
def get_lockfile_schema(d):
from dvc.schema import (
COMPILED_LOCKFILE_V1_SCHEMA,
COMPILED_LOCKFILE_V2_SCHEMA,
)
schema = {
LOCKFILE_VERSION.V1: COMPILED_LOCKFILE_V1_SCHEMA,
LOCKFILE_VERSION.V2: COMPILED_LOCKFILE_V2_SCHEMA,
}
version = LOCKFILE_VERSION.from_dict(d)
return schema[version]
def migrate_lock_v1_to_v2(d, version_info):
stages = {k: v for k, v in d.items()}
for key in stages:
d.pop(key)
# forcing order, meta should always be at the top
d.update(version_info)
d["stages"] = stages
class Lockfile(FileMixin):
from dvc.schema import COMPILED_LOCKFILE_SCHEMA as SCHEMA
def load(self):
if not self.exists():
return {}
data = load_yaml(self.path, tree=self.repo.tree)
@classmethod
def validate(cls, d, fname=None):
schema = get_lockfile_schema(d)
try:
self.validate(data, fname=self.relpath)
except StageFileFormatError:
return schema(d)
except MultipleInvalid as exc:
raise StageFileFormatError(f"'{fname}' format error: {exc}")
def _verify_filename(self):
pass # lockfile path is hardcoded, so no need to verify here
def _load(self):
try:
return super()._load()
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."
)
return data
) from exc
def load(self):
d, _ = self._load()
return d
@property
def latest_version_info(self):
version = LOCKFILE_VERSION.V2.value # pylint:disable=no-member
return {SCHEMA_KWD: version}
def dump(self, stage, **kwargs):
stage_data = serialize.to_lockfile(stage)
with modify_yaml(self.path, tree=self.repo.tree) as data:
if not data:
logger.info("Generating lock file '%s'", self.relpath)
with modify_yaml(self.path, fs=self.repo.fs) as data:
version = LOCKFILE_VERSION.from_dict(data)
if version == LOCKFILE_VERSION.V1:
logger.info(
"Migrating lock file '%s' from v1 to v2", self.relpath
)
migrate_lock_v1_to_v2(data, self.latest_version_info)
else:
if not data:
data.update(self.latest_version_info)
# order is important, meta should always be at the top
logger.info("Generating lock file '%s'", self.relpath)
modified = data.get(stage.name, {}) != stage_data.get(
data["stages"] = data.get("stages", {})
modified = data["stages"].get(stage.name, {}) != stage_data.get(
stage.name, {}
)
if modified:
logger.info("Updating lock file '%s'", self.relpath)
data.update(stage_data)
data["stages"].update(stage_data)
if modified:
self.repo.scm.track_file(self.relpath)
@@ -310,17 +393,19 @@ class Lockfile(FileMixin):
if not self.exists():
return
with open(self.path) as f:
with open(self.path, encoding="utf-8") as f:
d = parse_yaml_for_update(f.read(), self.path)
self.validate(d, self.path)
if stage.name not in d:
version = LOCKFILE_VERSION.from_dict(d)
data = d if version == LOCKFILE_VERSION.V1 else d.get("stages", {})
if stage.name not in data:
return
logger.debug("Removing '%s' from '%s'", stage.name, self.path)
del d[stage.name]
del data[stage.name]
if d:
if data:
dump_yaml(self.path, d)
else:
self.remove()
@@ -330,12 +415,19 @@ class Lockfile(FileMixin):
class Dvcfile:
def __new__(cls, repo, path, **kwargs):
def __new__(cls, repo: "Repo", path: AnyPath, **kwargs: Any):
assert path
assert repo
_, ext = os.path.splitext(path)
if ext in [".yaml", ".yml"]:
return PipelineFile(repo, path, **kwargs)
# fallback to single stage file for better error messages
return SingleStageFile(repo, path, **kwargs)
return make_dvcfile(repo, path, **kwargs)
DVCFile = Union["PipelineFile", "SingleStageFile"]
def make_dvcfile(repo: "Repo", path: AnyPath, **kwargs: Any) -> DVCFile:
_, ext = os.path.splitext(str(path))
if ext in [".yaml", ".yml"]:
return PipelineFile(repo, path, **kwargs)
# fallback to single stage file for better error messages
return SingleStageFile(repo, path, **kwargs)
+4
View File
@@ -2,3 +2,7 @@ DVC_CHECKPOINT = "DVC_CHECKPOINT"
DVC_DAEMON = "DVC_DAEMON"
DVC_PAGER = "DVC_PAGER"
DVC_ROOT = "DVC_ROOT"
DVCLIVE_PATH = "DVCLIVE_PATH"
DVCLIVE_SUMMARY = "DVCLIVE_SUMMARY"
DVCLIVE_HTML = "DVCLIVE_HTML"
DVCLIVE_RESUME = "DVCLIVE_RESUME"
+29 -12
View File
@@ -1,7 +1,5 @@
"""Exceptions raised by the dvc."""
from funcy import first
from dvc.utils import error_link, format_link, relpath
from typing import List
class DvcException(Exception):
@@ -9,6 +7,7 @@ class DvcException(Exception):
def __init__(self, msg, *args):
assert msg
self.msg = msg
super().__init__(msg, *args)
@@ -26,6 +25,8 @@ class OutputDuplicationError(DvcException):
"""
def __init__(self, output, stages):
from funcy import first
assert isinstance(output, str)
assert all(hasattr(stage, "relpath") for stage in stages)
if len(stages) == 1:
@@ -49,10 +50,12 @@ class OutputNotFoundError(DvcException):
"""
def __init__(self, output, repo=None):
from dvc.utils import relpath
self.output = output
self.repo = repo
super().__init__(
"Unable to find DVC-file with output '{path}'".format(
"Unable to find DVC file with output '{path}'".format(
path=relpath(self.output)
)
)
@@ -170,11 +173,11 @@ class BadMetricError(DvcException):
)
class NoMetricsError(DvcException):
class MetricsError(DvcException):
pass
class NoMetricsParsedError(NoMetricsError):
class NoMetricsParsedError(MetricsError):
def __init__(self, command):
super().__init__(
f"Could not parse {command} files. Use `-v` option to see more "
@@ -182,7 +185,7 @@ class NoMetricsParsedError(NoMetricsError):
)
class NoMetricsFoundError(NoMetricsError):
class NoMetricsFoundError(MetricsError):
def __init__(self, command, run_options):
super().__init__(
f"No {command} files in this repository. "
@@ -191,6 +194,15 @@ class NoMetricsFoundError(NoMetricsError):
)
class MetricDoesNotExistError(MetricsError):
def __init__(self, targets: List[str]):
if len(targets) == 1:
msg = "'{}' does not exist."
else:
msg = "'{}' do not exist."
super().__init__(msg.format(", ".join(targets)))
class RecursiveAddingWhileUsingFilename(DvcException):
def __init__(self):
super().__init__(
@@ -227,11 +239,6 @@ class FileMissingError(DvcException):
)
class FileOwnershipError(DvcException):
def __init__(self, path):
super().__init__(f"file '{path}' not owned by user! ")
class DvcIgnoreInCollectedDirError(DvcException):
def __init__(self, ignore_dirname):
super().__init__(
@@ -242,6 +249,8 @@ class DvcIgnoreInCollectedDirError(DvcException):
class GitHookAlreadyExistsError(DvcException):
def __init__(self, hook_name):
from dvc.utils import format_link
super().__init__(
"Hook '{}' already exists. Please refer to {} for more "
"info.".format(
@@ -266,6 +275,8 @@ class UploadError(DvcException):
class CheckoutError(DvcException):
def __init__(self, target_infos, stats=None):
from dvc.utils import error_link
self.target_infos = target_infos
self.stats = stats
targets = [str(t) for t in target_infos]
@@ -291,6 +302,8 @@ class NoRemoteInExternalRepoError(DvcException):
class NoOutputInExternalRepoError(DvcException):
def __init__(self, path, external_repo_path, external_repo_url):
from dvc.utils import relpath
super().__init__(
"Output '{}' not found in target repository '{}'".format(
relpath(path, external_repo_path), external_repo_url
@@ -321,6 +334,8 @@ class PathMissingError(DvcException):
class RemoteCacheRequiredError(DvcException):
def __init__(self, path_info):
from dvc.utils import format_link
super().__init__(
(
"Current operation was unsuccessful because '{}' requires "
@@ -355,6 +370,8 @@ class MergeError(DvcException):
class CacheLinkError(DvcException):
from dvc.utils import format_link
SUPPORT_LINK = "See {} for more information.".format(
format_link(
"https://dvc.org/doc/user-guide/troubleshooting#cache-types"
+80 -222
View File
@@ -3,39 +3,31 @@ import os
import tempfile
import threading
from contextlib import contextmanager
from distutils.dir_util import copy_tree
from typing import Dict, Iterable
from typing import Dict
from funcy import cached_property, reraise, retry, wrap_with
from funcy import retry, wrap_with
from dvc.cache import Cache
from dvc.config import NoRemoteError, NotDvcRepoError
from dvc.exceptions import (
DvcException,
FileMissingError,
NoOutputInExternalRepoError,
NoRemoteInExternalRepoError,
NotDvcRepoError,
OutputNotFoundError,
PathMissingError,
)
from dvc.path_info import PathInfo
from dvc.repo import Repo
from dvc.scm.base import CloneError
from dvc.scm.git import Git
from dvc.tree.local import LocalTree
from dvc.tree.repo import RepoTree
from dvc.utils import relpath
from dvc.utils.fs import remove
logger = logging.getLogger(__name__)
class IsADVCRepoError(DvcException):
"""Raised when it is not expected to be a dvc repo."""
@contextmanager
def external_repo(url, rev=None, for_write=False, **kwargs):
def external_repo(
url, rev=None, for_write=False, cache_dir=None, cache_types=None, **kwargs
):
from dvc.config import NoRemoteError
from dvc.scm.git import Git
logger.debug("Creating external repo %s@%s", url, rev)
path = _cached_clone(url, rev, for_write=for_write)
# Local HEAD points to the tip of whatever branch we first cloned from
@@ -43,17 +35,42 @@ def external_repo(url, rev=None, for_write=False, **kwargs):
# the tip of the default branch
rev = rev or "refs/remotes/origin/HEAD"
cache_config = {
"cache": {
"dir": cache_dir or _get_cache_dir(url),
"type": cache_types,
}
}
config = _get_remote_config(url) if os.path.isdir(url) else {}
config.update(cache_config)
def make_repo(path, **_kwargs):
_config = cache_config.copy()
if os.path.isdir(url):
rel = os.path.relpath(path, _kwargs["scm"].root_dir)
repo_path = os.path.join(url, rel)
_config.update(_get_remote_config(repo_path))
return Repo(path, config=_config, **_kwargs)
root_dir = path if for_write else os.path.realpath(path)
conf = dict(
repo_kwargs = dict(
root_dir=root_dir,
url=url,
scm=None if for_write else Git(root_dir),
rev=None if for_write else rev,
for_write=for_write,
uninitialized=True,
config=config,
repo_factory=make_repo,
**kwargs,
)
repo = ExternalRepo(**conf)
if "subrepos" not in repo_kwargs:
repo_kwargs["subrepos"] = True
if "uninitialized" not in repo_kwargs:
repo_kwargs["uninitialized"] = True
repo = Repo(**repo_kwargs)
try:
yield repo
@@ -77,6 +94,15 @@ CLONES: Dict[str, str] = {}
CACHE_DIRS: Dict[str, str] = {}
@wrap_with(threading.Lock())
def _get_cache_dir(url):
try:
cache_dir = CACHE_DIRS[url]
except KeyError:
cache_dir = CACHE_DIRS[url] = tempfile.mkdtemp("dvc-cache")
return cache_dir
def clean_repos():
# Outside code should not see cache while we are removing
paths = [path for path, _ in CLONES.values()] + list(CACHE_DIRS.values())
@@ -87,206 +113,28 @@ def clean_repos():
_remove(path)
class ExternalRepo(Repo):
# pylint: disable=no-member
def _get_remote_config(url):
try:
repo = Repo(url)
except NotDvcRepoError:
return {}
def __init__(
self,
root_dir,
url,
scm=None,
rev=None,
for_write=False,
cache_dir=None,
cache_types=None,
uninitialized=False,
**kwargs,
):
super().__init__(
root_dir, scm=scm, rev=rev, uninitialized=uninitialized
)
try:
name = repo.config["core"].get("remote")
if not name:
# Fill the empty upstream entry with a new remote pointing to the
# original repo's cache location.
name = "auto-generated-upstream"
return {
"core": {"remote": name},
"remote": {name: {"url": repo.odb.local.cache_dir}},
}
self.url = url
self.for_write = for_write
self.cache_dir = cache_dir or self._get_cache_dir()
self.cache_types = cache_types
self._setup_cache(self)
self._fix_upstream(self)
self.tree_confs = kwargs
def __str__(self):
return self.url
@cached_property
def repo_tree(self):
return self._get_tree_for(
self, subrepos=not self.for_write, repo_factory=self.make_repo
)
def get_rev(self):
assert self.scm
if isinstance(self.tree, LocalTree):
return self.scm.get_rev()
return self.tree.rev
def _fetch_to_cache(self, path_info, repo, callback, **kwargs):
# don't support subrepo traversal as it might fail due to difference
# in remotes
tree = self._get_tree_for(repo)
cache = repo.cache.local
hash_info = tree.get_hash(
path_info, download_callback=callback, **kwargs
)
cache.save(
path_info,
tree,
hash_info,
save_link=False,
download_callback=callback,
)
return hash_info
def fetch_external(self, paths: Iterable, **kwargs):
"""Fetch specified external repo paths into cache.
Returns 3-tuple in the form
(downloaded, failed, list(cache_infos))
where cache_infos can be used as checkout targets for the
fetched paths.
"""
download_results = []
failed = 0
root = PathInfo(self.root_dir)
paths = [root / path for path in paths]
def download_update(result):
download_results.append(result)
hash_infos = []
for path in paths:
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path)
self._check_repo(path, metadata.repo)
repo = metadata.repo
hash_info = self._fetch_to_cache(
path, repo, download_update, **kwargs
)
hash_infos.append(hash_info)
return sum(download_results), failed, hash_infos
def _check_repo(self, path_info, repo):
if not repo:
return
repo_path = PathInfo(repo.root_dir)
if path_info == repo_path and isinstance(repo, Repo):
message = "Cannot fetch a complete DVC repository"
if repo.root_dir != self.root_dir:
rel = relpath(repo.root_dir, self.root_dir)
message += f" '{rel}'"
raise IsADVCRepoError(message)
def get_external(self, path, dest):
"""Convenience wrapper for fetch_external and checkout."""
path_info = PathInfo(self.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path_info)
self._check_repo(path_info, metadata.repo)
if metadata.output_exists:
repo = metadata.repo
cache = repo.cache.local
# fetch DVC and git files to tmpdir cache, then checkout
save_info = self._fetch_to_cache(path_info, repo, None)
cache.checkout(PathInfo(dest), save_info)
else:
# git-only folder, just copy files directly to dest
tree = self._get_tree_for(metadata.repo) # ignore subrepos
tree.copytree(path_info, dest)
def _get_tree_for(self, repo, **kwargs):
"""
Provides a combined tree of a single repo with dvc + git/local tree.
"""
kw = {**self.tree_confs, **kwargs}
if "fetch" not in kw:
kw["fetch"] = True
return RepoTree(repo, **kw)
def get_checksum(self, path):
path_info = PathInfo(self.root_dir) / path
with reraise(FileNotFoundError, PathMissingError(path, self.url)):
metadata = self.repo_tree.metadata(path_info)
# skip subrepos to check for
tree = self._get_tree_for(metadata.repo)
return tree.get_hash(path_info)
@staticmethod
def _fix_local_remote(orig_repo, src_repo, remote_name):
# If a remote URL is relative to the source repo,
# it will have changed upon config load and made
# relative to this new repo. Restore the old one here.
new_remote = orig_repo.config["remote"][remote_name]
old_remote = src_repo.config["remote"][remote_name]
if new_remote["url"] != old_remote["url"]:
new_remote["url"] = old_remote["url"]
@staticmethod
def _add_upstream(orig_repo, src_repo):
# Fill the empty upstream entry with a new remote pointing to the
# original repo's cache location.
cache_dir = src_repo.cache.local.cache_dir
orig_repo.config["remote"]["auto-generated-upstream"] = {
"url": cache_dir
}
orig_repo.config["core"]["remote"] = "auto-generated-upstream"
def make_repo(self, path):
repo = Repo(path, scm=self.scm, rev=self.get_rev())
self._setup_cache(repo)
self._fix_upstream(repo)
return repo
def _setup_cache(self, repo):
repo.config["cache"]["dir"] = self.cache_dir
repo.cache = Cache(repo)
if self.cache_types:
repo.cache.local.cache_types = self.cache_types
def _fix_upstream(self, repo):
if not os.path.isdir(self.url):
return
try:
rel_path = os.path.relpath(repo.root_dir, self.root_dir)
src_repo = Repo(PathInfo(self.url) / rel_path)
except NotDvcRepoError:
return
try:
remote_name = repo.config["core"].get("remote")
if remote_name:
self._fix_local_remote(repo, src_repo, remote_name)
else:
self._add_upstream(repo, src_repo)
finally:
src_repo.close()
@wrap_with(threading.Lock())
def _get_cache_dir(self):
try:
cache_dir = CACHE_DIRS[self.url]
except KeyError:
cache_dir = CACHE_DIRS[self.url] = tempfile.mkdtemp("dvc-cache")
return cache_dir
# Use original remote to make sure that we are using correct url,
# credential paths, etc if they are relative to the config location.
return {"remote": {name: repo.config["remote"][name]}}
finally:
repo.close()
def _cached_clone(url, rev, for_write=False):
@@ -296,6 +144,8 @@ def _cached_clone(url, rev, for_write=False):
revision checked out. If for_write is set prevents reusing this dir via
cache.
"""
from distutils.dir_util import copy_tree
# even if we have already cloned this repo, we may need to
# fetch/fast-forward to get specified rev
clone_path, shallow = _clone_default_branch(url, rev, for_write=for_write)
@@ -322,6 +172,8 @@ def _clone_default_branch(url, rev, for_write=False):
The cloned is reactualized with git pull unless rev is a known sha.
"""
from dvc.scm.git import Git
clone_path, shallow = CLONES.get(url, (None, False))
git = None
@@ -349,6 +201,8 @@ def _clone_default_branch(url, rev, for_write=False):
clone_path = tempfile.mkdtemp("dvc-clone")
if not for_write and rev and not Git.is_sha(rev):
# If rev is a tag or branch name try shallow clone first
from dvc.scm.base import CloneError
try:
git = Git.clone(url, clone_path, shallow_branch=rev)
shallow = True
@@ -369,19 +223,21 @@ def _clone_default_branch(url, rev, for_write=False):
def _unshallow(git):
if git.repo.head.is_detached:
if git.gitpython.repo.head.is_detached:
# If this is a detached head (i.e. we shallow cloned a tag) switch to
# the default branch
origin_refs = git.repo.remotes["origin"].refs
origin_refs = git.gitpython.repo.remotes["origin"].refs
ref = origin_refs["HEAD"].reference
branch_name = ref.name.split("/")[-1]
branch = git.repo.create_head(branch_name, ref)
branch = git.gitpython.repo.create_head(branch_name, ref)
branch.set_tracking_branch(ref)
branch.checkout()
git.pull(unshallow=True)
def _git_checkout(repo_path, rev):
from dvc.scm.git import Git
logger.debug("erepo: git checkout %s@%s", repo_path, rev)
git = Git(repo_path)
try:
@@ -391,6 +247,8 @@ def _git_checkout(repo_path, rev):
def _remove(path):
from dvc.utils.fs import remove
if os.name == "nt":
# git.exe may hang for a while not permitting to remove temp dir
os_retry = retry(5, errors=OSError, timeout=0.1)
+39 -39
View File
@@ -1,45 +1,44 @@
import posixpath
from urllib.parse import urlparse
from .azure import AzureTree
from .gdrive import GDriveTree
from .gs import GSTree
from .hdfs import HDFSTree
from .http import HTTPTree
from .https import HTTPSTree
from .local import LocalTree
from .oss import OSSTree
from .s3 import S3Tree
from .ssh import SSHTree
from .webdav import WebDAVTree
from .webdavs import WebDAVSTree
from .webhdfs import WebHDFSTree
from ..scheme import Schemes
from .azure import AzureFileSystem
from .gdrive import GDriveFileSystem
from .gs import GSFileSystem
from .hdfs import HDFSFileSystem
from .http import HTTPFileSystem
from .https import HTTPSFileSystem
from .local import LocalFileSystem
from .oss import OSSFileSystem
from .s3 import S3FileSystem
from .ssh import SSHFileSystem
from .webdav import WebDAVFileSystem
from .webdavs import WebDAVSFileSystem
from .webhdfs import WebHDFSFileSystem
TREES = [
AzureTree,
GDriveTree,
GSTree,
HDFSTree,
HTTPTree,
HTTPSTree,
S3Tree,
SSHTree,
OSSTree,
WebDAVTree,
WebDAVSTree,
WebHDFSTree
# NOTE: LocalTree is the default
]
FS_MAP = {
Schemes.AZURE: AzureFileSystem,
Schemes.GDRIVE: GDriveFileSystem,
Schemes.GS: GSFileSystem,
Schemes.HDFS: HDFSFileSystem,
Schemes.WEBHDFS: WebHDFSFileSystem,
Schemes.HTTP: HTTPFileSystem,
Schemes.HTTPS: HTTPSFileSystem,
Schemes.S3: S3FileSystem,
Schemes.SSH: SSHFileSystem,
Schemes.OSS: OSSFileSystem,
Schemes.WEBDAV: WebDAVFileSystem,
Schemes.WEBDAVS: WebDAVSFileSystem,
# NOTE: LocalFileSystem is the default
}
def get_tree_cls(remote_conf):
for tree_cls in TREES:
if tree_cls.supported(remote_conf):
return tree_cls
return LocalTree
def get_fs_cls(remote_conf):
scheme = urlparse(remote_conf["url"]).scheme
return FS_MAP.get(scheme, LocalFileSystem)
def get_tree_config(config, **kwargs):
def get_fs_config(config, **kwargs):
name = kwargs.get("name")
if name:
remote_conf = config["remote"][name.lower()]
@@ -74,17 +73,18 @@ def _resolve_remote_refs(config, remote_conf):
if parsed.scheme != "remote":
return remote_conf
base = get_tree_config(config, name=parsed.netloc)
base = get_fs_config(config, name=parsed.netloc)
url = posixpath.join(base["url"], parsed.path.lstrip("/"))
return {**base, **remote_conf, "url": url}
def get_cloud_tree(repo, **kwargs):
from dvc.config import SCHEMA, ConfigError, Invalid
def get_cloud_fs(repo, **kwargs):
from dvc.config import ConfigError
from dvc.config_schema import SCHEMA, Invalid
remote_conf = get_tree_config(repo.config, **kwargs)
remote_conf = get_fs_config(repo.config, **kwargs)
try:
remote_conf = SCHEMA["remote"][str](remote_conf)
except Invalid as exc:
raise ConfigError(str(exc)) from None
return get_tree_cls(remote_conf)(repo, remote_conf)
return get_fs_cls(remote_conf)(repo, remote_conf)
+126
View File
@@ -0,0 +1,126 @@
import logging
import os
import threading
from funcy import cached_property, wrap_prop
from dvc.exceptions import DvcException
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from dvc.utils import format_link
from .fsspec_wrapper import FSSpecWrapper
logger = logging.getLogger(__name__)
_DEFAULT_CREDS_STEPS = (
"https://azuresdkdocs.blob.core.windows.net/$web/python/"
"azure-identity/1.4.0/azure.identity.html#azure.identity"
".DefaultAzureCredential"
)
class AzureAuthError(DvcException):
pass
class AzureFileSystem(FSSpecWrapper):
scheme = Schemes.AZURE
PATH_CLS = CloudURLInfo
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
REQUIRES = {
"adlfs": "adlfs",
"knack": "knack",
"azure-identity": "azure.identity",
}
def __init__(self, repo, config):
super().__init__(repo, config)
url = config.get("url")
self.path_info = self.PATH_CLS(url)
self.bucket = self.path_info.bucket
if not self.bucket:
container = self._az_config.get("storage", "container_name", None)
url = f"azure://{container}"
self.path_info = self.PATH_CLS(url)
self.bucket = self.path_info.bucket
self.login_method, self.login_info = self._prepare_credentials(config)
def _prepare_credentials(self, config):
from azure.identity.aio import DefaultAzureCredential
login_info = {}
login_info["connection_string"] = config.get(
"connection_string",
self._az_config.get("storage", "connection_string", None),
)
login_info["account_name"] = config.get(
"account_name", self._az_config.get("storage", "account", None)
)
login_info["account_key"] = config.get(
"account_key", self._az_config.get("storage", "key", None)
)
login_info["sas_token"] = config.get(
"sas_token", self._az_config.get("storage", "sas_token", None)
)
login_info["tenant_id"] = config.get("tenant_id")
login_info["client_id"] = config.get("client_id")
login_info["client_secret"] = config.get("client_secret")
if not any(login_info.values()):
login_info["credentials"] = DefaultAzureCredential()
for login_method, required_keys in [ # noqa
("connection string", ["connection_string"]),
(
"AD service principal",
["tenant_id", "client_id", "client_secret"],
),
("account key", ["account_name", "account_key"]),
("SAS token", ["account_name", "sas_token"]),
("anonymous login", ["account_name"]),
(f"default credentials ({_DEFAULT_CREDS_STEPS})", ["credentials"]),
]:
if all(login_info.get(key) is not None for key in required_keys):
break
else:
login_method = None
return login_method, login_info
@cached_property
def _az_config(self):
# NOTE: ideally we would've used get_default_cli().config from
# azure.cli.core, but azure-cli-core has a lot of conflicts with other
# dependencies. So instead we are just use knack directly
from knack.config import CLIConfig
config_dir = os.getenv(
"AZURE_CONFIG_DIR", os.path.expanduser(os.path.join("~", ".azure"))
)
return CLIConfig(config_dir=config_dir, config_env_var_prefix="AZURE")
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from adlfs import AzureBlobFileSystem
from azure.core.exceptions import AzureError
try:
file_system = AzureBlobFileSystem(**self.login_info)
if self.bucket not in [
container.rstrip("/") for container in file_system.ls("/")
]:
file_system.mkdir(self.bucket)
except (ValueError, AzureError) as e:
raise AzureAuthError(
f"Authentication to Azure Blob Storage via {self.login_method}"
" failed.\nLearn more about configuration settings at"
f" {format_link('https://man.dvc.org/remote/modify')}"
) from e
return file_system
+48 -186
View File
@@ -2,17 +2,13 @@ import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial
from multiprocessing import cpu_count
from typing import Any, ClassVar, Dict, Optional
from urllib.parse import urlparse
from typing import Any, ClassVar, Dict, FrozenSet, Optional
from funcy import cached_property, decorator
from funcy import cached_property
from dvc.dir_info import DirInfo
from dvc.exceptions import DvcException, DvcIgnoreInCollectedDirError
from dvc.ignore import DvcIgnore
from dvc.exceptions import DvcException
from dvc.path_info import URLInfo
from dvc.progress import Tqdm
from dvc.state import StateNoop
from dvc.utils import tmp_fname
from dvc.utils.fs import makedirs, move
from dvc.utils.http import open_url
@@ -38,14 +34,7 @@ class RemoteMissingDepsError(DvcException):
pass
@decorator
def use_state(call):
tree = call._args[0] # pylint: disable=protected-access
with tree.state:
return call()
class BaseTree:
class BaseFileSystem:
scheme = "base"
REQUIRES: ClassVar[Dict[str, str]] = {}
PATH_CLS = URLInfo # type: Any
@@ -53,18 +42,17 @@ class BaseTree:
CHECKSUM_DIR_SUFFIX = ".dir"
HASH_JOBS = max(1, min(4, cpu_count() // 2))
DEFAULT_VERIFY = False
LIST_OBJECT_PAGE_SIZE = 1000
TRAVERSE_WEIGHT_MULTIPLIER = 5
TRAVERSE_PREFIX_LEN = 3
TRAVERSE_THRESHOLD_SIZE = 500000
CAN_TRAVERSE = True
CACHE_MODE: Optional[int] = None
SHARED_MODE_MAP = {None: (None, None), "group": (None, None)}
PARAM_CHECKSUM: ClassVar[Optional[str]] = None
# Needed for some providers, and http open()
CHUNK_SIZE = 64 * 1024 * 1024 # 64 MiB
state = StateNoop()
PARAM_CHECKSUM: ClassVar[Optional[str]] = None
DETAIL_FIELDS: FrozenSet[str] = frozenset()
def __init__(self, repo, config):
self.repo = repo
@@ -72,10 +60,6 @@ class BaseTree:
self._check_requires()
shared = config.get("shared")
self._file_mode, self._dir_mode = self.SHARED_MODE_MAP[shared]
self.verify = config.get("verify", self.DEFAULT_VERIFY)
self.path_info = None
@cached_property
@@ -146,35 +130,12 @@ class BaseTree:
f"dependencies: {missing}. {hint}"
)
@classmethod
def supported(cls, config):
if isinstance(config, (str, bytes)):
url = config
else:
url = config["url"]
# NOTE: silently skipping remote, calling code should handle that
parsed = urlparse(url)
return parsed.scheme == cls.scheme
@property
def file_mode(self):
return self._file_mode
@property
def dir_mode(self):
return self._dir_mode
@property
def cache(self):
return getattr(self.repo.cache, self.scheme)
def open(self, path_info, mode: str = "r", encoding: str = None):
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)
return open_url(get_url, mode=mode, encoding=encoding, **kwargs)
raise RemoteActionNotImplemented("open", self.scheme)
@@ -195,6 +156,12 @@ class BaseTree:
"""
return True
def isexec(self, path_info):
"""Optional: Overwrite only if the remote has a way to distinguish
between executable and non-executable file.
"""
return False
def iscopy(self, path_info):
"""Check if this file is an independent copy."""
return False # We can't be sure by default
@@ -208,9 +175,18 @@ class BaseTree:
"""
raise NotImplementedError
def ls(self, path_info, detail=False, **kwargs):
raise RemoteActionNotImplemented("ls", self.scheme)
def is_empty(self, path_info):
return False
def info(self, path_info):
raise NotImplementedError
def getsize(self, path_info):
return self.info(path_info).get("size")
def remove(self, path_info):
raise RemoteActionNotImplemented("remove", self.scheme)
@@ -219,17 +195,13 @@ class BaseTree:
directories before copying/linking/moving data
"""
def move(self, from_info, to_info, mode=None):
assert mode is None
def move(self, from_info, to_info):
self.copy(from_info, to_info)
self.remove(from_info)
def copy(self, from_info, to_info):
raise RemoteActionNotImplemented("copy", self.scheme)
def copy_fobj(self, fobj, to_info):
raise RemoteActionNotImplemented("copy_fobj", self.scheme)
def symlink(self, from_info, to_info):
raise RemoteActionNotImplemented("symlink", self.scheme)
@@ -239,127 +211,16 @@ class BaseTree:
def reflink(self, from_info, to_info):
raise RemoteActionNotImplemented("reflink", self.scheme)
@staticmethod
def protect(path_info):
pass
def is_protected(self, path_info):
return False
# pylint: enable=unused-argument
@staticmethod
def unprotect(path_info):
pass
@classmethod
def is_dir_hash(cls, hash_):
if not hash_:
return False
return hash_.endswith(cls.CHECKSUM_DIR_SUFFIX)
@use_state
def get_hash(self, path_info, **kwargs):
assert path_info and (
isinstance(path_info, str) or path_info.scheme == self.scheme
)
if not self.exists(path_info):
return None
# pylint: disable=assignment-from-none
hash_info = self.state.get(path_info)
# If we have dir hash in state db, but dir cache file is lost,
# then we need to recollect the dir via .get_dir_hash() call below,
# see https://github.com/iterative/dvc/issues/2219 for context
if (
hash_info
and hash_info.isdir
and not self.cache.tree.exists(
self.cache.tree.hash_to_path_info(hash_info.value)
)
):
hash_info = None
if hash_info:
assert hash_info.name == self.PARAM_CHECKSUM
if hash_info.isdir:
self.cache.set_dir_info(hash_info)
return hash_info
if self.isdir(path_info):
hash_info = self.get_dir_hash(path_info, **kwargs)
else:
hash_info = self.get_file_hash(path_info)
if hash_info and self.exists(path_info):
self.state.save(path_info, hash_info)
return hash_info
def get_file_hash(self, path_info):
raise NotImplementedError
def hash_to_path_info(self, hash_):
return self.path_info / hash_[0:2] / hash_[2:]
def _calculate_hashes(self, file_infos):
file_infos = list(file_infos)
with Tqdm(
total=len(file_infos),
unit="md5",
desc="Computing file/dir hashes (only done once)",
) as pbar:
worker = pbar.wrap_fn(self.get_file_hash)
with ThreadPoolExecutor(max_workers=self.hash_jobs) as executor:
hash_infos = executor.map(worker, file_infos)
return dict(zip(file_infos, hash_infos))
def _collect_dir(self, path_info, **kwargs):
file_infos = set()
for fname in self.walk_files(path_info, **kwargs):
if DvcIgnore.DVCIGNORE_FILE == fname.name:
raise DvcIgnoreInCollectedDirError(fname.parent)
file_infos.add(fname)
hash_infos = {fi: self.state.get(fi) for fi in file_infos}
not_in_state = {fi for fi, hi in hash_infos.items() if hi is None}
new_hash_infos = self._calculate_hashes(not_in_state)
hash_infos.update(new_hash_infos)
dir_info = DirInfo()
for fi, hi in hash_infos.items():
# NOTE: this is lossy transformation:
# "hey\there" -> "hey/there"
# "hey/there" -> "hey/there"
# The latter is fine filename on Windows, which
# will transform to dir/file on back transform.
#
# Yes, this is a BUG, as long as we permit "/" in
# filenames on Windows and "\" on Unix
dir_info.trie[fi.relative_to(path_info).parts] = hi
return dir_info
@use_state
def get_dir_hash(self, path_info, **kwargs):
dir_info = self._collect_dir(path_info, **kwargs)
hash_info = self.repo.cache.local.save_dir_info(dir_info)
hash_info.size = dir_info.size
return hash_info
def upload(
self,
from_info,
to_info,
name=None,
no_progress_bar=False,
file_mode=None,
self, from_info, to_info, name=None, no_progress_bar=False,
):
if not hasattr(self, "_upload"):
raise RemoteActionNotImplemented("upload", self.scheme)
@@ -379,17 +240,25 @@ class BaseTree:
to_info,
name=name,
no_progress_bar=no_progress_bar,
file_mode=file_mode,
)
def upload_fobj(self, fobj, to_info, no_progress_bar=False, **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, **pbar_args
) as wrapped:
self._upload_fobj(wrapped, to_info) # pylint: disable=no-member
def download(
self,
from_info,
to_info,
name=None,
no_progress_bar=False,
file_mode=None,
dir_mode=None,
jobs=None,
**kwargs,
):
if not hasattr(self, "_download"):
raise RemoteActionNotImplemented("download", self.scheme)
@@ -406,16 +275,14 @@ class BaseTree:
if self.isdir(from_info):
return self._download_dir(
from_info, to_info, name, no_progress_bar, file_mode, dir_mode
from_info, to_info, name, no_progress_bar, jobs, **kwargs,
)
return self._download_file(
from_info, to_info, name, no_progress_bar, file_mode, dir_mode
)
return self._download_file(from_info, to_info, name, no_progress_bar,)
def _download_dir(
self, from_info, to_info, name, no_progress_bar, file_mode, dir_mode
self, from_info, to_info, name, no_progress_bar, jobs, **kwargs,
):
from_infos = list(self.walk_files(from_info))
from_infos = list(self.walk_files(from_info, **kwargs))
to_infos = (
to_info / info.relative_to(from_info) for info in from_infos
)
@@ -427,15 +294,10 @@ class BaseTree:
disable=no_progress_bar,
) as pbar:
download_files = pbar.wrap_fn(
partial(
self._download_file,
name=name,
no_progress_bar=True,
file_mode=file_mode,
dir_mode=dir_mode,
)
partial(self._download_file, name=name, no_progress_bar=True,)
)
with ThreadPoolExecutor(max_workers=self.jobs) as executor:
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)
@@ -457,9 +319,9 @@ class BaseTree:
raise exc
def _download_file(
self, from_info, to_info, name, no_progress_bar, file_mode, dir_mode
self, from_info, to_info, name, no_progress_bar,
):
makedirs(to_info.parent, exist_ok=True, mode=dir_mode)
makedirs(to_info.parent, exist_ok=True)
logger.debug("Downloading '%s' to '%s'", from_info, to_info)
name = name or to_info.name
@@ -470,4 +332,4 @@ class BaseTree:
from_info, tmp_file, name=name, no_progress_bar=no_progress_bar
)
move(tmp_file, to_info, mode=file_mode)
move(tmp_file, to_info)
+53 -76
View File
@@ -7,7 +7,7 @@ from dvc.path_info import PathInfo
from dvc.utils import relpath
from ._metadata import Metadata
from .base import BaseTree, RemoteActionNotImplemented
from .base import BaseFileSystem
if typing.TYPE_CHECKING:
from dvc.output.base import BaseOutput
@@ -16,27 +16,18 @@ if typing.TYPE_CHECKING:
logger = logging.getLogger(__name__)
class DvcTree(BaseTree): # pylint:disable=abstract-method
"""DVC repo tree.
class DvcFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""DVC repo fs.
Args:
repo: DVC repo.
fetch: if True, uncached DVC outs will be fetched on `open()`.
stream: if True, uncached DVC outs will be streamed directly from
remote on `open()`.
`stream` takes precedence over `fetch`. If `stream` is enabled and
a remote does not support streaming, uncached DVC outs will be fetched
as a fallback.
"""
scheme = "local"
PARAM_CHECKSUM = "md5"
def __init__(self, repo, fetch=False, stream=False):
def __init__(self, repo):
super().__init__(repo, {"url": repo.root_dir})
self.fetch = fetch
self.stream = stream
def _find_outs(self, path, *args, **kwargs):
outs = self.repo.find_outs_by_path(path, *args, **kwargs)
@@ -54,19 +45,16 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
self, path_info: PathInfo, out: "BaseOutput", remote=None
):
assert isinstance(path_info, PathInfo)
if not self.fetch and not self.stream:
raise FileNotFoundError
# NOTE: use string paths here for performance reasons
key = tuple(relpath(path_info, out.path_info).split(os.sep))
out.get_dir_cache(remote=remote)
file_hash = out.hash_info.dir_info.trie.get(key)
file_hash = out.hash_info.dir_info.get(key)
if file_hash:
return file_hash
raise FileNotFoundError
def open(
self, path: PathInfo, mode="r", encoding="utf-8", remote=None
def open( # type: ignore
self, path: PathInfo, mode="r", encoding=None, remote=None, **kwargs
): # pylint: disable=arguments-differ
try:
outs = self._find_outs(path, strict=False)
@@ -80,28 +68,24 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
out = outs[0]
if out.changed_cache(filter_info=path):
if not self.fetch and not self.stream:
raise FileNotFoundError
from dvc.config import NoRemoteError
remote_obj = self.repo.cloud.get_remote(remote)
if self.stream:
if out.is_dir_checksum:
checksum = self._get_granular_hash(path, out).value
else:
checksum = out.hash_info.value
try:
remote_info = remote_obj.tree.hash_to_path_info(checksum)
return remote_obj.tree.open(
remote_info, mode=mode, encoding=encoding
)
except RemoteActionNotImplemented:
pass
cache_info = out.get_used_cache(filter_info=path, remote=remote)
self.repo.cloud.pull(cache_info, remote=remote)
try:
remote_obj = self.repo.cloud.get_remote(remote)
except NoRemoteError:
raise FileNotFoundError
if out.is_dir_checksum:
checksum = self._get_granular_hash(path, out).value
else:
checksum = out.hash_info.value
remote_info = remote_obj.odb.hash_to_path_info(checksum)
return remote_obj.fs.open(
remote_info, mode=mode, encoding=encoding
)
if out.is_dir_checksum:
checksum = self._get_granular_hash(path, out).value
cache_path = out.cache.tree.hash_to_path_info(checksum).url
cache_path = out.odb.hash_to_path_info(checksum).url
else:
cache_path = out.cache_path
return open(cache_path, mode=mode, encoding=encoding)
@@ -143,27 +127,25 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
except FileNotFoundError:
return False
def _fetch_dir(
self, out, filter_info=None, download_callback=None, **kwargs
):
def _fetch_dir(self, out, **kwargs):
# pull dir cache if needed
out.get_dir_cache(**kwargs)
# pull dir contents if needed
if self.fetch and out.changed_cache(filter_info=filter_info):
used_cache = out.get_used_cache(filter_info=filter_info)
downloaded = self.repo.cloud.pull(used_cache, **kwargs)
if download_callback:
download_callback(downloaded)
dir_cache = out.dir_cache
if not dir_cache:
raise FileNotFoundError
def _add_dir(self, top, trie, out, **kwargs):
if not self.fetch and not self.stream:
return
from dvc.objects import Tree
self._fetch_dir(out, filter_info=top, **kwargs)
hash_info = Tree.save_dir_info(out.odb, dir_cache)
if hash_info != out.hash_info:
raise FileNotFoundError
def _add_dir(self, trie, out, **kwargs):
self._fetch_dir(out, **kwargs)
base = out.path_info.parts
for key in out.dir_cache.trie.iterkeys(): # noqa: B301
for key, _ in out.dir_cache.items(): # noqa: B301
trie[base + key] = None
def _walk(self, root, trie, topdown=True, **kwargs):
@@ -172,7 +154,7 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
out = trie.get(root.parts)
if out and out.is_dir_checksum:
self._add_dir(root, trie, out, **kwargs)
self._add_dir(trie, out, **kwargs)
root_len = len(root.parts)
for key, out in trie.iteritems(prefix=root.parts): # noqa: B301
@@ -215,7 +197,7 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
trie[out.path_info.parts] = out
if out.is_dir_checksum and root.isin_or_eq(out.path_info):
self._add_dir(top, trie, out, **kwargs)
self._add_dir(trie, out, **kwargs)
yield from self._walk(root, trie, topdown=topdown, **kwargs)
@@ -234,31 +216,9 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
recurse = recursive or not strict
return meta.output_exists if recurse else meta.is_output
def isexec(self, path): # pylint: disable=unused-argument
def isexec(self, path_info): # pylint: disable=unused-argument
return False
def get_dir_hash(self, path_info, **kwargs):
try:
outs = self._find_outs(path_info, strict=True)
if len(outs) == 1 and outs[0].is_dir_checksum:
out = outs[0]
# other code expects us to fetch the dir at this point
self._fetch_dir(out, **kwargs)
return out.hash_info
except OutputNotFoundError:
pass
return super().get_dir_hash(path_info, **kwargs)
def get_file_hash(self, path_info):
outs = self._find_outs(path_info, strict=False)
if len(outs) != 1:
raise OutputNotFoundError
out = outs[0]
if out.is_dir_checksum:
return self._get_granular_hash(path_info, out)
return out.hash_info
def metadata(self, path_info):
path_info = PathInfo(os.path.abspath(path_info))
@@ -270,3 +230,20 @@ class DvcTree(BaseTree): # pylint:disable=abstract-method
meta = Metadata(path_info=path_info, outs=outs, repo=self.repo)
meta.isdir = meta.isdir or self.check_isdir(meta.path_info, meta.outs)
return meta
def info(self, path_info):
meta = self.metadata(path_info)
ret = {"type": "dir" if meta.isdir else "file"}
if meta.is_output and len(meta.outs) == 1 and meta.outs[0].hash_info:
hash_info = meta.outs[0].hash_info
ret["size"] = hash_info.size
ret[hash_info.name] = hash_info.value
elif meta.part_of_output:
(out,) = meta.outs
key = path_info.relative_to(out.path_info).parts
hash_info = out.hash_info.dir_info.trie.get(key)
if hash_info:
ret["size"] = hash_info.size
ret[hash_info.name] = hash_info.value
return ret
+115
View File
@@ -0,0 +1,115 @@
import os
import shutil
from dvc.progress import Tqdm
from .base import BaseFileSystem
# pylint: disable=no-member
class FSSpecWrapper(BaseFileSystem):
def fs(self):
raise NotImplementedError
def _with_bucket(self, path):
if isinstance(path, self.PATH_CLS):
return f"{path.bucket}/{path.path}"
return path
def _strip_bucket(self, entry):
_, entry = entry.split("/", 1)
return entry
def _strip_buckets(self, entries, detail, prefix=None):
for entry in entries:
if detail:
entry = self._entry_hook(entry.copy())
entry["name"] = self._strip_bucket(entry["name"])
else:
entry = self._strip_bucket(
f"{prefix}/{entry}" if prefix else entry
)
yield entry
def _entry_hook(self, entry):
"""Simple hook method to be overriden when wanted to process
entries within info() and ls(detail=True) calls"""
return entry
def isdir(self, path_info):
return self.fs.isdir(self._with_bucket(path_info))
def isfile(self, path_info):
return self.fs.isfile(self._with_bucket(path_info))
def open(
self, path_info, mode="r", **kwargs
): # pylint: disable=arguments-differ
return self.fs.open(self._with_bucket(path_info), mode=mode)
def copy(self, from_info, to_info):
self.fs.copy(self._with_bucket(from_info), self._with_bucket(to_info))
def exists(self, path_info, use_dvcignore=False):
return self.fs.exists(self._with_bucket(path_info))
def ls(
self, path_info, detail=False, recursive=False
): # pylint: disable=arguments-differ
path = self._with_bucket(path_info)
if recursive:
for root, _, files in self.fs.walk(path, detail=detail):
if detail:
files = files.values()
yield from self._strip_buckets(files, detail, prefix=root)
return None
yield from self._strip_buckets(self.ls(path, detail=detail), detail)
def walk_files(self, path_info, **kwargs):
for file in self.ls(path_info, recursive=True):
yield path_info.replace(path=file)
def remove(self, path_info):
self.fs.rm(self._with_bucket(path_info))
def info(self, path_info):
info = self.fs.info(self._with_bucket(path_info)).copy()
info = self._entry_hook(info)
info["name"] = self._strip_bucket(info["name"])
return info
def _upload_fobj(self, fobj, to_info):
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
):
total = os.path.getsize(from_file)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
self._with_bucket(to_info),
desc=name,
total=total,
no_progress_bar=no_progress_bar,
)
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)
+133 -86
View File
@@ -18,7 +18,7 @@ from dvc.scheme import Schemes
from dvc.utils import format_link, tmp_fname
from dvc.utils.stream import IterStream
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"
@@ -85,11 +85,11 @@ class GDriveURLInfo(CloudURLInfo):
self._spath = re.sub("/{2,}", "/", self._spath.rstrip("/"))
class GDriveTree(BaseTree):
class GDriveFileSystem(BaseFileSystem):
scheme = Schemes.GDRIVE
PATH_CLS = GDriveURLInfo
PARAM_CHECKSUM = "checksum"
REQUIRES = {"pydrive2": "pydrive2"}
DEFAULT_VERIFY = True
# Always prefer traverse for GDrive since API usage quotas are a concern.
TRAVERSE_WEIGHT_MULTIPLIER = 1
TRAVERSE_PREFIX_LEN = 2
@@ -114,23 +114,21 @@ class GDriveTree(BaseTree):
)
self._bucket = self.path_info.bucket
self._path = self.path_info.path
self._trash_only = config.get("gdrive_trash_only")
self._use_service_account = config.get("gdrive_use_service_account")
self._service_account_email = config.get(
"gdrive_service_account_email"
)
self._service_account_user_email = config.get(
"gdrive_service_account_user_email"
)
self._service_account_p12_file_path = config.get(
"gdrive_service_account_p12_file_path"
self._service_account_json_file_path = config.get(
"gdrive_service_account_json_file_path"
)
self._client_id = config.get("gdrive_client_id")
self._client_secret = config.get("gdrive_client_secret")
self._validate_config()
self._gdrive_user_credentials_path = (
tmp_fname(os.path.join(self.repo.tmp_dir, ""))
if os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA)
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
else config.get(
"gdrive_user_credentials_file",
os.path.join(
@@ -141,14 +139,14 @@ class GDriveTree(BaseTree):
def _validate_config(self):
# Validate Service Account configuration
if self._use_service_account and (
not self._service_account_email
or not self._service_account_p12_file_path
if (
self._use_service_account
and not self._service_account_json_file_path
):
raise DvcException(
"To use service account, set `gdrive_service_account_email`,\n"
"`gdrive_service_account_p12_file_path`, and optionally "
"`gdrive_service_account_user_email`\nin DVC config. "
"To use service account, set "
"`gdrive_service_account_json_file_path`, and optionally"
"`gdrive_service_account_user_email` in DVC config\n"
"Learn more at {}".format(
format_link("https://man.dvc.org/remote/modify")
)
@@ -172,8 +170,8 @@ class GDriveTree(BaseTree):
Useful for tests, exception messages, etc. Returns either env variable
name if it's set or actual path to the credentials file.
"""
if os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA):
return GDriveTree.GDRIVE_CREDENTIALS_DATA
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
return GDriveFileSystem.GDRIVE_CREDENTIALS_DATA
if os.path.exists(self._gdrive_user_credentials_path):
return self._gdrive_user_credentials_path
return None
@@ -187,7 +185,7 @@ class GDriveTree(BaseTree):
DVC config client id or secret but forgets to remove the cached
credentials file.
"""
if not os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA):
if not os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
if (
settings["client_config"]["client_id"]
!= auth.credentials.client_id
@@ -210,14 +208,15 @@ class GDriveTree(BaseTree):
from pydrive2.auth import GoogleAuth
from pydrive2.drive import GoogleDrive
if os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA):
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
with open(self._gdrive_user_credentials_path, "w") as cred_file:
cred_file.write(os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA))
cred_file.write(
os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA)
)
auth_settings = {
"client_config_backend": "settings",
"save_credentials": True,
"save_credentials_backend": "file",
"save_credentials_file": self._gdrive_user_credentials_path,
"get_refresh_token": True,
"oauth_scope": [
@@ -227,12 +226,13 @@ class GDriveTree(BaseTree):
}
if self._use_service_account:
auth_settings["save_credentials_backend"] = "json"
auth_settings["service_config"] = {
"client_service_email": self._service_account_email,
"client_user_email": self._service_account_user_email,
"client_pkcs12_file_path": self._service_account_p12_file_path,
"client_json_file_path": self._service_account_json_file_path,
}
else:
auth_settings["save_credentials_backend"] = "file"
auth_settings["client_config"] = {
"client_id": self._client_id or self.DEFAULT_GDRIVE_CLIENT_ID,
"client_secret": self._client_secret
@@ -258,7 +258,7 @@ class GDriveTree(BaseTree):
gauth.ServiceAuth()
else:
gauth.CommandLineAuth()
GDriveTree._validate_credentials(gauth, auth_settings)
GDriveFileSystem._validate_credentials(gauth, auth_settings)
# Handle AuthenticationError, RefreshError and other auth failures
# It's hard to come up with a narrow exception, since PyDrive throws
@@ -267,7 +267,7 @@ class GDriveTree(BaseTree):
except Exception as exc:
raise GDriveAuthError(self.credentials_location) from exc
finally:
if os.getenv(GDriveTree.GDRIVE_CREDENTIALS_DATA):
if os.getenv(GDriveFileSystem.GDRIVE_CREDENTIALS_DATA):
os.remove(self._gdrive_user_credentials_path)
return GoogleDrive(gauth)
@@ -285,20 +285,21 @@ class GDriveTree(BaseTree):
),
}
self._cache_path_id(self.path_info.path, cache["root_id"], cache)
self._cache_path_id(self._path, cache["root_id"], cache=cache)
for item in self._gdrive_list(
"'{}' in parents and trashed=false".format(cache["root_id"])
):
item_path = (self.path_info / item["title"]).path
self._cache_path_id(item_path, item["id"], cache)
self._cache_path_id(item_path, item["id"], cache=cache)
return cache
def _cache_path_id(self, path, item_id, cache=None):
def _cache_path_id(self, path, *item_ids, cache=None):
cache = cache or self._ids_cache
cache["dirs"][path].append(item_id)
cache["ids"][item_id] = path
for item_id in item_ids:
cache["dirs"][path].append(item_id)
cache["ids"][item_id] = path
@cached_property
def _list_params(self):
@@ -345,29 +346,12 @@ class GDriveTree(BaseTree):
return item.get("driveId", None)
@_gdrive_retry
def _gdrive_upload_file(
self,
parent_id,
title,
no_progress_bar=False,
from_file="",
progress_name="",
):
def _gdrive_upload_fobj(self, fobj, parent_id, title):
item = self._drive.CreateFile(
{"title": title, "parents": [{"id": parent_id}]}
)
with open(from_file, "rb") as fobj:
total = os.path.getsize(from_file)
with Tqdm.wrapattr(
fobj,
"read",
desc=progress_name,
total=total,
disable=no_progress_bar,
) as wrapped:
item.content = wrapped
item.Upload()
item.content = fobj
item.Upload()
return item
@_gdrive_retry
@@ -389,7 +373,7 @@ class GDriveTree(BaseTree):
@contextmanager
@_gdrive_retry
def open(self, path_info, mode="r", encoding=None):
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb"}
item_id = self._get_item_id(path_info)
@@ -494,7 +478,7 @@ class GDriveTree(BaseTree):
return self._ids_cache["dirs"].get(path, [])
return []
def _path_to_item_ids(self, path, create, use_cache):
def _path_to_item_ids(self, path, create=False, use_cache=True):
item_ids = self._get_cached_item_ids(path, use_cache)
if item_ids:
return item_ids
@@ -527,53 +511,116 @@ class GDriveTree(BaseTree):
else:
return True
def _list_paths(self, prefix=None):
if not self._ids_cache["ids"]:
return
if prefix:
dir_ids = self._ids_cache["dirs"].get(prefix[:2])
if not dir_ids:
return
else:
dir_ids = self._ids_cache["ids"]
parents_query = " or ".join(
f"'{dir_id}' in parents" for dir_id in dir_ids
def _gdrive_list_ids(self, query_ids):
query = " or ".join(
f"'{query_id}' in parents" for query_id in query_ids
)
query = f"({parents_query}) and trashed=false"
query = f"({query}) and trashed=false"
return self._gdrive_list(query)
for item in self._gdrive_list(query):
parent_id = item["parents"][0]["id"]
yield posixpath.join(
self._ids_cache["ids"][parent_id], item["title"]
)
def _ls_recursive(self, path_info, detail=False):
root_path = path_info.path
seen_paths = set()
dir_ids = [self._ids_cache["ids"].copy()]
while dir_ids:
query_ids = {
dir_id: dir_name
for dir_id, dir_name in dir_ids.pop().items()
if posixpath.commonpath([root_path, dir_name]) == root_path
if dir_id not in seen_paths
}
if not query_ids:
continue
seen_paths |= query_ids.keys()
new_query_ids = {}
dir_ids.append(new_query_ids)
for item in self._gdrive_list_ids(query_ids):
parent_id = item["parents"][0]["id"]
item_path = posixpath.join(query_ids[parent_id], item["title"])
if item["mimeType"] == FOLDER_MIME_TYPE:
new_query_ids[item["id"]] = item_path
self._cache_path_id(item_path, item["id"])
continue
if detail:
yield {
"type": "file",
"name": item_path,
"size": item["fileSize"],
"checksum": item["md5Checksum"],
}
else:
yield item_path
def ls(
self, path_info, detail=False, recursive=False
): # pylint: disable=arguments-differ
if recursive:
yield from self._ls_recursive(path_info, detail=detail)
return None
cached = path_info.path in self._ids_cache["dirs"]
if cached:
dir_ids = self._ids_cache["dirs"][path_info.path]
else:
dir_ids = self._path_to_item_ids(path_info.path)
if not dir_ids:
return None
root_path = path_info.path
for item in self._gdrive_list_ids(dir_ids):
item_path = posixpath.join(root_path, item["title"])
if detail:
if item["mimeType"] == FOLDER_MIME_TYPE:
yield {"type": "directory", "name": item_path}
else:
yield {
"type": "file",
"name": item_path,
"size": item["fileSize"],
"checksum": item["md5Checksum"],
}
else:
yield item_path
if not cached:
self._cache_path_id(root_path, *dir_ids)
def walk_files(self, path_info, **kwargs):
use_prefix = kwargs.pop("prefix", False)
if path_info == self.path_info or not use_prefix:
prefix = None
else:
prefix = path_info.path
for fname in self._list_paths(prefix=prefix, **kwargs):
yield path_info.replace(fname)
for filename in self.ls(path_info, recursive=True):
yield path_info.replace(path=filename)
def remove(self, path_info):
item_id = self._get_item_id(path_info)
self.gdrive_delete_file(item_id)
def get_file_hash(self, path_info):
raise NotImplementedError
def info(self, path_info):
item_id = self._get_item_id(path_info)
gdrive_file = self._drive.CreateFile({"id": item_id})
gdrive_file.FetchMetadata(fields="fileSize")
return {"size": gdrive_file.get("fileSize")}
def _upload_fobj(self, fobj, to_info):
dirname = to_info.parent
assert dirname
parent_id = self._get_item_id(dirname, create=True)
self._gdrive_upload_fobj(fobj, parent_id, to_info.name)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
dirname = to_info.parent
assert dirname
parent_id = self._get_item_id(dirname, True)
self._gdrive_upload_file(
parent_id, to_info.name, no_progress_bar, from_file, name
)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
to_info,
no_progress_bar=no_progress_bar,
desc=name or to_info.name,
total=os.path.getsize(from_file),
)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
item_id = self._get_item_id(from_info)
+174
View File
@@ -0,0 +1,174 @@
import errno
import os
from funcy import cached_property
from dvc.utils import is_exec, relpath
from .base import BaseFileSystem
class GitFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""Proxies the repo file access methods to Git objects"""
def __init__(
self, root_dir, trie, use_dvcignore=False, dvcignore_root=None
):
super().__init__(None, {})
self._fs_root = root_dir
self.trie = trie
self.use_dvcignore = use_dvcignore
self.dvcignore_root = dvcignore_root
@property
def rev(self):
return self.trie.rev
@property
def fs_root(self):
return self._fs_root
def _get_key(self, path):
if isinstance(path, str):
if not os.path.isabs(path):
relparts = path.split(os.sep)
else:
relparts = relpath(path, self.fs_root).split(os.sep)
else:
relparts = path.relative_to(self.fs_root).parts
if relparts == ["."]:
return ()
return tuple(relparts)
@cached_property
def dvcignore(self):
from dvc.ignore import DvcIgnoreFilter, DvcIgnoreFilterNoop
root = self.dvcignore_root or self.fs_root
cls = DvcIgnoreFilter if self.use_dvcignore else DvcIgnoreFilterNoop
return cls(self, root)
def open(
self, path, mode="r", encoding=None
): # pylint: disable=arguments-differ
# NOTE: this is incorrect, we need to use locale to determine default
# encoding.
encoding = encoding or "utf-8"
key = self._get_key(path)
try:
return self.trie.open(key, mode=mode, encoding=encoding)
except KeyError as exc:
msg = os.strerror(errno.ENOENT) + f"in branch '{self.rev}'"
raise FileNotFoundError(errno.ENOENT, msg, path) from exc
except IsADirectoryError as exc:
raise IsADirectoryError(
errno.EISDIR, os.strerror(errno.EISDIR), path
) from exc
def exists(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
def _is_ignored(path):
return self.dvcignore.is_ignored_file(
path
) or self.dvcignore.is_ignored_dir(path)
if use_dvcignore and _is_ignored(path):
return False
key = self._get_key(path)
return self.trie.exists(key)
def isdir(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
if use_dvcignore and self.dvcignore.is_ignored_dir(path):
return False
key = self._get_key(path)
return self.trie.isdir(key)
def isfile(self, path): # pylint: disable=arguments-differ
if self.dvcignore.is_ignored_file(path):
return False
key = self._get_key(path)
return self.trie.isfile(key)
def walk(
self,
top,
topdown=True,
onerror=None,
use_dvcignore=True,
ignore_subrepos=True,
):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
- no support for symlinks
"""
if not self.isdir(top, use_dvcignore=use_dvcignore):
if onerror:
if self.exists(top, use_dvcignore=use_dvcignore):
exc = NotADirectoryError(
errno.ENOTDIR, os.strerror(errno.ENOTDIR), top
)
else:
exc = FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), top
)
onerror(exc)
return []
key = self._get_key(top)
for prefix, dirs, files in self.trie.walk(key, topdown=topdown):
if prefix:
root = os.path.join(self.fs_root, os.sep.join(prefix))
else:
root = self.fs_root
if use_dvcignore:
dirs[:], files[:] = self.dvcignore(
root, dirs, files, ignore_subrepos=ignore_subrepos,
)
yield root, dirs, files
def isexec(self, path_info):
if not self.exists(path_info):
return False
mode = self.stat(path_info).st_mode
return is_exec(mode)
def info(self, path_info):
key = self._get_key(path_info)
try:
st = self.trie.stat(key)
except KeyError:
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path_info
)
return {"size": st.st_size}
def stat(self, path):
key = self._get_key(path)
try:
return self.trie.stat(key)
except KeyError:
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path
)
@property
def hash_jobs(self): # pylint: disable=invalid-overridden-method
# NOTE: gitpython is not threadsafe. See
# https://github.com/iterative/dvc/issues/4079
return 1
def walk_files(self, top): # pylint: disable=arguments-differ
for root, _, files in self.walk(top):
for file in files:
# NOTE: os.path.join is ~5.5 times slower
yield f"{root}{os.sep}{file}"
def _reset(self):
return self.__dict__.pop("dvcignore", None)
+42
View File
@@ -0,0 +1,42 @@
import base64
import threading
from funcy import cached_property, wrap_prop
from dvc.path_info import CloudURLInfo
from dvc.scheme import Schemes
from .fsspec_wrapper import FSSpecWrapper
class GSFileSystem(FSSpecWrapper):
scheme = Schemes.GS
PATH_CLS = CloudURLInfo
REQUIRES = {"gcsfs": "gcsfs"}
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
def __init__(self, repo, config):
super().__init__(repo, config)
url = config.get("url", "gs://")
self.path_info = self.PATH_CLS(url)
self.login_info = self._prepare_credentials(config)
def _prepare_credentials(self, config):
login_info = {}
login_info["project"] = config.get("projectname")
login_info["token"] = config.get("credentialpath")
return login_info
def _entry_hook(self, entry):
entry["etag"] = base64.b64decode(entry["etag"]).hex()
return entry
@wrap_prop(threading.Lock())
@cached_property
def fs(self):
from gcsfs import GCSFileSystem
return GCSFileSystem(**self.login_info)
+275
View File
@@ -0,0 +1,275 @@
import io
import logging
import os
import re
import shutil
import subprocess
from collections import deque
from contextlib import closing, contextmanager
from urllib.parse import urlparse
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 .base import BaseFileSystem, RemoteCmdError
from .pool import get_connection
logger = logging.getLogger(__name__)
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):
scheme = Schemes.HDFS
REQUIRES = {"pyarrow": "pyarrow"}
REGEX = r"^hdfs://((?P<user>.*)@)?.*$"
PARAM_CHECKSUM = "checksum"
TRAVERSE_PREFIX_LEN = 2
def __init__(self, repo, config):
super().__init__(repo, config)
self.path_info = None
url = config.get("url")
if not url:
return
parsed = urlparse(url)
user = parsed.username or config.get("user")
self.path_info = self.PATH_CLS.from_parts(
scheme=self.scheme,
host=parsed.hostname,
user=user,
port=parsed.port,
path=parsed.path,
)
@staticmethod
def hdfs(path_info):
import pyarrow.fs
# 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
return get_connection(
HDFSConnection,
path_info.host,
path_info.port,
user=path_info.user,
)
@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, use_dvcignore=True):
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, path_info, **kwargs):
if not self.isdir(path_info):
return
with self.hdfs(path_info) as hdfs:
for root, dnames, fnames in self._walk(
hdfs, path_info.path, **kwargs
):
yield path_info.replace(path=root), dnames, fnames
def walk_files(self, path_info, **kwargs):
for root, _, fnames in self.walk(path_info):
yield from (root / fname for fname in fnames)
def remove(self, path_info):
if path_info.scheme != "hdfs":
raise NotImplementedError
if self.exists(path_info):
logger.debug(f"Removing {path_info.path}")
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
if file_info.type == pyarrow.fs.FileType.Directory:
hdfs.delete_dir(path_info.path)
else:
hdfs.delete_file(path_info.path)
def makedirs(self, path_info):
with self.hdfs(path_info) as hdfs:
# NOTE: fs.create_dir creates parents by default
hdfs.create_dir(path_info.path)
def copy(self, from_info, to_info, **_kwargs):
# NOTE: hdfs.copy_file is not supported yet in pyarrow
with self.hdfs(to_info) as hdfs:
# NOTE: this is how `hadoop fs -cp` works too: it copies through
# your local machine.
with closing(hdfs.open_input_stream(from_info.path)) as from_fobj:
tmp_info = to_info.parent / tmp_fname(to_info.name)
try:
with closing(
hdfs.open_output_stream(tmp_info.path)
) as tmp_fobj:
shutil.copyfileobj(from_fobj, tmp_fobj)
hdfs.move(tmp_info.path, to_info.path)
except Exception:
self.remove(tmp_info)
raise
def isfile(self, path_info):
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
return file_info.type == pyarrow.fs.FileType.File
def isdir(self, path_info):
with self.hdfs(path_info) as hdfs:
import pyarrow.fs
file_info = hdfs.get_file_info(path_info.path)
return file_info.type == pyarrow.fs.FileType.Directory
def info(self, path_info):
with self.hdfs(path_info) as hdfs:
finfo = hdfs.get_file_info(path_info.path)
return {"size": finfo.size}
def checksum(self, path_info):
return HashInfo(
"checksum",
_hadoop_fs_checksum(path_info),
size=self.getsize(path_info),
)
def _upload_fobj(self, fobj, to_info):
with self.hdfs(to_info) as hdfs:
with hdfs.open_output_stream(to_info.path) as fdest:
shutil.copyfileobj(fobj, fdest)
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)
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())
+32 -44
View File
@@ -6,12 +6,11 @@ from funcy import cached_property, memoize, wrap_prop, wrap_with
import dvc.prompt as prompt
from dvc.exceptions import DvcException, HTTPError
from dvc.hash_info import HashInfo
from dvc.path_info import HTTPURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
@@ -25,7 +24,7 @@ def ask_password(host, user):
)
class HTTPTree(BaseTree): # pylint:disable=abstract-method
class HTTPFileSystem(BaseFileSystem): # pylint:disable=abstract-method
scheme = Schemes.HTTP
PATH_CLS = HTTPURLInfo
PARAM_CHECKSUM = "etag"
@@ -42,9 +41,8 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
url = config.get("url")
if url:
self.path_info = self.PATH_CLS(url)
user = config.get("user", None)
if user:
self.path_info.user = user
self.user = config.get("user", None)
self.host = self.path_info.host
else:
self.path_info = None
@@ -56,24 +54,23 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
self.ssl_verify = config.get("ssl_verify", True)
self.method = config.get("method", "POST")
def _auth_method(self, path_info=None):
def _auth_method(self):
from requests.auth import HTTPBasicAuth, HTTPDigestAuth
if path_info is None:
path_info = self.path_info
if self.auth:
if self.ask_password and self.password is None:
host, user = path_info.host, path_info.user
self.password = ask_password(host, user)
self.password = ask_password(self.host, self.user)
if self.auth == "basic":
return HTTPBasicAuth(path_info.user, self.password)
return HTTPBasicAuth(self.user, self.password)
if self.auth == "digest":
return HTTPDigestAuth(path_info.user, self.password)
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
def _generate_download_url(self, path_info):
return path_info.url
@wrap_prop(threading.Lock())
@cached_property
def _session(self):
@@ -148,20 +145,24 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
return True
raise HTTPError(res.status_code, res.reason)
def get_file_hash(self, path_info):
def info(self, path_info):
url = path_info.url
headers = self._head(url).headers
etag = headers.get("ETag") or headers.get("Content-MD5")
size = int(headers.get("Content-Length"))
return {"etag": etag, "size": size}
if not etag:
raise DvcException(
"could not find an ETag or "
"Content-MD5 header for '{url}'".format(url=url)
)
def _upload_fobj(self, fobj, to_info):
def chunks(fobj):
while True:
chunk = fobj.read(self.CHUNK_SIZE)
if not chunk:
break
yield chunk
return HashInfo(self.PARAM_CHECKSUM, etag)
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)
@@ -184,27 +185,14 @@ class HTTPTree(BaseTree): # pylint:disable=abstract-method
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
def chunks():
with open(from_file, "rb") as fd:
with Tqdm.wrapattr(
fd,
"read",
total=None
if no_progress_bar
else os.path.getsize(from_file),
leave=False,
desc=to_info.url if name is None else name,
disable=no_progress_bar,
) as fd_wrapped:
while True:
chunk = fd_wrapped.read(self.CHUNK_SIZE)
if not chunk:
break
yield chunk
response = self.request(self.method, to_info.url, data=chunks())
if response.status_code not in (200, 201):
raise HTTPError(response.status_code, response.reason)
with open(from_file, "rb") as fobj:
self.upload_fobj(
fobj,
to_info,
no_progress_bar=no_progress_bar,
desc=name or to_info.url,
total=None if no_progress_bar else os.path.getsize(from_file),
)
@staticmethod
def _content_length(response):
+7
View File
@@ -0,0 +1,7 @@
from dvc.scheme import Schemes
from .http import HTTPFileSystem
class HTTPSFileSystem(HTTPFileSystem): # pylint:disable=abstract-method
scheme = Schemes.HTTPS
+28 -150
View File
@@ -1,38 +1,27 @@
import errno
import logging
import os
import stat
from typing import Any, Dict
from funcy import cached_property
from shortuuid import uuid
from dvc.exceptions import DvcException
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from dvc.scheme import Schemes
from dvc.system import System
from dvc.utils import file_md5, is_exec, relpath, tmp_fname
from dvc.utils import is_exec, tmp_fname
from dvc.utils.fs import copy_fobj_to_file, copyfile, makedirs, move, remove
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
class LocalTree(BaseTree):
class LocalFileSystem(BaseFileSystem):
scheme = Schemes.LOCAL
PATH_CLS = PathInfo
PARAM_CHECKSUM = "md5"
PARAM_PATH = "path"
TRAVERSE_PREFIX_LEN = 2
CACHE_MODE = 0o444
SHARED_MODE_MAP: Dict[Any, Any] = {
None: (0o644, 0o755),
"group": (0o664, 0o775),
}
def __init__(self, repo, config, use_dvcignore=False, dvcignore_root=None):
super().__init__(repo, config)
url = config.get("url")
@@ -41,25 +30,19 @@ class LocalTree(BaseTree):
self.dvcignore_root = dvcignore_root
@property
def tree_root(self):
def fs_root(self):
return self.config.get("url")
@property
def state(self):
from dvc.state import StateNoop
return self.repo.state if self.repo else StateNoop()
@cached_property
def dvcignore(self):
from dvc.ignore import DvcIgnoreFilter, DvcIgnoreFilterNoop
root = self.dvcignore_root or self.tree_root
root = self.dvcignore_root or self.fs_root
cls = DvcIgnoreFilter if self.use_dvcignore else DvcIgnoreFilterNoop
return cls(self, root)
@staticmethod
def open(path_info, mode="r", encoding=None):
def open(path_info, mode="r", encoding=None, **kwargs):
return open(path_info, mode=mode, encoding=encoding)
def exists(self, path_info, use_dvcignore=True):
@@ -103,7 +86,7 @@ class LocalTree(BaseTree):
use_dvcignore=True,
ignore_subrepos=True,
):
"""Directory tree generator.
"""Directory fs generator.
See `os.walk` for the docs. Differences:
- no support for symlinks
@@ -128,12 +111,10 @@ class LocalTree(BaseTree):
yield PathInfo(f"{root}{os.sep}{file}")
def is_empty(self, path_info):
path = path_info.fspath
if self.isfile(path_info) and os.path.getsize(path) == 0:
if self.isfile(path_info) and os.path.getsize(path_info) == 0:
return True
if self.isdir(path_info) and len(os.listdir(path)) == 0:
if self.isdir(path_info) and len(os.listdir(path_info)) == 0:
return True
return False
@@ -142,18 +123,13 @@ class LocalTree(BaseTree):
if isinstance(path_info, PathInfo):
if path_info.scheme != "local":
raise NotImplementedError
path = path_info.fspath
else:
path = path_info
if self.exists(path):
remove(path)
remove(path_info)
def makedirs(self, path_info):
makedirs(path_info, exist_ok=True, mode=self.dir_mode)
makedirs(path_info, exist_ok=True)
def isexec(self, path):
mode = os.stat(path).st_mode
def isexec(self, path_info):
mode = self.stat(path_info).st_mode
return is_exec(mode)
def stat(self, path):
@@ -162,36 +138,27 @@ class LocalTree(BaseTree):
return os.stat(path)
def move(self, from_info, to_info, mode=None):
def move(self, from_info, to_info):
if from_info.scheme != "local" or to_info.scheme != "local":
raise NotImplementedError
self.makedirs(to_info.parent)
if mode is None:
if self.isfile(from_info):
mode = self.file_mode
else:
mode = self.dir_mode
move(from_info, to_info, mode=mode)
move(from_info, to_info)
def copy(self, from_info, to_info):
tmp_info = to_info.parent / tmp_fname(to_info.name)
tmp_info = to_info.parent / tmp_fname("")
try:
System.copy(from_info, tmp_info)
os.chmod(tmp_info, self.file_mode)
copyfile(from_info, tmp_info)
os.rename(tmp_info, to_info)
except Exception:
self.remove(tmp_info)
raise
def copy_fobj(self, fobj, to_info):
def _upload_fobj(self, fobj, to_info):
self.makedirs(to_info.parent)
tmp_info = to_info.parent / tmp_fname(to_info.name)
tmp_info = to_info.parent / tmp_fname("")
try:
copy_fobj_to_file(fobj, tmp_info)
os.chmod(tmp_info, self.file_mode)
os.rename(tmp_info, to_info)
except Exception:
self.remove(tmp_info)
@@ -222,11 +189,7 @@ class LocalTree(BaseTree):
if self.getsize(from_info) == 0:
self.open(to_info, "w").close()
logger.debug(
"Created empty file: {src} -> {dest}".format(
src=str(from_info), dest=str(to_info)
)
)
logger.debug("Created empty file: %s -> %s", from_info, to_info)
return
System.hardlink(from_info, to_info)
@@ -236,98 +199,18 @@ class LocalTree(BaseTree):
return System.is_hardlink(path_info)
def reflink(self, from_info, to_info):
tmp_info = to_info.parent / tmp_fname(to_info.name)
System.reflink(from_info, tmp_info)
# NOTE: reflink has its own separate inode, so you can set permissions
# that are different from the source.
os.chmod(tmp_info, self.file_mode)
os.rename(tmp_info, to_info)
def chmod(self, path_info, mode):
path = os.fspath(path_info)
try:
os.chmod(path, mode)
except OSError as exc:
# There is nothing we need to do in case of a read-only file system
if exc.errno == errno.EROFS:
return
# In shared cache scenario, we might not own the cache file, so we
# need to check if cache file is already protected.
if exc.errno not in [errno.EPERM, errno.EACCES]:
raise
actual = stat.S_IMODE(os.stat(path).st_mode)
if actual != mode:
raise
def _unprotect_file(self, path):
if System.is_symlink(path) or System.is_hardlink(path):
logger.debug(f"Unprotecting '{path}'")
tmp = os.path.join(os.path.dirname(path), "." + uuid())
# The operations order is important here - if some application
# would access the file during the process of copyfile then it
# would get only the part of file. So, at first, the file should be
# copied with the temporary name, and then original file should be
# replaced by new.
copyfile(path, tmp, name="Unprotecting '{}'".format(relpath(path)))
remove(path)
os.rename(tmp, path)
else:
logger.debug(
"Skipping copying for '{}', since it is not "
"a symlink or a hardlink.".format(path)
)
os.chmod(path, self.file_mode)
def _unprotect_dir(self, path):
for fname in self.walk_files(path):
self._unprotect_file(fname)
def unprotect(self, path_info):
path = path_info.fspath
if not os.path.exists(path):
raise DvcException(f"can't unprotect non-existing data '{path}'")
if os.path.isdir(path):
self._unprotect_dir(path)
else:
self._unprotect_file(path)
def protect(self, path_info):
self.chmod(path_info, self.CACHE_MODE)
def is_protected(self, path_info):
try:
mode = os.stat(path_info).st_mode
except FileNotFoundError:
return False
return stat.S_IMODE(mode) == self.CACHE_MODE
def get_file_hash(self, path_info):
hash_info = HashInfo(self.PARAM_CHECKSUM, file_md5(path_info)[0],)
if hash_info:
hash_info.size = os.path.getsize(path_info)
return hash_info
System.reflink(from_info, to_info)
@staticmethod
def getsize(path_info):
return os.path.getsize(path_info)
def info(path_info):
st = os.stat(path_info)
return {
"size": st.st_size,
"type": "dir" if stat.S_ISDIR(st.st_mode) else "file",
}
def _upload(
self,
from_file,
to_info,
name=None,
no_progress_bar=False,
file_mode=None,
**_kwargs,
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs,
):
makedirs(to_info.parent, exist_ok=True)
@@ -335,11 +218,6 @@ class LocalTree(BaseTree):
copyfile(
from_file, tmp_file, name=name, no_progress_bar=no_progress_bar
)
if file_mode is not None:
self.chmod(tmp_file, file_mode)
else:
self.protect(tmp_file)
os.replace(tmp_file, to_info)
@staticmethod
+31
View File
@@ -0,0 +1,31 @@
from .base import BaseFileSystem
class MemoryFileSystem(BaseFileSystem):
scheme = "local"
PARAM_CHECKSUM = "md5"
def __init__(self, repo, config):
from fsspec.implementations.memory import MemoryFileSystem as MemFS
super().__init__(repo, config)
self.fs = MemFS()
def exists(self, path_info, use_dvcignore=True):
return self.fs.exists(path_info.path)
def open(self, path_info, mode="r", encoding=None, **kwargs):
return self.fs.open(
path_info.fspath, mode=mode, encoding=encoding, **kwargs
)
def stat(self, path_info):
import os
info = self.fs.info(path_info.fspath)
return os.stat_result((0, 0, 0, 0, 0, 0, info["size"], 0, 0, 0))
def walk_files(self, path_info, **kwargs):
raise NotImplementedError
+15 -7
View File
@@ -8,12 +8,12 @@ from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
class OSSTree(BaseTree): # pylint:disable=abstract-method
class OSSFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""
oss2 document:
https://www.alibabacloud.com/help/doc-detail/32026.htm
@@ -45,6 +45,7 @@ class OSSTree(BaseTree): # pylint:disable=abstract-method
url = config.get("url")
self.path_info = self.PATH_CLS(url) if url else None
self.bucket = self.path_info.bucket
self.endpoint = config.get("oss_endpoint") or os.getenv("OSS_ENDPOINT")
self.key_id = (
@@ -64,12 +65,11 @@ class OSSTree(BaseTree): # pylint:disable=abstract-method
def oss_service(self):
import oss2
logger.debug(f"URL: {self.path_info}")
logger.debug(f"key id: {self.key_id}")
logger.debug(f"key secret: {self.key_secret}")
auth = oss2.Auth(self.key_id, self.key_secret)
bucket = oss2.Bucket(auth, self.endpoint, self.path_info.bucket)
bucket = oss2.Bucket(auth, self.endpoint, self.bucket)
# Ensure bucket exists
try:
@@ -84,7 +84,7 @@ class OSSTree(BaseTree): # pylint:disable=abstract-method
return bucket
def _generate_download_url(self, path_info, expires=3600):
assert path_info.bucket == self.path_info.bucket
assert path_info.bucket == self.bucket
return self.oss_service.sign_url("GET", path_info.path, expires)
@@ -116,6 +116,9 @@ class OSSTree(BaseTree): # pylint:disable=abstract-method
logger.debug(f"Removing oss://{path_info}")
self.oss_service.delete_object(path_info.path)
def _upload_fobj(self, fobj, to_info):
self.oss_service.put_object(to_info.path, fobj)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
):
@@ -128,6 +131,11 @@ class OSSTree(BaseTree): # pylint:disable=abstract-method
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
with Tqdm(desc=name, disable=no_progress_bar, bytes=True) as pbar:
self.oss_service.get_object_to_file(
from_info.path, to_file, progress_callback=pbar.update_to
import oss2
oss2.resumable_download(
self.oss_service,
from_info.path,
to_file,
progress_callback=pbar.update_to,
)
+1 -1
View File
@@ -11,7 +11,7 @@ def get_connection(conn_func, *args, **kwargs):
conn = pool.get_connection()
try:
yield conn
except BaseException:
except Exception:
conn.close()
raise
else:
+119 -132
View File
@@ -8,16 +8,10 @@ from typing import TYPE_CHECKING, Callable, Optional, Tuple, Type, Union
from funcy import lfilter, wrap_with
from dvc.dvcfile import is_valid_filename
from dvc.hash_info import HashInfo
from dvc.path_info import PathInfo
from dvc.utils import file_md5, is_exec
from dvc.utils.collections import PathStringTrie
from dvc.utils.fs import copy_fobj_to_file, makedirs
from ._metadata import Metadata
from .base import BaseTree
from .dvc import DvcTree
from .base import BaseFileSystem
from .dvc import DvcFileSystem
if TYPE_CHECKING:
from dvc.repo import Repo
@@ -27,24 +21,26 @@ logger = logging.getLogger(__name__)
RepoFactory = Union[Callable[[str], "Repo"], Type["Repo"]]
class RepoTree(BaseTree): # pylint:disable=abstract-method
"""DVC + git-tracked files tree.
class RepoFileSystem(BaseFileSystem): # pylint:disable=abstract-method
"""DVC + git-tracked files fs.
Args:
repo: DVC or git repo.
subrepos: traverse to subrepos (by default, it ignores subrepos)
repo_factory: A function to initialize subrepo with, default is Repo.
kwargs: Additional keyword arguments passed to the `DvcTree()`.
kwargs: Additional keyword arguments passed to the `DvcFileSystem()`.
"""
scheme = "local"
PARAM_CHECKSUM = "md5"
def __init__(
self, repo, subrepos=False, repo_factory: RepoFactory = None, **kwargs
self, repo, subrepos=False, repo_factory: RepoFactory = None,
):
super().__init__(repo, {"url": repo.root_dir})
from dvc.utils.collections import PathStringTrie
if not repo_factory:
from dvc.repo import Repo
@@ -61,13 +57,11 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
self._subrepos_trie[self.root_dir] = repo
self._dvctrees = {}
"""Keep a dvctree instance of each repo."""
self._dvctree_configs = kwargs
self._dvcfss = {}
"""Keep a dvcfs instance of each repo."""
if hasattr(repo, "dvc_dir"):
self._dvctrees[repo.root_dir] = DvcTree(repo, **kwargs)
self._dvcfss[repo.root_dir] = DvcFileSystem(repo)
def _get_repo(self, path) -> Optional["Repo"]:
"""Returns repo that the path falls in, using prefix.
@@ -97,10 +91,13 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
repo = starting_repo
for d in dirs:
if self._is_dvc_repo(d):
repo = self.repo_factory(d)
self._dvctrees[repo.root_dir] = DvcTree(
repo, **self._dvctree_configs
repo = self.repo_factory(
d,
scm=self.repo.scm,
rev=self.repo.get_rev(),
repo_factory=self.repo_factory,
)
self._dvcfss[repo.root_dir] = DvcFileSystem(repo)
self._subrepos_trie[d] = repo
def _is_dvc_repo(self, dir_path):
@@ -112,11 +109,13 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
repo_path = os.path.join(dir_path, Repo.DVC_DIR)
# dvcignore will ignore subrepos, therefore using `use_dvcignore=False`
return self._main_repo.tree.isdir(repo_path, use_dvcignore=False)
return self._main_repo.fs.isdir(repo_path, use_dvcignore=False)
def _get_tree_pair(self, path) -> Tuple[BaseTree, Optional[DvcTree]]:
def _get_fs_pair(
self, path
) -> Tuple[BaseFileSystem, Optional[DvcFileSystem]]:
"""
Returns a pair of trees based on repo the path falls in, using prefix.
Returns a pair of fss based on repo the path falls in, using prefix.
"""
path = os.path.abspath(path)
@@ -124,16 +123,8 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
# this can happen if the path is outside of the repo
repo = self._get_repo(path) or self._main_repo
dvc_tree = self._dvctrees.get(repo.root_dir)
return repo.tree, dvc_tree
@property
def fetch(self):
return "fetch" in self._dvctree_configs
@property
def stream(self):
return "stream" in self._dvctree_configs
dvc_fs = self._dvcfss.get(repo.root_dir)
return repo.fs, dvc_fs
def open(
self, path, mode="r", encoding="utf-8", **kwargs
@@ -141,99 +132,99 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
if "b" in mode:
encoding = None
tree, dvc_tree = self._get_tree_pair(path)
fs, dvc_fs = self._get_fs_pair(path)
path_info = PathInfo(path)
try:
return tree.open(path_info, mode=mode, encoding=encoding)
return fs.open(path_info, mode=mode, encoding=encoding)
except FileNotFoundError:
if not dvc_tree:
if not dvc_fs:
raise
return dvc_tree.open(path_info, mode=mode, encoding=encoding, **kwargs)
return dvc_fs.open(path_info, mode=mode, encoding=encoding, **kwargs)
def exists(
self, path, use_dvcignore=True
): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
fs, dvc_fs = self._get_fs_pair(path)
if not dvc_tree:
return tree.exists(path)
if not dvc_fs:
return fs.exists(path)
if tree.exists(path):
if fs.exists(path):
return True
try:
meta = dvc_tree.metadata(path)
meta = dvc_fs.metadata(path)
except FileNotFoundError:
return False
(out,) = meta.outs
assert len(meta.outs) == 1
if tree.exists(out.path_info):
if fs.exists(out.path_info):
return False
return True
def isdir(self, path): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
fs, dvc_fs = self._get_fs_pair(path)
try:
st = tree.stat(path)
st = fs.stat(path)
return stat.S_ISDIR(st.st_mode)
except (OSError, ValueError):
# from CPython's os.path.isdir()
pass
if not dvc_tree:
if not dvc_fs:
return False
try:
meta = dvc_tree.metadata(path)
meta = dvc_fs.metadata(path)
except FileNotFoundError:
return False
(out,) = meta.outs
assert len(meta.outs) == 1
if tree.exists(out.path_info):
if fs.exists(out.path_info):
return False
return meta.isdir
def isdvc(self, path, **kwargs):
_, dvc_tree = self._get_tree_pair(path)
return dvc_tree is not None and dvc_tree.isdvc(path, **kwargs)
_, dvc_fs = self._get_fs_pair(path)
return dvc_fs is not None and dvc_fs.isdvc(path, **kwargs)
def isfile(self, path): # pylint: disable=arguments-differ
tree, dvc_tree = self._get_tree_pair(path)
fs, dvc_fs = self._get_fs_pair(path)
try:
st = tree.stat(path)
st = fs.stat(path)
return stat.S_ISREG(st.st_mode)
except (OSError, ValueError):
# from CPython's os.path.isfile()
pass
if not dvc_tree:
if not dvc_fs:
return False
try:
meta = dvc_tree.metadata(path)
meta = dvc_fs.metadata(path)
except FileNotFoundError:
return False
(out,) = meta.outs
assert len(meta.outs) == 1
if tree.exists(out.path_info):
if fs.exists(out.path_info):
return False
return meta.isfile
def isexec(self, path):
tree, dvc_tree = self._get_tree_pair(path)
if dvc_tree and dvc_tree.exists(path):
return dvc_tree.isexec(path)
return tree.isexec(path)
def isexec(self, path_info):
fs, dvc_fs = self._get_fs_pair(path_info)
if dvc_fs and dvc_fs.exists(path_info):
return dvc_fs.isexec(path_info)
return fs.isexec(path_info)
def stat(self, path):
tree, _ = self._get_tree_pair(path)
return tree.stat(path)
fs, _ = self._get_fs_pair(path)
return fs.stat(path)
def _dvc_walk(self, walk):
try:
@@ -250,17 +241,20 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
NOTE: subrepo will only be discovered when walking if
ignore_subrepos is set to False.
"""
tree, dvc_tree = self._get_tree_pair(dir_path)
tree_walk = tree.walk(
fs, dvc_fs = self._get_fs_pair(dir_path)
fs_walk = fs.walk(
dir_path, topdown=True, ignore_subrepos=not self._traverse_subrepos
)
if dvc_tree:
dvc_walk = dvc_tree.walk(dir_path, topdown=True, **kwargs)
if dvc_fs:
dvc_walk = dvc_fs.walk(dir_path, topdown=True, **kwargs)
else:
dvc_walk = None
yield from self._walk(tree_walk, dvc_walk, **kwargs)
yield from self._walk(fs_walk, dvc_walk, **kwargs)
def _walk(self, repo_walk, dvc_walk=None, dvcfiles=False):
from dvc.dvcfile import is_valid_filename
from dvc.ignore import DvcIgnore
assert repo_walk
try:
_, dvc_dirs, dvc_fnames = (
@@ -278,12 +272,16 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
shared = list(dvc_set & repo_set)
dirs = shared + dvc_only + repo_only
def _func(fname):
if dvcfiles:
return True
return not (
is_valid_filename(fname) or fname == DvcIgnore.DVCIGNORE_FILE
)
# merge file lists
files = {
fname
for fname in dvc_fnames + repo_fnames
if dvcfiles or not is_valid_filename(fname)
}
files = set(filter(_func, dvc_fnames + repo_fnames))
yield repo_root, dirs, list(files)
@@ -309,13 +307,19 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
def walk(
self, top, topdown=True, onerror=None, dvcfiles=False, **kwargs
self,
top,
topdown=True,
onerror=None,
dvcfiles=False,
follow_subrepos=None,
**kwargs
): # pylint: disable=arguments-differ
"""Walk and merge both DVC and repo trees.
"""Walk and merge both DVC and repo fss.
Args:
top: path to walk from
topdown: if True, tree will be walked from top down.
topdown: if True, fs will be walked from top down.
onerror: if set, onerror function will be called if an error
occurs (by default errors are ignored).
dvcfiles: if True, dvcfiles will be included in the files list
@@ -336,25 +340,29 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
onerror(NotADirectoryError(top))
return
tree, dvc_tree = self._get_tree_pair(top)
repo_exists = tree.exists(top)
repo_walk = tree.walk(
ignore_subrepos = not self._traverse_subrepos
if follow_subrepos is not None:
ignore_subrepos = not follow_subrepos
fs, dvc_fs = self._get_fs_pair(top)
repo_exists = fs.exists(top)
repo_walk = fs.walk(
top,
topdown=topdown,
onerror=onerror,
ignore_subrepos=not self._traverse_subrepos,
ignore_subrepos=ignore_subrepos,
)
if not dvc_tree or (repo_exists and dvc_tree.isdvc(top)):
if not dvc_fs or (repo_exists and dvc_fs.isdvc(top)):
yield from self._walk(repo_walk, None, dvcfiles=dvcfiles)
return
if not repo_exists:
yield from dvc_tree.walk(top, topdown=topdown, **kwargs)
yield from dvc_fs.walk(top, topdown=topdown, **kwargs)
dvc_walk = None
if dvc_tree.exists(top):
dvc_walk = dvc_tree.walk(top, topdown=topdown, **kwargs)
if dvc_fs.exists(top):
dvc_walk = dvc_fs.walk(top, topdown=topdown, **kwargs)
yield from self._walk(repo_walk, dvc_walk, dvcfiles=dvcfiles)
@@ -363,74 +371,43 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
for fname in files:
yield PathInfo(root) / fname
def get_dir_hash(self, path_info, **kwargs):
tree, dvc_tree = self._get_tree_pair(path_info)
if tree.exists(path_info):
return super().get_dir_hash(path_info, **kwargs)
if not dvc_tree:
raise FileNotFoundError
return dvc_tree.get_dir_hash(path_info, **kwargs)
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **kwargs
):
import shutil
def get_file_hash(self, path_info):
"""Return file checksum for specified path.
from dvc.progress import Tqdm
If path_info is a DVC out, the pre-computed checksum for the file
will be used. If path_info is a git file, MD5 will be computed for
the git object.
"""
if not self.exists(path_info):
raise FileNotFoundError
_, dvc_tree = self._get_tree_pair(path_info)
if dvc_tree and dvc_tree.exists(path_info):
try:
return dvc_tree.get_file_hash(path_info)
except FileNotFoundError:
pass
return HashInfo(self.PARAM_CHECKSUM, file_md5(path_info, self)[0])
def copytree(self, top, dest):
top = PathInfo(top)
dest = PathInfo(dest)
if not self.exists(top):
raise FileNotFoundError
if self.isfile(top):
makedirs(dest.parent, exist_ok=True)
with self.open(top, mode="rb") as fobj:
copy_fobj_to_file(fobj, dest)
return
for root, _, files in self.walk(top):
root = PathInfo(root)
dest_dir = dest / root.relative_to(top)
makedirs(dest_dir, exist_ok=True)
for fname in files:
src = root / fname
with self.open(src, mode="rb") as fobj:
copy_fobj_to_file(fobj, dest_dir / fname)
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)
@property
def hash_jobs(self): # pylint: disable=invalid-overridden-method
return self._main_repo.tree.hash_jobs
return self._main_repo.fs.hash_jobs
def metadata(self, path):
abspath = os.path.abspath(path)
path_info = PathInfo(abspath)
tree, dvc_tree = self._get_tree_pair(path_info)
fs, dvc_fs = self._get_fs_pair(path_info)
dvc_meta = None
if dvc_tree:
if dvc_fs:
with suppress(FileNotFoundError):
dvc_meta = dvc_tree.metadata(path_info)
dvc_meta = dvc_fs.metadata(path_info)
stat_result = None
with suppress(FileNotFoundError):
stat_result = tree.stat(path_info)
stat_result = fs.stat(path_info)
if not stat_result and not dvc_meta:
raise FileNotFoundError
from ._metadata import Metadata
meta = dvc_meta or Metadata(
path_info=path_info,
repo=self._get_repo(abspath) or self._main_repo,
@@ -440,5 +417,15 @@ class RepoTree(BaseTree): # pylint:disable=abstract-method
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)
return meta
def info(self, path_info):
fs, dvc_fs = self._get_fs_pair(path_info)
try:
return fs.info(path_info)
except FileNotFoundError:
return dvc_fs.info(path_info)
+114 -17
View File
@@ -11,18 +11,21 @@ from dvc.hash_info import HashInfo
from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from dvc.utils import error_link
from dvc.utils import conversions, error_link
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
_AWS_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".aws", "config")
class S3Tree(BaseTree):
class S3FileSystem(BaseFileSystem):
scheme = Schemes.S3
PATH_CLS = CloudURLInfo
REQUIRES = {"boto3": "boto3"}
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
def __init__(self, repo, config):
super().__init__(repo, config)
@@ -54,11 +57,62 @@ class S3Tree(BaseTree):
self.access_key_id = config.get("access_key_id")
self.secret_access_key = config.get("secret_access_key")
self.session_token = config.get("session_token")
shared_creds = config.get("credentialpath")
if shared_creds:
os.environ.setdefault("AWS_SHARED_CREDENTIALS_FILE", shared_creds)
config_path = config.get("configpath")
if config_path:
os.environ.setdefault("AWS_CONFIG_FILE", config_path)
self._transfer_config = None
# https://github.com/aws/aws-cli/blob/0376c6262d6b15dc36c82e6da6e1aad10249cc8c/awscli/customizations/s3/transferconfig.py#L107-L113
_TRANSFER_CONFIG_ALIASES = {
"max_queue_size": "max_io_queue",
"max_concurrent_requests": "max_concurrency",
"multipart_threshold": "multipart_threshold",
"multipart_chunksize": "multipart_chunksize",
}
def _transform_config(self, s3_config):
"""Splits the general s3 config into 2 different config
objects, one for transfer.TransferConfig and other is the
general session config"""
config, transfer_config = {}, {}
for key, value in s3_config.items():
if key in self._TRANSFER_CONFIG_ALIASES:
if key in {"multipart_chunksize", "multipart_threshold"}:
# cast human readable sizes (like 24MiB) to integers
value = conversions.human_readable_to_bytes(value)
else:
value = int(value)
transfer_config[self._TRANSFER_CONFIG_ALIASES[key]] = value
else:
config[key] = value
return config, transfer_config
def _process_config(self):
from boto3.s3.transfer import TransferConfig
from botocore.configloader import load_config
config_path = os.environ.get("AWS_CONFIG_FILE", _AWS_CONFIG_PATH)
if not os.path.exists(config_path):
return None
config = load_config(config_path)
profile = config["profiles"].get(self.profile or "default")
if not profile:
return None
s3_config = profile.get("s3", {})
s3_config, transfer_config = self._transform_config(s3_config)
self._transfer_config = TransferConfig(**transfer_config)
return s3_config
@wrap_prop(threading.Lock())
@cached_property
def s3(self):
@@ -73,11 +127,19 @@ class S3Tree(BaseTree):
session_opts["aws_access_key_id"] = self.access_key_id
if self.secret_access_key:
session_opts["aws_secret_access_key"] = self.secret_access_key
if self.session_token:
session_opts["aws_session_token"] = self.session_token
session = boto3.session.Session(**session_opts)
s3_config = self._process_config()
return session.resource(
"s3", endpoint_url=self.endpoint_url, use_ssl=self.use_ssl
"s3",
endpoint_url=self.endpoint_url,
use_ssl=self.use_ssl,
config=boto3.session.Config(
signature_version="s3v4", s3=s3_config
),
)
@contextmanager
@@ -198,13 +260,26 @@ class S3Tree(BaseTree):
return path_info.path in self._list_paths(path_info)
def _list_paths(self, path_info, max_items=None):
kwargs = {"Prefix": path_info.path}
if max_items is not None:
kwargs["MaxKeys"] = max_items
def info(self, path_info):
# temporary workaround, will be removed when migrating to s3fs
if self.isdir(path_info):
return {"type": "directory"}
with self._get_obj(path_info) as obj:
return {
"type": "file",
"etag": obj.e_tag.strip('"'),
"size": obj.content_length,
}
def _list_paths(self, path_info, max_items=None):
with self._get_bucket(path_info.bucket) as bucket:
for obj_summary in bucket.objects.filter(**kwargs):
obj_summaries = bucket.objects.filter(Prefix=path_info.path)
if max_items is not None:
obj_summaries = obj_summaries.page_size(max_items).limit(
max_items
)
for obj_summary in obj_summaries:
yield obj_summary.key
def walk_files(self, path_info, **kwargs):
@@ -216,6 +291,23 @@ class S3Tree(BaseTree):
yield path_info.replace(path=fname)
def ls(
self, path_info, detail=False, recursive=False
): # pylint: disable=arguments-differ
assert recursive
with self._get_bucket(path_info.bucket) as bucket:
for obj_summary in bucket.objects.filter(Prefix=path_info.path):
if detail:
yield {
"type": "file",
"name": obj_summary.key,
"size": obj_summary.size,
"etag": obj_summary.e_tag.strip('"'),
}
else:
yield obj_summary.key
def remove(self, path_info):
if path_info.scheme != "s3":
raise NotImplementedError
@@ -334,13 +426,13 @@ class S3Tree(BaseTree):
if etag != cached_etag:
raise ETagMismatchError(etag, cached_etag)
def get_file_hash(self, path_info):
def etag(self, path_info):
with self._get_obj(path_info) as obj:
return HashInfo(
self.PARAM_CHECKSUM,
obj.e_tag.strip('"'),
size=obj.content_length,
)
return HashInfo("etag", size=obj.content_length,)
def _upload_fobj(self, fobj, to_info):
with self._get_obj(to_info) as obj:
obj.upload_fileobj(fobj, Config=self._transfer_config)
def _upload(
self, from_file, to_info, name=None, no_progress_bar=False, **_kwargs
@@ -351,7 +443,10 @@ class S3Tree(BaseTree):
disable=no_progress_bar, total=total, bytes=True, desc=name
) as pbar:
obj.upload_file(
from_file, Callback=pbar.update, ExtraArgs=self.extra_args,
from_file,
Callback=pbar.update,
ExtraArgs=self.extra_args,
Config=self._transfer_config,
)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
@@ -362,4 +457,6 @@ class S3Tree(BaseTree):
bytes=True,
desc=name,
) as pbar:
obj.download_file(to_file, Callback=pbar.update)
obj.download_file(
to_file, Callback=pbar.update, Config=self._transfer_config
)
@@ -2,6 +2,7 @@ import getpass
import io
import logging
import os
import shutil
import threading
from contextlib import closing, contextmanager
from urllib.parse import urlparse
@@ -12,7 +13,7 @@ import dvc.prompt as prompt
from dvc.hash_info import HashInfo
from dvc.scheme import Schemes
from ..base import BaseTree
from ..base import BaseFileSystem
from ..pool import get_connection
logger = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ def ask_password(host, user, port):
)
class SSHTree(BaseTree):
class SSHFileSystem(BaseFileSystem):
scheme = Schemes.SSH
REQUIRES = {"paramiko": "paramiko"}
JOBS = 4
@@ -100,7 +101,7 @@ class SSHTree(BaseTree):
def _load_user_ssh_config(hostname):
import paramiko
user_config_file = SSHTree.ssh_config_filename()
user_config_file = SSHFileSystem.ssh_config_filename()
user_ssh_config = {}
if hostname and os.path.exists(user_config_file):
ssh_config = paramiko.SSHConfig()
@@ -147,7 +148,7 @@ class SSHTree(BaseTree):
)
@contextmanager
def open(self, path_info, mode="r", encoding=None):
def open(self, path_info, mode="r", encoding=None, **kwargs):
assert mode in {"r", "rt", "rb", "wb"}
with self.ssh(path_info) as ssh, closing(
@@ -186,8 +187,7 @@ class SSHTree(BaseTree):
with self.ssh(path_info) as ssh:
ssh.makedirs(path_info.path)
def move(self, from_info, to_info, mode=None):
assert mode is None
def move(self, from_info, to_info):
if from_info.scheme != self.scheme or to_info.scheme != self.scheme:
raise NotImplementedError
@@ -235,21 +235,22 @@ class SSHTree(BaseTree):
with self.ssh(from_info) as ssh:
ssh.reflink(from_info.path, to_info.path)
def get_file_hash(self, path_info):
if path_info.scheme != self.scheme:
raise NotImplementedError
def md5(self, path_info):
with self.ssh(path_info) as ssh:
hash_info = HashInfo(self.PARAM_CHECKSUM, ssh.md5(path_info.path),)
return HashInfo(
"md5",
ssh.md5(path_info.path),
size=ssh.getsize(path_info.path),
)
if hash_info:
hash_info.size = ssh.getsize(path_info.path)
return hash_info
def getsize(self, path_info):
def info(self, path_info):
with self.ssh(path_info) as ssh:
return ssh.getsize(path_info.path)
return ssh.info(path_info.path)
def _upload_fobj(self, fobj, to_info):
self.makedirs(to_info.parent)
with self.open(to_info, mode="wb") as fdest:
shutil.copyfileobj(fobj, fdest)
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
with self.ssh(from_info) as ssh:
@@ -76,12 +76,15 @@ class SSHConnection:
return lstat.st_mode if lstat else 0
def getsize(self, path):
lstat = None
with suppress(FileNotFoundError):
lstat = self.sftp.lstat(path)
def info(self, path):
st = self.sftp.stat(path)
return {
"size": st.st_size,
"type": "dir" if stat.S_ISDIR(st.st_mode) else "file",
}
return lstat.st_size if lstat else 0
def getsize(self, path):
return self.info(path)["size"]
def exists(self, path):
return bool(self.st_mode(path))
@@ -189,6 +192,7 @@ class SSHConnection:
desc=progress_title or os.path.basename(src),
disable=no_progress_bar,
bytes=True,
total=self.getsize(src),
) as pbar:
self.sftp.get(src, dest, callback=pbar.update_to)
@@ -221,7 +225,10 @@ class SSHConnection:
progress_title = posixpath.basename(dest)
with Tqdm(
desc=progress_title, disable=no_progress_bar, bytes=True
desc=progress_title,
disable=no_progress_bar,
bytes=True,
total=os.path.getsize(src),
) as pbar:
self.sftp.put(src, tmp_file, callback=pbar.update_to)
+79 -36
View File
@@ -1,3 +1,4 @@
import io
import logging
import os
import threading
@@ -7,12 +8,11 @@ from funcy import cached_property, nullcontext, wrap_prop
from dvc.config import ConfigError
from dvc.exceptions import DvcException
from dvc.hash_info import HashInfo
from dvc.path_info import HTTPURLInfo, WebDAVURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseTree
from .base import BaseFileSystem
from .http import ask_password
logger = logging.getLogger(__name__)
@@ -23,7 +23,7 @@ class WebDAVConnectionError(DvcException):
super().__init__(f"Unable to connect to WebDAV {host}.")
class WebDAVTree(BaseTree): # pylint:disable=abstract-method
class WebDAVFileSystem(BaseFileSystem): # pylint:disable=abstract-method
# Use webdav scheme
scheme = Schemes.WEBDAV
@@ -43,10 +43,11 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
CHUNK_SIZE = 2 ** 16
PARAM_CHECKSUM = "etag"
DETAIL_FIELDS = frozenset(("etag", "size"))
# Constructor
def __init__(self, repo, config):
# Call BaseTree constructor
# Call BaseFileSystem constructor
super().__init__(repo, config)
# Get username from configuration
@@ -81,24 +82,23 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
if self.user is None and self.path_info.user is not None:
self.user = self.path_info.user
# Construct hostname from path_info by stripping path
http_info = HTTPURLInfo(self.path_info.url)
self.hostname = http_info.replace(path="").url
# Webdav client
@wrap_prop(threading.Lock())
@cached_property
def _client(self):
from webdav3.client import Client
# Construct hostname from path_info by stripping path
http_info = HTTPURLInfo(self.path_info.url)
hostname = http_info.replace(path="").url
# Set password or ask for it
if self.ask_password and self.password is None and self.token is None:
host, user = self.path_info.host, self.path_info.user
self.password = ask_password(host, user)
self.password = ask_password(self.hostname, self.user)
# Setup webdav client options dictionary
options = {
"webdav_hostname": hostname,
"webdav_hostname": self.hostname,
"webdav_login": self.user,
"webdav_password": self.password,
"webdav_token": self.token,
@@ -113,34 +113,39 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
# Check whether client options are valid
if not client.valid():
raise ConfigError(
f"Configuration for WebDAV {hostname} is invalid."
f"Configuration for WebDAV {self.hostname} is invalid."
)
# Check whether connection is valid (root should always exist)
if not client.check(self.path_info.path):
raise WebDAVConnectionError(hostname)
if not client.check("/"):
raise WebDAVConnectionError(self.hostname)
return client
def open(self, path_info, mode="r", encoding=None, **kwargs):
from webdav3.exceptions import RemoteResourceNotFound
assert mode in {"r", "rt", "rb"}
fobj = io.BytesIO()
try:
self._client.download_from(buff=fobj, remote_path=path_info.path)
except RemoteResourceNotFound as exc:
raise FileNotFoundError from exc
fobj.seek(0)
if "mode" == "rb":
return fobj
return io.TextIOWrapper(fobj, encoding=encoding)
# Checks whether file/directory exists at remote
def exists(self, path_info, use_dvcignore=True):
# Use webdav check to test for file existence
return self._client.check(path_info.path)
# Gets file hash 'etag'
def get_file_hash(self, path_info):
# Use webdav client info method to get etag
etag = self._client.info(path_info.path)["etag"].strip('"')
# From HTTPTree
if not etag:
raise DvcException(
"could not find an ETag or "
"Content-MD5 header for '{url}'".format(url=path_info.url)
)
return HashInfo(self.PARAM_CHECKSUM, etag)
# Checks whether path points to directory
def isdir(self, path_info):
# Use webdav is_dir to test whether path points to a directory
@@ -170,6 +175,36 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
# Yield path info to non directory
yield info
def ls(
self, path_info, detail=False, recursive=False
): # pylint: disable=arguments-differ
dirs = deque([path_info.path])
while dirs:
for entry in self._client.list(dirs.pop(), get_info=True):
path = entry["path"]
if entry["isdir"]:
dirs.append(path)
continue
if detail:
yield {
"type": "file",
"name": path,
"size": entry["size"],
"etag": entry["etag"],
}
else:
yield path
if not recursive:
for entry in dirs:
if detail:
yield {"type": "directory", "name": entry}
else:
yield entry
return None
# Removes file/directory
def remove(self, path_info):
# Use webdav client clean (DELETE) method to remove file/directory
@@ -178,7 +213,7 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
# Creates directories
def makedirs(self, path_info):
# Terminate recursion
if path_info.path == self.path_info.path or self.exists(path_info):
if path_info.path == "/" or self.exists(path_info):
return
# Recursively descent to root
@@ -188,18 +223,24 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
self._client.mkdir(path_info.path)
# Moves file/directory at remote
def move(self, from_info, to_info, mode=None):
def move(self, from_info, to_info):
# Webdav client move
self._client.move(from_info.path, to_info.path)
def _upload_fobj(self, fobj, to_info):
# In contrast to other upload_fobj implementations, this one does not
# exactly do a chunked-upload but rather put everything in one request.
self.makedirs(to_info.parent)
self._client.upload_to(buff=fobj, remote_path=to_info.path)
# Downloads file from remote to file
def _download(self, from_info, to_file, name=None, no_progress_bar=False):
# Progress from HTTPTree
# Progress from HTTPFileSystem
with open(to_file, "wb") as fd:
with Tqdm.wrapattr(
fd,
"write",
total=None if no_progress_bar else self._file_size(from_info),
total=None if no_progress_bar else self.getsize(from_info),
leave=False,
desc=from_info.url if name is None else name,
disable=no_progress_bar,
@@ -235,7 +276,9 @@ class WebDAVTree(BaseTree): # pylint:disable=abstract-method
buff=fd_wrapped, remote_path=to_info.path
)
# Queries size of file at remote
def _file_size(self, path_info):
# Get file size from info dictionary and convert to int (from str)
return int(self._client.info(path_info.path)["size"])
def info(self, path_info):
info = self._client.info(path_info.path)
return {
"size": int(info["size"]),
"etag": info["etag"],
}
+7
View File
@@ -0,0 +1,7 @@
from dvc.scheme import Schemes
from .webdav import WebDAVFileSystem
class WebDAVSFileSystem(WebDAVFileSystem): # pylint:disable=abstract-method
scheme = Schemes.WEBDAVS
+36 -29
View File
@@ -1,5 +1,7 @@
import logging
import os
import posixpath
import shutil
import threading
from contextlib import contextmanager
from urllib.parse import urlparse
@@ -11,7 +13,7 @@ from dvc.path_info import CloudURLInfo
from dvc.progress import Tqdm
from dvc.scheme import Schemes
from .base import BaseTree
from .base import BaseFileSystem
logger = logging.getLogger(__name__)
@@ -28,11 +30,12 @@ def update_pbar(pbar, total):
return update
class WebHDFSTree(BaseTree):
class WebHDFSFileSystem(BaseFileSystem):
scheme = Schemes.WEBHDFS
PATH_CLS = CloudURLInfo
REQUIRES = {"hdfs": "hdfs"}
PARAM_CHECKSUM = "checksum"
TRAVERSE_PREFIX_LEN = 2
def __init__(self, repo, config):
super().__init__(repo, config)
@@ -42,16 +45,13 @@ class WebHDFSTree(BaseTree):
if not url:
return
parsed = urlparse(url)
user = parsed.username or config.get("user")
self.path_info = self.PATH_CLS(url)
self.path_info = self.PATH_CLS.from_parts(
scheme="webhdfs",
host=parsed.hostname,
user=user,
port=parsed.port,
path=parsed.path,
)
parsed = urlparse(url)
self.host = parsed.hostname
self.user = parsed.username or config.get("user")
self.port = parsed.port
self.hdfscli_config = config.get("hdfscli_config")
self.token = config.get("webhdfs_token")
@@ -62,7 +62,6 @@ class WebHDFSTree(BaseTree):
def hdfs_client(self):
import hdfs
logger.debug("URL: %s", self.path_info)
logger.debug("HDFSConfig: %s", self.hdfscli_config)
try:
@@ -79,35 +78,35 @@ class WebHDFSTree(BaseTree):
if not any(err in exc_msg for err in errors):
raise
http_url = f"http://{self.path_info.host}:{self.path_info.port}"
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.path_info.user, root="/"
http_url, user=self.user, root="/"
)
return client
@contextmanager
def open(self, path_info, mode="r", encoding=None):
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.read()
yield reader
def walk_files(self, path_info, **kwargs):
if not self.exists(path_info):
return
root = path_info.path
for path, _, files in self.hdfs_client.walk(root):
for file_ in files:
path = os.path.join(path, file_)
yield path_info.replace(path=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:
@@ -122,22 +121,30 @@ class WebHDFSTree(BaseTree):
status = self.hdfs_client.status(path_info.path, strict=False)
return status is not None
def get_file_hash(self, path_info):
checksum = self.hdfs_client.checksum(path_info.path)
hash_info = HashInfo(self.PARAM_CHECKSUM, checksum["bytes"])
def info(self, path_info):
st = self.hdfs_client.status(path_info.path)
return {"size": st["length"]}
hash_info.size = self.hdfs_client.status(path_info.path)["length"]
return hash_info
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:
content = reader.read()
self.hdfs_client.write(to_info.path, data=content)
with self.hdfs_client.write(to_info.path) as writer:
shutil.copyfileobj(reader, writer)
def move(self, from_info, to_info, mode=None):
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):
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
):
@@ -155,7 +162,7 @@ class WebHDFSTree(BaseTree):
def _download(
self, from_info, to_file, name=None, no_progress_bar=False, **_kwargs
):
total = self.hdfs_client.status(from_info.path)["length"]
total = self.getsize(from_info)
with Tqdm(
desc=name, total=total, disable=no_progress_bar, bytes=True
) as pbar:
+3
View File
@@ -21,6 +21,9 @@ class HashInfo:
def __bool__(self):
return bool(self.value)
def __str__(self):
return f"{self.name}: {self.value}"
@classmethod
def from_dict(cls, d):
_d = d.copy() if d else {}
+89 -81
View File
@@ -49,13 +49,11 @@ class DvcIgnorePatterns(DvcIgnore):
]
@classmethod
def from_files(cls, ignore_file_path, tree):
def from_files(cls, ignore_file_path, fs):
assert os.path.isabs(ignore_file_path)
dirname = os.path.normpath(os.path.dirname(ignore_file_path))
ignore_file_rel_path = os.path.relpath(
ignore_file_path, tree.tree_root
)
with tree.open(ignore_file_path, encoding="utf-8") as fobj:
ignore_file_rel_path = os.path.relpath(ignore_file_path, fs.fs_root)
with fs.open(ignore_file_path, encoding="utf-8") as fobj:
path_spec_lines = [
PatternInfo(
line,
@@ -91,45 +89,50 @@ class DvcIgnorePatterns(DvcIgnore):
path = normalize_file(path)
return path
def matches(self, dirname, basename, is_dir=False):
def matches(self, dirname, basename, is_dir=False, details: bool = False):
path = self._get_normalize_path(dirname, basename)
if not path:
return False
if details:
return self._ignore_details(path, is_dir)
return self.ignore(path, is_dir)
def ignore(self, path, is_dir):
def matches(pattern, path, is_dir) -> bool:
matches_ = bool(pattern.match(path))
if is_dir:
matches_ |= bool(pattern.match(f"{path}/"))
return matches_
result = False
if is_dir:
path_dir = f"{path}/"
for ignore, pattern in self.ignore_spec:
if pattern.match(path) or pattern.match(path_dir):
result = ignore
else:
for ignore, pattern in self.ignore_spec:
if pattern.match(path):
result = ignore
for ignore, pattern in self.ignore_spec[::-1]:
if matches(pattern, path, is_dir):
result = ignore
break
return result
def match_details(self, dirname, basename, is_dir=False):
path = self._get_normalize_path(dirname, basename)
if not path:
return False
return self._ignore_details(path, is_dir)
def _ignore_details(self, path, is_dir):
def _ignore_details(self, path, is_dir: bool):
result = []
for ignore, pattern in zip(self.regex_pattern_list, self.pattern_list):
regex = re.compile(ignore[0])
for (regex, _), pattern_info in list(
zip(self.regex_pattern_list, self.pattern_list)
):
# skip system pattern
if not pattern.file_info:
if not pattern_info.file_info:
continue
regex = re.compile(regex)
matches = bool(regex.match(path))
if is_dir:
path_dir = f"{path}/"
if regex.match(path) or regex.match(path_dir):
result.append(pattern.file_info)
else:
if regex.match(path):
result.append(pattern.file_info)
matches |= bool(regex.match(f"{path}/"))
if matches:
result.append(pattern_info.file_info)
return result
def __hash__(self):
@@ -157,7 +160,7 @@ def _no_match(path):
class DvcIgnoreFilterNoop:
def __init__(self, tree, root_dir):
def __init__(self, fs, root_dir):
pass
def __call__(self, root, dirs, files, **kwargs):
@@ -177,39 +180,35 @@ class DvcIgnoreFilterNoop:
class DvcIgnoreFilter:
@staticmethod
def _is_dvc_repo(root, directory):
from dvc.repo import Repo
return os.path.isdir(os.path.join(root, directory, Repo.DVC_DIR))
def __init__(self, tree, root_dir):
def __init__(self, fs, root_dir):
from dvc.repo import Repo
default_ignore_patterns = [".hg/", ".git/", "{}/".format(Repo.DVC_DIR)]
self.tree = tree
self.fs = fs
self.root_dir = root_dir
self.ignores_trie_tree = PathStringTrie()
self.ignores_trie_tree[root_dir] = DvcIgnorePatterns(
self.ignores_trie_fs = PathStringTrie()
self.ignores_trie_fs[root_dir] = DvcIgnorePatterns(
default_ignore_patterns, root_dir
)
self._ignored_subrepos = PathStringTrie()
self._update(self.root_dir)
def _update(self, dirname):
old_pattern = self.ignores_trie_tree.longest_prefix(dirname).value
self._update_sub_repo(dirname)
old_pattern = self.ignores_trie_fs.longest_prefix(dirname).value
matches = old_pattern.matches(dirname, DvcIgnore.DVCIGNORE_FILE, False)
ignore_file_path = os.path.join(dirname, DvcIgnore.DVCIGNORE_FILE)
if not matches and self.tree.exists(
if not matches and self.fs.exists(
ignore_file_path, use_dvcignore=False
):
new_pattern = DvcIgnorePatterns.from_files(
ignore_file_path, self.tree
ignore_file_path, self.fs
)
if old_pattern:
self.ignores_trie_tree[dirname] = DvcIgnorePatterns(
self.ignores_trie_fs[dirname] = DvcIgnorePatterns(
*merge_patterns(
old_pattern.pattern_list,
old_pattern.dirname,
@@ -218,39 +217,43 @@ class DvcIgnoreFilter:
)
)
else:
self.ignores_trie_tree[dirname] = new_pattern
self.ignores_trie_fs[dirname] = new_pattern
elif old_pattern:
self.ignores_trie_tree[dirname] = old_pattern
self.ignores_trie_fs[dirname] = old_pattern
# NOTE: using `walk` + `break` because tree doesn't have `listdir()`
for root, dirs, _ in self.tree.walk(dirname, use_dvcignore=False):
self._update_sub_repo(root, dirs)
break
def _update_sub_repo(self, path):
from dvc.repo import Repo
def _update_sub_repo(self, root, dirs):
for d in dirs:
if self._is_dvc_repo(root, d):
self._ignored_subrepos[root] = self._ignored_subrepos.get(
root, set()
) | {d}
pattern_info = PatternInfo(
f"/{d}/", "in sub_repo:{}".format(d)
if path == self.root_dir:
return
dvc_dir = os.path.join(path, Repo.DVC_DIR)
if not os.path.exists(dvc_dir):
return
root, dname = os.path.split(path)
self._ignored_subrepos[root] = self._ignored_subrepos.get(
root, set()
) | {dname}
pattern_info = PatternInfo(f"/{dname}/", f"in sub_repo:{dname}")
new_pattern = DvcIgnorePatterns([pattern_info], root)
old_pattern = self.ignores_trie_fs.longest_prefix(root).value
if old_pattern:
self.ignores_trie_fs[root] = DvcIgnorePatterns(
*merge_patterns(
old_pattern.pattern_list,
old_pattern.dirname,
new_pattern.pattern_list,
new_pattern.dirname,
)
new_pattern = DvcIgnorePatterns([pattern_info], root)
old_pattern = self.ignores_trie_tree.longest_prefix(root).value
if old_pattern:
self.ignores_trie_tree[root] = DvcIgnorePatterns(
*merge_patterns(
old_pattern.pattern_list,
old_pattern.dirname,
new_pattern.pattern_list,
new_pattern.dirname,
)
)
else:
self.ignores_trie_tree[root] = new_pattern
)
else:
self.ignores_trie_fs[root] = new_pattern
def __call__(self, root, dirs, files, ignore_subrepos=True):
for dname in dirs:
self._update_sub_repo(os.path.join(root, dname))
ignore_pattern = self._get_trie_pattern(root)
if ignore_pattern:
dirs, files = ignore_pattern(root, dirs, files)
@@ -259,11 +262,11 @@ class DvcIgnoreFilter:
return dirs, files
def _get_trie_pattern(self, dirname):
ignore_pattern = self.ignores_trie_tree.get(dirname)
ignore_pattern = self.ignores_trie_fs.get(dirname)
if ignore_pattern:
return ignore_pattern
prefix = self.ignores_trie_tree.longest_prefix(dirname).key
prefix = self.ignores_trie_fs.longest_prefix(dirname).key
if not prefix:
# outside of the repo
return None
@@ -280,7 +283,7 @@ class DvcIgnoreFilter:
for parent in dirs:
self._update(parent)
return self.ignores_trie_tree.get(dirname)
return self.ignores_trie_fs.get(dirname)
def _is_ignored(self, path, is_dir=False):
if self._outside_repo(path):
@@ -289,8 +292,7 @@ class DvcIgnoreFilter:
ignore_pattern = self._get_trie_pattern(dirname)
if ignore_pattern:
return ignore_pattern.matches(dirname, basename, is_dir)
else:
return False
return False
def _is_subrepo(self, path):
dirname, basename = os.path.split(os.path.normpath(path))
@@ -324,13 +326,15 @@ class DvcIgnoreFilter:
return False
def check_ignore(self, target):
# NOTE: can only be used in `dvc check-ignore`, see
# https://github.com/iterative/dvc/issues/5046
full_target = os.path.abspath(target)
if not self._outside_repo(full_target):
dirname, basename = os.path.split(os.path.normpath(full_target))
pattern = self._get_trie_pattern(dirname)
if pattern:
matches = pattern.match_details(
dirname, basename, os.path.isdir(full_target)
matches = pattern.matches(
dirname, basename, os.path.isdir(full_target), True,
)
if matches:
@@ -340,7 +344,11 @@ class DvcIgnoreFilter:
def is_ignored(self, path):
# NOTE: can't use self.check_ignore(path).match for now, see
# https://github.com/iterative/dvc/issues/4555
return self.is_ignored_dir(path) or self.is_ignored_file(path)
if os.path.isfile(path):
return self.is_ignored_file(path)
if os.path.isdir(path):
return self.is_ignored_dir(path)
return self.is_ignored_file(path) or self.is_ignored_dir(path)
def init(path):
+17 -21
View File
@@ -4,20 +4,17 @@ import pathlib
import platform
import uuid
import psutil
from dvc.exceptions import DvcException, NotDvcRepoError
from dvc.fs import FS_MAP, get_fs_cls, get_fs_config
from dvc.repo import Repo
from dvc.scm.base import SCMError
from dvc.system import System
from dvc.tree import TREES, get_tree_cls, get_tree_config
from dvc.utils import error_link
from dvc.utils.pkg import PKG
from dvc.version import __version__
try:
import psutil
except ImportError:
psutil = None
if PKG is None:
package = ""
else:
@@ -40,17 +37,16 @@ def get_dvc_info():
# can't auto-create it, as it might cause issues if the user
# later decides to enable shared cache mode with
# `dvc config cache.shared group`.
if os.path.exists(repo.cache.local.cache_dir):
if os.path.exists(repo.odb.local.cache_dir):
info.append(
"Cache types: {}".format(_get_linktype_support_info(repo))
)
if psutil:
fs_type = get_fs_type(repo.cache.local.cache_dir)
info.append(f"Cache directory: {fs_type}")
fs_type = get_fs_type(repo.odb.local.cache_dir)
info.append(f"Cache directory: {fs_type}")
else:
info.append("Cache types: " + error_link("no-dvc-cache"))
info.append(f"Caches: {_get_caches(repo.cache)}")
info.append(f"Caches: {_get_caches(repo.odb)}")
info.append(f"Remotes: {_get_remotes(repo.config)}")
@@ -60,9 +56,8 @@ def get_dvc_info():
info.append("Repo: dvc, git (broken)")
else:
root_directory = repo.root_dir
if psutil:
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
fs_root = get_fs_type(os.path.abspath(root_directory))
info.append(f"Workspace directory: {fs_root}")
info.append("Repo: {}".format(_get_dvc_repo_info(repo)))
return "\n".join(info)
@@ -80,7 +75,7 @@ def _get_caches(cache):
def _get_remotes(config):
schemes = (
get_tree_cls(get_tree_config(config, name=remote)).scheme
get_fs_cls(get_fs_config(config, name=remote)).scheme
for remote in config["remote"]
)
@@ -96,7 +91,7 @@ def _get_linktype_support_info(repo):
}
fname = "." + str(uuid.uuid4())
src = os.path.join(repo.cache.local.cache_dir, fname)
src = os.path.join(repo.odb.local.cache_dir, fname)
open(src, "w").close()
dst = os.path.join(repo.root_dir, fname)
@@ -122,11 +117,11 @@ def _get_linktype_support_info(repo):
def _get_supported_remotes():
supported_remotes = []
for tree_cls in TREES:
if not tree_cls.get_missing_deps():
supported_remotes.append(tree_cls.scheme)
for scheme, fs_cls in FS_MAP.items():
if not fs_cls.get_missing_deps():
supported_remotes.append(scheme)
if len(supported_remotes) == len(TREES):
if len(supported_remotes) == len(FS_MAP):
return "All remotes"
if len(supported_remotes) == 1:
@@ -142,7 +137,8 @@ def get_fs_type(path):
for part in psutil.disk_partitions(all=True)
}
path = pathlib.Path(path)
# need to follow the symlink: https://github.com/iterative/dvc/issues/5065
path = pathlib.Path(path).resolve()
for parent in itertools.chain([path], path.parents):
if parent in partition:
+12 -13
View File
@@ -7,19 +7,7 @@
TEXT_CHARS = bytes(range(32, 127)) + b"\n\r\t\f\b"
def istextfile(fname, blocksize=512, tree=None):
""" Uses heuristics to guess whether the given file is text or binary,
by reading a single block of bytes from the file.
If more than 30% of the chars in the block are non-text, or there
are NUL ('\x00') bytes in the block, assume this is a binary file.
"""
if tree:
open_func = tree.open
else:
open_func = open
with open_func(fname, "rb") as fobj:
block = fobj.read(blocksize)
def istextblock(block):
if not block:
# An empty file is considered a valid text file
return True
@@ -32,3 +20,14 @@ def istextfile(fname, blocksize=512, tree=None):
# occurrences of TEXT_CHARS from the block
nontext = block.translate(None, TEXT_CHARS)
return float(len(nontext)) / len(block) <= 0.30
def istextfile(fname, fs, blocksize=512):
""" Uses heuristics to guess whether the given file is text or binary,
by reading a single block of bytes from the file.
If more than 30% of the chars in the block are non-text, or there
are NUL ('\x00') bytes in the block, assume this is a binary file.
"""
with fs.open(fname, "rb") as fobj:
block = fobj.read(blocksize)
return istextblock(block)
+1 -2
View File
@@ -148,9 +148,8 @@ class HardlinkLock(flufl.lock.Lock, LockBase):
): # pylint: disable=super-init-not-called
import socket
super().__init__(lockfile)
self._tmp_dir = tmp_dir
super().__init__(lockfile)
# NOTE: this is basically Lock.__init__ copy-paste, except that
# instead of using `socket.getfqdn()` we use `socket.gethostname()`
+14 -44
View File
@@ -1,17 +1,11 @@
"""Main entry point for dvc CLI."""
import errno
import logging
from contextlib import contextmanager
from dvc import analytics
from dvc._debug import debugtools
from dvc.cli import parse_args
from dvc.config import ConfigError
from dvc.exceptions import DvcException, DvcParserError, NotDvcRepoError
from dvc.external_repo import clean_repos
from dvc.logger import FOOTER, disable_other_loggers
from dvc.tree.pool import close_pools
from dvc.utils import error_link
# Workaround for CPython bug. See [1] and [2] for more info.
# [1] https://github.com/aws/aws-cli/blob/1.16.277/awscli/clidriver.py#L55
@@ -22,39 +16,6 @@ from dvc.utils import error_link
logger = logging.getLogger("dvc")
@contextmanager
def profile(enable, dump):
if not enable:
yield
return
import cProfile
prof = cProfile.Profile()
prof.enable()
yield
prof.disable()
if not dump:
prof.print_stats(sort="cumtime")
return
prof.dump_stats(dump)
@contextmanager
def debug(enable):
try:
yield
return
except Exception:
if enable:
import pdb # noqa: T100
pdb.post_mortem()
raise
def main(argv=None): # noqa: C901
"""Run dvc CLI command.
@@ -84,10 +45,9 @@ def main(argv=None): # noqa: C901
logger.trace(args)
with profile(enable=args.cprofile, dump=args.cprofile_dump):
with debug(args.pdb):
cmd = args.func(args)
ret = cmd.run()
with debugtools(args):
cmd = args.func(args)
ret = cmd.run()
except ConfigError:
logger.exception("configuration error")
ret = 251
@@ -104,7 +64,11 @@ def main(argv=None): # noqa: C901
logger.exception("")
except Exception as exc: # noqa, pylint: disable=broad-except
# pylint: disable=no-member
import errno
if isinstance(exc, OSError) and exc.errno == errno.EMFILE:
from dvc.utils import error_link
logger.exception(
"too many open files, please visit "
"{} to see how to handle this "
@@ -123,6 +87,8 @@ def main(argv=None): # noqa: C901
ret = 255
try:
from dvc import analytics
if analytics.is_enabled():
analytics.collect_and_send_report(args, ret)
@@ -130,11 +96,15 @@ def main(argv=None): # noqa: C901
finally:
logger.setLevel(outerLogLevel)
from dvc.fs.pool import close_pools
# Closing pools by-hand to prevent weird messages when closing SSH
# connections. See https://github.com/iterative/dvc/issues/3248 for
# more info.
close_pools()
from dvc.external_repo import clean_repos
# Remove cached repos in the end of the call, these are anonymous
# so won't be reused by any other subsequent run anyway.
clean_repos()
+315
View File
@@ -0,0 +1,315 @@
import itertools
import json
import logging
from concurrent import futures
from dvc.dir_info import DirInfo
from dvc.exceptions import DvcException
from dvc.progress import Tqdm
from .stage import get_hash
logger = logging.getLogger(__name__)
class ObjectError(DvcException):
pass
class ObjectFormatError(ObjectError):
pass
class HashFile:
def __init__(self, path_info, fs, hash_info):
self.path_info = path_info
self.fs = fs
self.hash_info = hash_info
def __len__(self):
return 1
def __str__(self):
return f"object {self.hash_info}"
def __bool__(self):
return bool(self.hash_info)
def check(self, odb):
actual = get_hash(self.path_info, self.fs, odb.fs.PARAM_CHECKSUM, odb)
logger.trace(
"cache '%s' expected '%s' actual '%s'",
self.path_info,
self.hash_info,
actual,
)
assert actual.name == self.hash_info.name
if actual.value.split(".")[0] != self.hash_info.value.split(".")[0]:
raise ObjectFormatError(f"{self} is corrupted")
@classmethod
def load(cls, odb, hash_info):
return odb.get(hash_info)
def save(self, odb, **kwargs):
odb.add(self.path_info, self.fs, self.hash_info, **kwargs)
class File(HashFile):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.src = None
@classmethod
def stage(cls, odb, path_info, fs, **kwargs):
hash_info = get_hash(
path_info, fs, odb.fs.PARAM_CHECKSUM, odb, **kwargs
)
raw = odb.get(hash_info)
obj = cls(raw.path_info, raw.fs, hash_info)
obj.src = HashFile(path_info, fs, hash_info)
return obj
def save(self, odb, **kwargs):
self.src.save(odb, **kwargs)
class Tree(HashFile):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.src_hash_info = None
self.src_path_info = None
self.src_fs = None
def __len__(self):
return self.hash_info.dir_info.nfiles
def __iter__(self):
yield from self.hash_info.dir_info.items()
@classmethod
def save_dir_info(cls, odb, dir_info, hash_info=None):
if hash_info and hash_info.name == odb.fs.PARAM_CHECKSUM:
try:
odb.check(hash_info)
assert hash_info.dir_info == dir_info
return hash_info
except (FileNotFoundError, ObjectFormatError):
pass
from dvc.fs.memory import MemoryFileSystem
from dvc.path_info import PathInfo
from dvc.utils import tmp_fname
fs = MemoryFileSystem(None, {})
path_info = PathInfo(tmp_fname(""))
with fs.open(path_info, "w") as fobj:
json.dump(dir_info.to_list(), fobj, sort_keys=True)
tmp_info = odb.fs.path_info / tmp_fname("")
with fs.open(path_info, "rb") as fobj:
odb.fs.upload_fobj(fobj, tmp_info)
hash_info = get_hash(tmp_info, odb.fs, odb.fs.PARAM_CHECKSUM, odb)
hash_info.value += odb.fs.CHECKSUM_DIR_SUFFIX
hash_info.dir_info = dir_info
hash_info.nfiles = dir_info.nfiles
odb.add(tmp_info, odb.fs, hash_info)
return hash_info
@classmethod
def stage(cls, odb, path_info, fs, **kwargs):
hash_info = get_hash(
path_info, fs, odb.fs.PARAM_CHECKSUM, odb, **kwargs
)
hi = cls.save_dir_info(odb, hash_info.dir_info, hash_info)
hi.size = hash_info.size
raw = odb.get(hi)
obj = cls(raw.path_info, raw.fs, hi)
obj.src_hash_info = hash_info
obj.src_path_info = path_info
obj.src_fs = fs
return obj
@classmethod
def load(cls, odb, hash_info):
obj = odb.get(hash_info)
try:
with obj.fs.open(obj.path_info, "r") as fobj:
raw = json.load(fobj)
except ValueError as exc:
raise ObjectFormatError(f"{obj} is corrupted") from exc
if not isinstance(raw, list):
logger.error(
"dir cache file format error '%s' [skipping the file]",
obj.path_info,
)
raise ObjectFormatError(f"{obj} is corrupted")
dir_info = DirInfo.from_list(raw)
hash_info.dir_info = dir_info
hash_info.nfiles = dir_info.nfiles
return cls(obj.path_info, obj.fs, hash_info)
def save(self, odb, **kwargs):
assert self.src_hash_info.dir_info
hi = self.save_dir_info(
odb, self.src_hash_info.dir_info, self.hash_info
)
for entry_key, entry_hash in Tqdm(
hi.dir_info.items(),
desc="Saving " + self.src_path_info.name,
unit="file",
):
entry_info = self.src_path_info.joinpath(*entry_key)
entry_obj = HashFile(entry_info, self.src_fs, entry_hash)
entry_obj.save(odb, **kwargs)
self.src_fs.repo.state.save(self.src_path_info, self.src_fs, hi)
def filter(self, odb, prefix):
hash_info = self.hash_info.dir_info.get(prefix)
if hash_info:
return load(odb, hash_info)
depth = len(prefix)
dir_info = DirInfo()
try:
for key, value in self.hash_info.dir_info.trie.items(prefix):
dir_info.add(key[depth:], value)
except KeyError:
return None
return load(odb, self.save_dir_info(odb, dir_info))
def save(odb, obj, **kwargs):
obj.save(odb, **kwargs)
def check(odb, obj):
odb.check(obj.hash_info)
if isinstance(obj, Tree):
for _, hash_info in obj:
odb.check(hash_info)
def load(odb, hash_info):
if hash_info.isdir:
return Tree.load(odb, hash_info)
return File.load(odb, hash_info)
def _get_dir_size(odb, dir_info):
try:
return sum(
odb.fs.getsize(odb.hash_to_path_info(hi.value))
for _, hi in dir_info.items()
)
except FileNotFoundError:
return None
def merge(odb, ancestor_info, our_info, their_info):
assert our_info
assert their_info
if ancestor_info:
ancestor = load(odb, ancestor_info).hash_info.dir_info
else:
ancestor = DirInfo()
our = load(odb, our_info).hash_info.dir_info
their = load(odb, their_info).hash_info.dir_info
merged = our.merge(ancestor, their)
hash_info = Tree.save_dir_info(odb, merged)
hash_info.size = _get_dir_size(odb, merged)
return hash_info
def _transfer_file(odb, from_fs, from_info):
from dvc.utils import tmp_fname
from dvc.utils.stream import HashedStreamReader
tmp_info = odb.fs.path_info / tmp_fname()
with from_fs.open(
from_info, mode="rb", chunk_size=from_fs.CHUNK_SIZE
) as stream:
stream_reader = HashedStreamReader(stream)
# Since we don't know the hash beforehand, we'll
# upload it to a temporary location and then move
# it.
odb.fs.upload_fobj(
stream_reader,
tmp_info,
total=from_fs.getsize(from_info),
desc=from_info.name,
)
hash_info = stream_reader.hash_info
return tmp_info, hash_info
def _transfer_directory_contents(odb, from_fs, from_info, jobs, pbar):
rel_path_infos = {}
from_infos = from_fs.walk_files(from_info)
def create_tasks(executor, amount):
for entry_info in itertools.islice(from_infos, amount):
pbar.total += 1
task = executor.submit(
pbar.wrap_fn(_transfer_file), odb, from_fs, entry_info
)
rel_path_infos[task] = entry_info.relative_to(from_info)
yield task
pbar.total = 0
with futures.ThreadPoolExecutor(max_workers=jobs) as executor:
tasks = set(create_tasks(executor, jobs * 5))
while tasks:
done, tasks = futures.wait(
tasks, return_when=futures.FIRST_COMPLETED
)
tasks.update(create_tasks(executor, len(done)))
for task in done:
yield rel_path_infos.pop(task), task.result()
def _transfer_directory(odb, from_fs, from_info, jobs, no_progress_bar=False):
dir_info = DirInfo()
with Tqdm(total=1, unit="Files", disable=no_progress_bar) as pbar:
for (
entry_info,
(entry_tmp_info, entry_hash),
) in _transfer_directory_contents(odb, from_fs, from_info, jobs, pbar):
odb.add(entry_tmp_info, odb.fs, entry_hash)
dir_info.add(entry_info.parts, entry_hash)
return Tree.save_dir_info(odb, dir_info)
def transfer(odb, from_fs, from_info, jobs=None, no_progress_bar=False):
jobs = jobs or min((from_fs.jobs, odb.fs.jobs))
if from_fs.isdir(from_info):
return _transfer_directory(
odb,
from_fs,
from_info,
jobs=jobs,
no_progress_bar=no_progress_bar,
)
tmp_info, hash_info = _transfer_file(odb, from_fs, from_info)
odb.add(tmp_info, odb.fs, hash_info)
return hash_info
@@ -1,40 +1,37 @@
"""Manages cache of a DVC repo."""
from collections import defaultdict
from ..scheme import Schemes
from dvc.scheme import Schemes
def get_cloud_cache(tree):
from .base import CloudCache
from .local import LocalCache
from .ssh import SSHCache
def get_odb(fs):
from .base import ObjectDB
from .gdrive import GDriveObjectDB
from .local import LocalObjectDB
from .ssh import SSHObjectDB
if tree.scheme == Schemes.LOCAL:
return LocalCache(tree)
if fs.scheme == Schemes.LOCAL:
return LocalObjectDB(fs)
if tree.scheme == Schemes.SSH:
return SSHCache(tree)
if fs.scheme == Schemes.SSH:
return SSHObjectDB(fs)
return CloudCache(tree)
if fs.scheme == Schemes.GDRIVE:
return GDriveObjectDB(fs)
return ObjectDB(fs)
def _get_cache(repo, settings):
from ..tree import get_cloud_tree
def _get_odb(repo, settings):
from dvc.fs import get_cloud_fs
if not settings:
return None
tree = get_cloud_tree(repo, **settings)
return get_cloud_cache(tree)
fs = get_cloud_fs(repo, **settings)
return get_odb(fs)
class Cache:
"""Class that manages cache locations of a DVC repo.
Args:
repo (dvc.repo.Repo): repo instance that this cache belongs to.
"""
class ODBManager:
CACHE_DIR = "cache"
CLOUD_SCHEMES = [
Schemes.S3,
@@ -47,7 +44,7 @@ class Cache:
def __init__(self, repo):
self.repo = repo
self.config = config = repo.config["cache"]
self._cache = {}
self._odb = {}
local = config.get("local")
@@ -56,28 +53,33 @@ class Cache:
elif "dir" not in config:
settings = None
else:
from ..config import LOCAL_COMMON
from dvc.config_schema import LOCAL_COMMON
settings = {"url": config["dir"]}
for opt in LOCAL_COMMON.keys():
if opt in config:
settings[str(opt)] = config.get(opt)
self._cache[Schemes.LOCAL] = _get_cache(repo, settings)
self._odb[Schemes.LOCAL] = _get_odb(repo, settings)
for scheme in self.CLOUD_SCHEMES:
def _init_odb(self, schemes):
for scheme in schemes:
remote = self.config.get(scheme)
settings = {"name": remote} if remote else None
self._cache[scheme] = _get_cache(repo, settings)
self._odb[scheme] = _get_odb(self.repo, settings)
def __getattr__(self, name):
if name not in self._odb and name in self.CLOUD_SCHEMES:
self._init_odb([name])
try:
return self._cache[name]
return self._odb[name]
except KeyError as exc:
raise AttributeError from exc
def by_scheme(self):
yield from self._cache.items()
self._init_odb(self.CLOUD_SCHEMES)
yield from self._odb.items()
class NamedCacheItem:
+412
View File
@@ -0,0 +1,412 @@
import itertools
import logging
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from typing import Optional
from dvc.progress import Tqdm
from .. import HashFile, ObjectFormatError
logger = logging.getLogger(__name__)
class ObjectDB:
DEFAULT_VERIFY = False
DEFAULT_CACHE_TYPES = ["copy"]
CACHE_MODE: Optional[int] = None
def __init__(self, fs):
self.fs = fs
self.repo = fs.repo
self.verify = fs.config.get("verify", self.DEFAULT_VERIFY)
self.cache_types = fs.config.get("type") or copy(
self.DEFAULT_CACHE_TYPES
)
self.cache_type_confirmed = False
def move(self, from_info, to_info):
self.fs.move(from_info, to_info)
def makedirs(self, path_info):
self.fs.makedirs(path_info)
def get(self, hash_info):
""" get raw object """
return HashFile(
# Prefer string path over PathInfo when possible due to performance
self.hash_to_path(hash_info.value),
self.fs,
hash_info,
)
def add(self, path_info, fs, hash_info, **kwargs):
try:
self.check(hash_info)
return
except (ObjectFormatError, FileNotFoundError):
pass
cache_info = self.hash_to_path_info(hash_info.value)
# using our makedirs to create dirs with proper permissions
self.makedirs(cache_info.parent)
if isinstance(fs, type(self.fs)):
self.fs.move(path_info, cache_info)
else:
with fs.open(path_info, mode="rb") as fobj:
self.fs.upload_fobj(fobj, cache_info)
self.protect(cache_info)
self.fs.repo.state.save(cache_info, self.fs, hash_info)
callback = kwargs.get("download_callback")
if callback:
callback(1)
def hash_to_path_info(self, hash_):
return self.fs.path_info / hash_[0:2] / hash_[2:]
# Override to return path as a string instead of PathInfo for clouds
# which support string paths (see local)
def hash_to_path(self, hash_):
return self.hash_to_path_info(hash_)
def protect(self, path_info): # pylint: disable=unused-argument
pass
def is_protected(self, path_info): # pylint: disable=unused-argument
return False
def unprotect(self, path_info): # pylint: disable=unused-argument
pass
def set_exec(self, path_info): # pylint: disable=unused-argument
pass
def check(self, hash_info):
"""Compare the given hash with the (corresponding) actual one.
- Use `State` as a cache for computed hashes
+ The entries are invalidated by taking into account the following:
* mtime
* inode
* size
* hash
- Remove the file from cache if it doesn't match the actual hash
"""
obj = self.get(hash_info)
if self.is_protected(obj.path_info):
logger.trace(
"Assuming '%s' is unchanged since it is read-only",
obj.path_info,
)
return
try:
obj.check(self)
except ObjectFormatError:
logger.warning("corrupted cache file '%s'.", obj.path_info)
self.fs.remove(obj.path_info)
raise
# making cache file read-only so we don't need to check it
# next time
self.protect(obj.path_info)
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
if len(prefix) > 2:
path_info = self.fs.path_info / prefix[:2] / prefix[2:]
else:
path_info = self.fs.path_info / prefix[:2]
prefix = True
else:
path_info = self.fs.path_info
prefix = False
if progress_callback:
for file_info in self.fs.walk_files(path_info, prefix=prefix):
progress_callback()
yield file_info.path
else:
yield from self.fs.walk_files(path_info, prefix=prefix)
def _path_to_hash(self, path):
parts = self.fs.PATH_CLS(path).parts[-2:]
if not (len(parts) == 2 and parts[0] and len(parts[0]) == 2):
raise ValueError(f"Bad cache file path '{path}'")
return "".join(parts)
def list_hashes(self, prefix=None, progress_callback=None):
"""Iterate over hashes in this fs.
If `prefix` is specified, only hashes which begin with `prefix`
will be returned.
"""
for path in self._list_paths(prefix, progress_callback):
try:
yield self._path_to_hash(path)
except ValueError:
logger.debug(
"'%s' doesn't look like a cache file, skipping", path
)
def _hashes_with_limit(self, limit, prefix=None, progress_callback=None):
count = 0
for hash_ in self.list_hashes(prefix, progress_callback):
yield hash_
count += 1
if count > limit:
logger.debug(
"`list_hashes()` returned max '{}' hashes, "
"skipping remaining results".format(limit)
)
return
def _max_estimation_size(self, hashes):
# Max remote size allowed for us to use traverse method
return max(
self.fs.TRAVERSE_THRESHOLD_SIZE,
len(hashes)
/ self.fs.TRAVERSE_WEIGHT_MULTIPLIER
* self.fs.LIST_OBJECT_PAGE_SIZE,
)
def _estimate_remote_size(self, hashes=None, name=None):
"""Estimate fs size based on number of entries beginning with
"00..." prefix.
"""
prefix = "0" * self.fs.TRAVERSE_PREFIX_LEN
total_prefixes = pow(16, self.fs.TRAVERSE_PREFIX_LEN)
if hashes:
max_hashes = self._max_estimation_size(hashes)
else:
max_hashes = None
with Tqdm(
desc="Estimating size of "
+ (f"cache in '{name}'" if name else "remote cache"),
unit="file",
) as pbar:
def update(n=1):
pbar.update(n * total_prefixes)
if max_hashes:
hashes = self._hashes_with_limit(
max_hashes / total_prefixes, prefix, update
)
else:
hashes = self.list_hashes(prefix, update)
remote_hashes = set(hashes)
if remote_hashes:
remote_size = total_prefixes * len(remote_hashes)
else:
remote_size = total_prefixes
logger.debug(f"Estimated remote size: {remote_size} files")
return remote_size, remote_hashes
def list_hashes_traverse(
self, remote_size, remote_hashes, jobs=None, name=None
):
"""Iterate over all hashes found in this fs.
Hashes are fetched in parallel according to prefix, except in
cases where the remote size is very small.
All hashes from the remote (including any from the size
estimation step passed via the `remote_hashes` argument) will be
returned.
NOTE: For large remotes the list of hashes will be very
big(e.g. 100M entries, md5 for each is 32 bytes, so ~3200Mb list)
and we don't really need all of it at the same time, so it makes
sense to use a generator to gradually iterate over it, without
keeping all of it in memory.
"""
num_pages = remote_size / self.fs.LIST_OBJECT_PAGE_SIZE
if num_pages < 256 / self.fs.JOBS:
# Fetching prefixes in parallel requires at least 255 more
# requests, for small enough remotes it will be faster to fetch
# entire cache without splitting it into prefixes.
#
# NOTE: this ends up re-fetching hashes that were already
# fetched during remote size estimation
traverse_prefixes = [None]
initial = 0
else:
yield from remote_hashes
initial = len(remote_hashes)
traverse_prefixes = [f"{i:02x}" for i in range(1, 256)]
if self.fs.TRAVERSE_PREFIX_LEN > 2:
traverse_prefixes += [
"{0:0{1}x}".format(i, self.fs.TRAVERSE_PREFIX_LEN)
for i in range(1, pow(16, self.fs.TRAVERSE_PREFIX_LEN - 2))
]
with Tqdm(
desc="Querying "
+ (f"cache in '{name}'" if name else "remote cache"),
total=remote_size,
initial=initial,
unit="file",
) as pbar:
def list_with_update(prefix):
return list(
self.list_hashes(
prefix=prefix, progress_callback=pbar.update
)
)
with ThreadPoolExecutor(
max_workers=jobs or self.fs.JOBS
) as executor:
in_remote = executor.map(list_with_update, traverse_prefixes,)
yield from itertools.chain.from_iterable(in_remote)
def all(self, jobs=None, name=None):
"""Iterate over all hashes in this fs.
Hashes will be fetched in parallel threads according to prefix
(except for small remotes) and a progress bar will be displayed.
"""
logger.debug(
"Fetching all hashes from '{}'".format(
name if name else "remote cache"
)
)
if not self.fs.CAN_TRAVERSE:
return self.list_hashes()
remote_size, remote_hashes = self._estimate_remote_size(name=name)
return self.list_hashes_traverse(
remote_size, remote_hashes, jobs, name
)
def _remove_unpacked_dir(self, hash_):
pass
def gc(self, used, jobs=None):
removed = False
# hashes must be sorted to ensure we always remove .dir files first
for hash_ in sorted(
self.all(jobs, str(self.fs.path_info)),
key=self.fs.is_dir_hash,
reverse=True,
):
if hash_ in used:
continue
path_info = self.hash_to_path_info(hash_)
if self.fs.is_dir_hash(hash_):
# backward compatibility
# pylint: disable=protected-access
self._remove_unpacked_dir(hash_)
self.fs.remove(path_info)
removed = True
return removed
def list_hashes_exists(self, hashes, jobs=None, name=None):
"""Return list of the specified hashes which exist in this fs.
Hashes will be queried individually.
"""
logger.debug(
"Querying {} hashes via object_exists".format(len(hashes))
)
with Tqdm(
desc="Querying "
+ ("cache in " + name if name else "remote cache"),
total=len(hashes),
unit="file",
) as pbar:
def exists_with_progress(path_info):
ret = self.fs.exists(path_info)
pbar.update_msg(str(path_info))
return ret
with ThreadPoolExecutor(
max_workers=jobs or self.fs.JOBS
) as executor:
path_infos = map(self.hash_to_path_info, hashes)
in_remote = executor.map(exists_with_progress, path_infos)
ret = list(itertools.compress(hashes, in_remote))
return ret
def hashes_exist(self, hashes, jobs=None, name=None):
"""Check if the given hashes are stored in the remote.
There are two ways of performing this check:
- Traverse method: Get a list of all the files in the remote
(traversing the cache directory) and compare it with
the given hashes. Cache entries will be retrieved in parallel
threads according to prefix (i.e. entries starting with, "00...",
"01...", and so on) and a progress bar will be displayed.
- Exists method: For each given hash, run the `exists`
method and filter the hashes that aren't on the remote.
This is done in parallel threads.
It also shows a progress bar when performing the check.
The reason for such an odd logic is that most of the remotes
take much shorter time to just retrieve everything they have under
a certain prefix (e.g. s3, gs, ssh, hdfs). Other remotes that can
check if particular file exists much quicker, use their own
implementation of hashes_exist (see ssh, local).
Which method to use will be automatically determined after estimating
the size of the remote cache, and comparing the estimated size with
len(hashes). To estimate the size of the remote cache, we fetch
a small subset of cache entries (i.e. entries starting with "00...").
Based on the number of entries in that subset, the size of the full
cache can be estimated, since the cache is evenly distributed according
to hash.
Returns:
A list with hashes that were found in the remote
"""
# Remotes which do not use traverse prefix should override
# hashes_exist() (see ssh, local)
assert self.fs.TRAVERSE_PREFIX_LEN >= 2
hashes = set(hashes)
if len(hashes) == 1 or not self.fs.CAN_TRAVERSE:
remote_hashes = self.list_hashes_exists(hashes, jobs, name)
return remote_hashes
# Max remote size allowed for us to use traverse method
remote_size, remote_hashes = self._estimate_remote_size(hashes, name)
traverse_pages = remote_size / self.fs.LIST_OBJECT_PAGE_SIZE
# For sufficiently large remotes, traverse must be weighted to account
# for performance overhead from large lists/sets.
# From testing with S3, for remotes with 1M+ files, object_exists is
# faster until len(hashes) is at least 10k~100k
if remote_size > self.fs.TRAVERSE_THRESHOLD_SIZE:
traverse_weight = (
traverse_pages * self.fs.TRAVERSE_WEIGHT_MULTIPLIER
)
else:
traverse_weight = traverse_pages
if len(hashes) < traverse_weight:
logger.debug(
"Large remote ('{}' hashes < '{}' traverse weight), "
"using object_exists for remaining hashes".format(
len(hashes), traverse_weight
)
)
return list(hashes & remote_hashes) + self.list_hashes_exists(
hashes - remote_hashes, jobs, name
)
logger.debug("Querying '{}' hashes via traverse".format(len(hashes)))
remote_hashes = set(
self.list_hashes_traverse(remote_size, remote_hashes, jobs, name)
)
return list(hashes & set(remote_hashes))
+5
View File
@@ -0,0 +1,5 @@
from .base import ObjectDB
class GDriveObjectDB(ObjectDB):
DEFAULT_VERIFY = True
+172
View File
@@ -0,0 +1,172 @@
import logging
import os
import stat
from funcy import cached_property
from shortuuid import uuid
from dvc.hash_info import HashInfo
from dvc.objects import ObjectFormatError
from dvc.path_info import PathInfo
from dvc.progress import Tqdm
from dvc.utils import relpath
from dvc.utils.fs import copyfile, remove, umask, walk_files
from .base import ObjectDB
logger = logging.getLogger(__name__)
class LocalObjectDB(ObjectDB):
DEFAULT_CACHE_TYPES = ["reflink", "copy"]
CACHE_MODE = 0o444
UNPACKED_DIR_SUFFIX = ".unpacked"
def __init__(self, fs):
super().__init__(fs)
self.cache_dir = fs.config.get("url")
shared = fs.config.get("shared")
if shared:
self._file_mode = 0o664
self._dir_mode = 0o2775
else:
self._file_mode = 0o666 & ~umask
self._dir_mode = 0o777 & ~umask
@property
def cache_dir(self):
return self.fs.path_info.fspath if self.fs.path_info else None
@cache_dir.setter
def cache_dir(self, value):
self.fs.path_info = PathInfo(value) if value else None
@cached_property
def cache_path(self):
return os.path.abspath(self.cache_dir)
def move(self, from_info, to_info):
super().move(from_info, to_info)
os.chmod(to_info, self._file_mode)
def makedirs(self, path_info):
from dvc.utils.fs import makedirs
makedirs(path_info, exist_ok=True, mode=self._dir_mode)
def hash_to_path(self, hash_):
# NOTE: `self.cache_path` is already normalized so we can simply use
# `os.sep` instead of `os.path.join`. This results in this helper
# being ~5.5 times faster.
return f"{self.cache_path}{os.sep}{hash_[0:2]}{os.sep}{hash_[2:]}"
def hashes_exist(
self, hashes, jobs=None, name=None
): # pylint: disable=unused-argument
ret = []
for hash_ in Tqdm(
hashes,
unit="file",
desc="Querying " + ("cache in " + name if name else "local cache"),
):
hash_info = HashInfo(self.fs.PARAM_CHECKSUM, hash_)
try:
self.check(hash_info)
ret.append(hash_)
except (FileNotFoundError, ObjectFormatError):
pass
return ret
def _list_paths(self, prefix=None, progress_callback=None):
assert self.fs.path_info is not None
if prefix:
path_info = self.fs.path_info / prefix[:2]
if not self.fs.exists(path_info):
return
else:
path_info = self.fs.path_info
# NOTE: use utils.fs walk_files since fs.walk_files will not follow
# symlinks
if progress_callback:
for path in walk_files(path_info):
progress_callback()
yield path
else:
yield from walk_files(path_info)
def _remove_unpacked_dir(self, hash_):
info = self.hash_to_path_info(hash_)
path_info = info.with_name(info.name + self.UNPACKED_DIR_SUFFIX)
self.fs.remove(path_info)
def _unprotect_file(self, path):
if self.fs.is_symlink(path) or self.fs.is_hardlink(path):
logger.debug("Unprotecting '%s'", path)
tmp = os.path.join(os.path.dirname(path), "." + uuid())
# The operations order is important here - if some application
# would access the file during the process of copyfile then it
# would get only the part of file. So, at first, the file should be
# copied with the temporary name, and then original file should be
# replaced by new.
copyfile(path, tmp, name="Unprotecting '{}'".format(relpath(path)))
remove(path)
os.rename(tmp, path)
else:
logger.debug(
"Skipping copying for '%s', since it is not "
"a symlink or a hardlink.",
path,
)
os.chmod(path, self._file_mode)
def _unprotect_dir(self, path):
for fname in self.fs.walk_files(path):
self._unprotect_file(fname)
def unprotect(self, path_info):
if not os.path.exists(path_info):
from dvc.exceptions import DvcException
raise DvcException(
f"can't unprotect non-existing data '{path_info}'"
)
if os.path.isdir(path_info):
self._unprotect_dir(path_info)
else:
self._unprotect_file(path_info)
def protect(self, path_info):
try:
os.chmod(path_info, self.CACHE_MODE)
except OSError:
# NOTE: not being able to protect cache file is not fatal, it
# might happen on funky filesystems (e.g. Samba, see #5255),
# read-only filesystems or in a shared cache scenario.
logger.trace("failed to protect '%s'", path_info, exc_info=True)
def is_protected(self, path_info):
try:
mode = os.stat(path_info).st_mode
except FileNotFoundError:
return False
return stat.S_IMODE(mode) == self.CACHE_MODE
def set_exec(self, path_info):
mode = os.stat(path_info).st_mode | stat.S_IEXEC
try:
os.chmod(path_info, mode)
except OSError:
logger.trace(
"failed to chmod '%s' '%s'",
oct(mode),
path_info,
exc_info=True,
)
+11 -11
View File
@@ -7,12 +7,12 @@ from concurrent.futures import ThreadPoolExecutor
from dvc.progress import Tqdm
from dvc.utils import to_chunks
from .base import CloudCache
from .base import ObjectDB
logger = logging.getLogger(__name__)
class SSHCache(CloudCache):
class SSHObjectDB(ObjectDB):
def batch_exists(self, path_infos, callback):
def _exists(chunk_and_channel):
chunk, channel = chunk_and_channel
@@ -28,7 +28,7 @@ class SSHCache(CloudCache):
callback(path)
return ret
with self.tree.ssh(path_infos[0]) as ssh:
with self.fs.ssh(path_infos[0]) as ssh:
channels = ssh.open_max_sftp_channels()
max_workers = len(channels)
@@ -47,11 +47,11 @@ class SSHCache(CloudCache):
faster than current approach (relying on exists(path_info)) applied in
remote/base.
"""
if not self.tree.CAN_TRAVERSE:
if not self.fs.CAN_TRAVERSE:
return list(set(hashes) & set(self.all()))
# possibly prompt for credentials before "Querying" progress output
self.tree.ensure_credentials()
self.fs.ensure_credentials()
with Tqdm(
desc="Querying "
@@ -64,10 +64,10 @@ class SSHCache(CloudCache):
return self.batch_exists(chunks, callback=pbar.update_msg)
with ThreadPoolExecutor(
max_workers=jobs or self.tree.JOBS
max_workers=jobs or self.fs.JOBS
) as executor:
path_infos = [self.tree.hash_to_path_info(x) for x in hashes]
chunks = to_chunks(path_infos, num_chunks=self.tree.JOBS)
path_infos = [self.hash_to_path_info(x) for x in hashes]
chunks = to_chunks(path_infos, num_chunks=self.fs.JOBS)
results = executor.map(exists_with_progress, chunks)
in_remote = itertools.chain.from_iterable(results)
ret = list(itertools.compress(hashes, in_remote))
@@ -75,10 +75,10 @@ class SSHCache(CloudCache):
def _list_paths(self, prefix=None, progress_callback=None):
if prefix:
root = posixpath.join(self.tree.path_info.path, prefix[:2])
root = posixpath.join(self.fs.path_info.path, prefix[:2])
else:
root = self.tree.path_info.path
with self.tree.ssh(self.tree.path_info) as ssh:
root = self.fs.path_info.path
with self.fs.ssh(self.fs.path_info) as ssh:
if prefix and not ssh.exists(root):
return
# If we simply return an iterator then with above closes instantly
+152
View File
@@ -0,0 +1,152 @@
import errno
import os
from concurrent.futures import ThreadPoolExecutor
from dvc.exceptions import DvcIgnoreInCollectedDirError
from dvc.hash_info import HashInfo
from dvc.ignore import DvcIgnore
from dvc.progress import Tqdm
from dvc.utils import file_md5
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"])
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
def _calculate_hashes(path_info, fs, name, state, **kwargs):
def _get_file_hash(file_info):
hash_info = state.get( # pylint: disable=assignment-from-none
file_info, fs,
)
if not hash_info:
hash_info = get_file_hash(file_info, fs, name)
state.save(file_info, fs, hash_info)
return file_info, hash_info
with Tqdm(
unit="md5", desc="Computing file/dir hashes (only done once)",
) as pbar:
worker = pbar.wrap_fn(_get_file_hash)
with ThreadPoolExecutor(max_workers=fs.hash_jobs) as executor:
pairs = executor.map(worker, fs.walk_files(path_info, **kwargs))
return dict(pairs)
def _iter_hashes(path_info, fs, name, state, **kwargs):
if name in fs.DETAIL_FIELDS:
for details in fs.ls(path_info, recursive=True, detail=True):
file_info = path_info.replace(path=details["name"])
hash_info = HashInfo(
name, details[name], size=details.get("size"),
)
yield file_info, hash_info
return None
yield from _calculate_hashes(path_info, fs, name, state, **kwargs).items()
def _collect_dir(path_info, fs, name, state, **kwargs):
from dvc.dir_info import DirInfo
dir_info = DirInfo()
for fi, hi in _iter_hashes(path_info, fs, name, state, **kwargs):
if DvcIgnore.DVCIGNORE_FILE == fi.name:
raise DvcIgnoreInCollectedDirError(fi.parent)
# NOTE: this is lossy transformation:
# "hey\there" -> "hey/there"
# "hey/there" -> "hey/there"
# The latter is fine filename on Windows, which
# will transform to dir/file on back transform.
#
# Yes, this is a BUG, as long as we permit "/" in
# filenames on Windows and "\" on Unix
dir_info.add(fi.relative_to(path_info).parts, hi)
return dir_info
def get_dir_hash(path_info, fs, name, odb, state, **kwargs):
from . import Tree
value = fs.info(path_info).get(name)
if value:
hash_info = HashInfo(name, value)
try:
Tree.load(odb, hash_info)
return hash_info
except FileNotFoundError:
pass
dir_info = _collect_dir(path_info, fs, name, state, **kwargs)
hash_info = Tree.save_dir_info(fs.repo.odb.local, dir_info)
hash_info.size = dir_info.size
hash_info.dir_info = dir_info
return hash_info
def get_hash(path_info, fs, name, odb, **kwargs):
assert path_info and (
isinstance(path_info, str) or path_info.scheme == fs.scheme
)
if not fs.exists(path_info):
raise FileNotFoundError(
errno.ENOENT, os.strerror(errno.ENOENT), path_info
)
state = odb.repo.state
# pylint: disable=assignment-from-none
hash_info = state.get(path_info, fs)
# If we have dir hash in state db, but dir cache file is lost,
# then we need to recollect the dir via .get_dir_hash() call below,
# see https://github.com/iterative/dvc/issues/2219 for context
if (
hash_info
and hash_info.isdir
and not odb.fs.exists(odb.hash_to_path_info(hash_info.value))
):
hash_info = None
if hash_info:
if hash_info.isdir:
from . import Tree
# NOTE: loading the fs will restore hash_info.dir_info
Tree.load(odb, hash_info)
assert hash_info.name == name
return hash_info
if fs.isdir(path_info):
hash_info = get_dir_hash(path_info, fs, name, odb, state, **kwargs)
else:
hash_info = get_file_hash(path_info, fs, name)
if hash_info and fs.exists(path_info):
state.save(path_info, fs, hash_info)
return hash_info
def stage(odb, path_info, fs, **kwargs):
from . import File, Tree
if fs.isdir(path_info):
return Tree.stage(odb, path_info, fs, **kwargs)
return File.stage(odb, path_info, fs, **kwargs)
+59 -45
View File
@@ -6,7 +6,6 @@ from voluptuous import And, Any, Coerce, Length, Lower, Required, SetTo
from dvc.hash_info import HashInfo
from dvc.output.base import BaseOutput
from dvc.output.gs import GSOutput
from dvc.output.hdfs import HDFSOutput
from dvc.output.local import LocalOutput
from dvc.output.s3 import S3Output
@@ -14,25 +13,15 @@ from dvc.output.ssh import SSHOutput
from dvc.output.webhdfs import WebHDFSOutput
from dvc.scheme import Schemes
from ..tree import get_cloud_tree
from ..tree.hdfs import HDFSTree
from ..tree.local import LocalTree
from ..tree.s3 import S3Tree
from ..tree.webhdfs import WebHDFSTree
OUTS = [
HDFSOutput,
S3Output,
GSOutput,
SSHOutput,
WebHDFSOutput,
# NOTE: LocalOutput is the default choice
]
from ..fs import get_cloud_fs
from ..fs.hdfs import HDFSFileSystem
from ..fs.local import LocalFileSystem
from ..fs.s3 import S3FileSystem
from ..fs.webhdfs import WebHDFSFileSystem
OUTS_MAP = {
Schemes.HDFS: HDFSOutput,
Schemes.S3: S3Output,
Schemes.GS: GSOutput,
Schemes.SSH: SSHOutput,
Schemes.LOCAL: LocalOutput,
Schemes.WEBHDFS: WebHDFSOutput,
@@ -46,17 +35,17 @@ CHECKSUM_SCHEMA = Any(
# NOTE: currently there are only 3 possible checksum names:
#
# 1) md5 (LOCAL, SSH, GS);
# 1) md5 (LOCAL, SSH);
# 2) etag (S3);
# 3) checksum (HDFS);
#
# so when a few types of outputs share the same name, we only need
# specify it once.
CHECKSUMS_SCHEMA = {
LocalTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
S3Tree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
HDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
WebHDFSTree.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
LocalFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
S3FileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
HDFSFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
WebHDFSFileSystem.PARAM_CHECKSUM: CHECKSUM_SCHEMA,
}
SCHEMA = CHECKSUMS_SCHEMA.copy()
@@ -69,6 +58,7 @@ SCHEMA[BaseOutput.PARAM_CHECKPOINT] = bool
SCHEMA[HashInfo.PARAM_SIZE] = int
SCHEMA[HashInfo.PARAM_NFILES] = int
SCHEMA[BaseOutput.PARAM_DESC] = str
SCHEMA[BaseOutput.PARAM_ISEXEC] = bool
def _get(
@@ -80,50 +70,43 @@ def _get(
plot=False,
persist=False,
checkpoint=False,
live=False,
desc=None,
isexec=False,
):
parsed = urlparse(p)
if parsed.scheme == "remote":
tree = get_cloud_tree(stage.repo, name=parsed.netloc)
return OUTS_MAP[tree.scheme](
fs = get_cloud_fs(stage.repo, name=parsed.netloc)
return OUTS_MAP[fs.scheme](
stage,
p,
info,
cache=cache,
tree=tree,
fs=fs,
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
live=live,
desc=desc,
isexec=isexec,
)
for o in OUTS:
if o.supported(p):
return o(
stage,
p,
info,
cache=cache,
tree=None,
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
desc=desc,
)
return LocalOutput(
out_cls = OUTS_MAP.get(parsed.scheme, LocalOutput)
return out_cls(
stage,
p,
info,
cache=cache,
tree=None,
fs=None,
metric=metric,
plot=plot,
persist=persist,
checkpoint=checkpoint,
live=live,
desc=desc,
isexec=isexec,
)
@@ -137,6 +120,8 @@ def loadd_from(stage, d_list):
persist = d.pop(BaseOutput.PARAM_PERSIST, False)
checkpoint = d.pop(BaseOutput.PARAM_CHECKPOINT, False)
desc = d.pop(BaseOutput.PARAM_DESC, False)
isexec = d.pop(BaseOutput.PARAM_ISEXEC, False)
live = d.pop(BaseOutput.PARAM_LIVE, False)
ret.append(
_get(
stage,
@@ -148,6 +133,8 @@ def loadd_from(stage, d_list):
persist=persist,
checkpoint=checkpoint,
desc=desc,
isexec=isexec,
live=live,
)
)
return ret
@@ -161,6 +148,8 @@ def loads_from(
plot=False,
persist=False,
checkpoint=False,
isexec=False,
live=False,
):
return [
_get(
@@ -172,6 +161,8 @@ def loads_from(
plot=plot,
persist=persist,
checkpoint=checkpoint,
isexec=isexec,
live=live,
)
for s in s_list
]
@@ -200,21 +191,35 @@ def _merge_data(s_list):
@collecting
def load_from_pipeline(stage, s_list, typ="outs"):
if typ not in (stage.PARAM_OUTS, stage.PARAM_METRICS, stage.PARAM_PLOTS):
def load_from_pipeline(stage, data, typ="outs"):
if typ not in (
stage.PARAM_OUTS,
stage.PARAM_METRICS,
stage.PARAM_PLOTS,
stage.PARAM_LIVE,
):
raise ValueError(f"'{typ}' key is not allowed for pipeline files.")
metric = typ == stage.PARAM_METRICS
plot = typ == stage.PARAM_PLOTS
live = typ == stage.PARAM_LIVE
d = _merge_data(s_list)
if live:
# `live` is single object
data = [data]
d = _merge_data(data)
for path, flags in d.items():
plt_d = {}
plt_d, live_d = {}, {}
if plot:
from dvc.schema import PLOT_PROPS
plt_d, flags = _split_dict(flags, keys=PLOT_PROPS.keys())
if live:
from dvc.schema import LIVE_PROPS
live_d, flags = _split_dict(flags, keys=LIVE_PROPS.keys())
extra = project(
flags,
[
@@ -223,4 +228,13 @@ def load_from_pipeline(stage, s_list, typ="outs"):
BaseOutput.PARAM_CHECKPOINT,
],
)
yield _get(stage, path, {}, plot=plt_d or plot, metric=metric, **extra)
yield _get(
stage,
path,
{},
plot=plt_d or plot,
metric=metric,
live=live_d or live,
**extra,
)

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